Add headless Angor services and shared-index install guard
Demo images / Build & push demo images (push) Failing after 43s

This commit is contained in:
archipelago
2026-09-30 11:52:19 -04:00
parent 7c4169867c
commit 169bf77de6
24 changed files with 828 additions and 11 deletions
@@ -22,6 +22,14 @@ const ARCHIVAL_BITCOIN_DEPENDENCY: &str = "bitcoin:archival";
/// hardcoded id list below — a new app just declares the dependency instead
/// of needing a code change here.
fn manifest_declares_archival_bitcoin(package_id: &str) -> bool {
// Registry-only apps need the same guard as OTA-bundled manifests. Honor
// the verified catalog's effective manifest before the disk fallback.
if let Some((_, value)) = crate::container::app_catalog::catalog_manifest_values()
.into_iter().find(|(id, _)| id == package_id) {
if let Some(manifest) = crate::container::app_catalog::catalog_manifest_overlay(package_id, value) {
return dependency_list_declares_archival_bitcoin(&manifest.app.dependencies);
}
}
for apps_dir in manifest_apps_dirs() {
let path = apps_dir.join(package_id).join("manifest.yml");
let Ok(contents) = std::fs::read_to_string(&path) else {
@@ -1055,6 +1063,9 @@ mod tests {
// edit to `requires_unpruned_bitcoin`.
assert!(manifest_declares_archival_bitcoin("electrumx"));
assert!(manifest_declares_archival_bitcoin("mempool"));
let angor = archipelago_container::AppManifest::parse(include_str!(concat!(env!("CARGO_MANIFEST_DIR"),
"/../../apps/angor-indexer/manifest.yml"))).unwrap();
assert!(dependency_list_declares_archival_bitcoin(&angor.app.dependencies));
// An app whose manifest exists but never declares the marker.
assert!(!manifest_declares_archival_bitcoin("bitcoin-knots"));
// An id with no manifest on disk at all.
@@ -573,6 +573,9 @@ impl RpcHandler {
"message": format!("Package {} installed and started", package_id)
}));
}
Err(e) if e.downcast_ref::<crate::container::prod_orchestrator::InstallPrerequisiteError>().is_some() => {
return Err(super::dependencies::DependencyGateError(e.to_string()).into());
}
Err(e) if is_unknown_app_id_error(&e) => {
info!(
"Install {}: orchestrator has no manifest mapping yet, falling back to legacy installer",
@@ -36,6 +36,11 @@ use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::sync::{Mutex, RwLock};
/// Refusal before installation has created state or changed any dependency.
#[derive(Debug, thiserror::Error)]
#[error("{0}")]
pub struct InstallPrerequisiteError(pub String);
use crate::config::{Config, ContainerRuntime as ConfigContainerRuntime};
use crate::container::bitcoin_ui;
use crate::container::quadlet;
@@ -3904,7 +3909,7 @@ impl ProdContainerOrchestrator {
let exists = tokio::process::Command::new("podman")
.args(["container", "exists", name]).status().await?;
if exists.code() != Some(1) {
anyhow::bail!("cannot verify existing container before network migration backup");
anyhow::bail!("cannot verify existing container before runtime migration backup");
}
false
};
@@ -3922,7 +3927,7 @@ impl ProdContainerOrchestrator {
}
match crate::container::migration_backup::snapshot(manifest, &self.data_dir, previous_unit.as_deref()).await {
Ok(archive) => {
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before network migration");
tracing::info!(container = %name, backup = %archive.display(), "Persistent state saved before runtime migration");
Ok(())
}
Err(error) => {
@@ -4551,6 +4556,28 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
}
async fn install(&self, app_id: &str) -> Result<String> {
let lm = self.loaded(app_id).await?;
// Optional shared-service preconditions are checked before recording
// installation or creating anything. A headless adapter must not claim
// successful installation against a missing indexing stack.
if let Some(required) = lm.manifest.app.extensions.get("install_prerequisites")
.and_then(|value| value.as_sequence()) {
let present = self.runtime.list_containers().await
.context("check installed prerequisite services")?;
for id in required.iter().filter_map(|value| value.as_str()) {
let dependency = self.loaded(id).await.map_err(|_| InstallPrerequisiteError(
format!("Required app {id} is unavailable. Refresh the app catalog before installing {}.",
lm.manifest.app.name)))?;
let name = compute_container_name(&dependency.manifest);
if !present.iter().any(|container| container.name.trim_start_matches('/') == name) {
let owner = crate::app_ops::owning_package(id);
let title = self.loaded(owner).await.map(|app| app.manifest.app.name)
.unwrap_or(dependency.manifest.app.name);
return Err(InstallPrerequisiteError(format!(
"Install {title} first, then install {}.", lm.manifest.app.name)).into());
}
}
}
{
let mut state = self.state.write().await;
state.disabled.remove(app_id);
@@ -4577,7 +4604,6 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
// health verification (the .228 "running but unreachable" failure
// mode). Routing every install through here means the orchestrator
// is the one source of truth for what "installed" means.
let lm = self.loaded(app_id).await?;
let name = compute_container_name(&lm.manifest);
// ensure_running takes the per-app lock itself; release the install
// path lock first if we hold one (we don't — install is the entry
@@ -5746,6 +5772,26 @@ app:
orch
}
#[tokio::test]
async fn missing_install_prerequisite_refuses_without_inventory_or_container_mutation() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
let mut app = pull_manifest("indexer-adapter", "docker.io/library/alpine:3.20");
app.app.extensions.insert("install_prerequisites".into(),
serde_yaml::to_value(vec!["shared-index"]).unwrap());
orch.insert_manifest_for_test(app, PathBuf::from("/tmp")).await;
orch.insert_manifest_for_test(pull_manifest("shared-index", "index:1"), PathBuf::from("/tmp")).await;
let error = orch.install("indexer-adapter").await.unwrap_err();
assert!(error.downcast_ref::<InstallPrerequisiteError>().is_some());
assert!(!crate::crash_recovery::load_installed_apps(&orch.data_dir).await.contains("indexer-adapter"));
assert_eq!(rt.calls(), vec!["list_containers"]);
// An installed prerequisite satisfies the guard; it is never recreated
// or reconfigured as part of installing this adapter.
rt.set_state("shared-index", ContainerState::Running);
orch.install("indexer-adapter").await.unwrap();
assert!(!rt.calls().iter().any(|c| c.starts_with("create_container:shared-index")));
}
fn pull_manifest_with_dynamic_env(id: &str, image: &str) -> AppManifest {
let yaml = format!(
"app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {image}\n derived_env:\n - key: FM_API_URL\n template: \"ws://{{{{HOST_MDNS}}}}:8174\"\n secret_env:\n - key: FM_BITCOIND_PASSWORD\n secret_file: bitcoin-rpc-password\n environment:\n - STATIC=1\n"
+39 -3
View File
@@ -6,7 +6,43 @@
//! no listener, so allowing them is inert.
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8090,
8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8337, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 18091, 23000, 32838, 50002,
2283,
2342,
3000,
3001,
3002,
4080,
5180,
7778,
8080,
8081,
8082,
8083,
8084,
8085,
8087,
8090,
8091,
8096,
8123,
8175,
8176,
8187,
8240,
8334,
8336,
8337,
8888,
8998,
8999,
9000,
9100,
10380,
11434,
18081,
18083,
18091,
23000,
32838,
50002,
];
+25
View File
@@ -989,6 +989,18 @@ impl AppManifest {
validate_security(&self.app.security)?;
validate_ports(&self.app.ports)?;
validate_interfaces(&self.app.interfaces)?;
if let Some(value) = self.app.extensions.get("install_prerequisites") {
let items = value.as_sequence().ok_or_else(|| ManifestError::Invalid(
"install_prerequisites must be a list of app ids".into()))?;
for item in items {
let id = item.as_str().unwrap_or_default();
if id.is_empty() || id == self.app.id || !id.bytes().all(|b|
b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') {
return Err(ManifestError::Invalid(
"install_prerequisites must contain valid other app ids".into()));
}
}
}
validate_environment(&self.app.environment)?;
validate_devices(&self.app.devices)?;
@@ -1805,9 +1817,14 @@ app:
// nginx-proxy-manager 8081 (NPM admin accounts), tailscale 8240
// (tailnet login on the web console). Both enforce their own login,
// and an operator can re-gate either from Settings → Access control.
// Angor's indexer exposes public chain data/transaction broadcast;
// its optional standalone relay accepts signed public Nostr events.
// Neither mounts credentials or the node's internal relay database.
assert_eq!(
open,
vec![
("angor-indexer".to_string(), 8998u16),
("angor-relay".to_string(), 8091u16),
("btcpay-server".to_string(), 23000u16),
("cuprate".to_string(), 18090u16),
("gitea".to_string(), 3001u16),
@@ -1818,6 +1835,14 @@ app:
);
}
#[test]
fn invalid_install_prerequisites_are_rejected() {
for value in ["not-a-list", "[demo]", "['../other']", "[false]", "['']"] {
let yaml = format!("app:\n id: demo\n name: Demo\n version: 1.0.0\n container:\n image: docker.io/library/alpine:3.20\n install_prerequisites: {value}\n");
assert!(AppManifest::parse(&yaml).unwrap_err().to_string().contains("install_prerequisites"));
}
}
#[test]
fn an_undeclared_port_classifies_as_session_but_is_not_declared() {
// Two different questions, and conflating them caused both gate