diff --git a/apps/did-wallet/manifest.yml b/apps/did-wallet/manifest.yml index d00974b1..c89f6561 100644 --- a/apps/did-wallet/manifest.yml +++ b/apps/did-wallet/manifest.yml @@ -27,7 +27,7 @@ app: apparmor_profile: did-wallet ports: - - host: 8083 + - host: 8088 container: 8080 protocol: tcp # Web UI @@ -42,7 +42,7 @@ app: health_check: type: http - endpoint: http://localhost:8083 + endpoint: http://127.0.0.1:8080 path: /health interval: 30s timeout: 5s diff --git a/apps/lightning-stack/manifest.yml b/apps/lightning-stack/manifest.yml index 8e7c6e2f..4c5e4431 100644 --- a/apps/lightning-stack/manifest.yml +++ b/apps/lightning-stack/manifest.yml @@ -29,13 +29,13 @@ app: apparmor_profile: lightning-stack ports: - - host: 9737 + - host: 9738 container: 9735 protocol: tcp # P2P - host: 10010 container: 10009 protocol: tcp # gRPC - - host: 8087 + - host: 8091 container: 8080 protocol: tcp # REST/Web UI @@ -53,7 +53,7 @@ app: health_check: type: http - endpoint: http://localhost:8087 + endpoint: http://127.0.0.1:8080 path: /v1/getinfo interval: 30s timeout: 5s diff --git a/apps/morphos-server/manifest.yml b/apps/morphos-server/manifest.yml index d506cf68..dd032bf4 100644 --- a/apps/morphos-server/manifest.yml +++ b/apps/morphos-server/manifest.yml @@ -27,7 +27,7 @@ app: apparmor_profile: morphos-server ports: - - host: 8086 + - host: 8089 container: 8080 protocol: tcp # Web UI @@ -43,7 +43,7 @@ app: health_check: type: http - endpoint: http://localhost:8086 + endpoint: http://127.0.0.1:8080 path: /health interval: 30s timeout: 5s diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 2639fe2c..78a36479 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -1032,6 +1032,37 @@ fn resolve_catalog_image(resolved_manifest: &mut AppManifest) { } } +/// Host-port collisions across a set of manifests, excluding pairs that are +/// mutually exclusive by design (the bitcoin-core/knots variants; the legacy +/// mempool umbrella vs its split frontend, which render the same container). +/// The strfry/vaultwarden 8082 collision shipped in a SIGNED catalog +/// (2026-07-09) because nothing checked — surface these loudly at load time. +fn host_port_collisions<'m>( + manifests: impl Iterator, +) -> Vec<(u16, String, String)> { + fn mutually_exclusive(a: &str, b: &str) -> bool { + let pair = |x: &str, y: &str| (a == x && b == y) || (a == y && b == x); + pair("bitcoin-core", "bitcoin-knots") || pair("mempool", "archy-mempool-web") + } + let mut by_port: std::collections::BTreeMap> = Default::default(); + for m in manifests { + for p in &m.app.ports { + by_port.entry(p.host).or_default().push(m.app.id.as_str()); + } + } + let mut out = Vec::new(); + for (port, apps) in by_port { + for i in 0..apps.len() { + for j in i + 1..apps.len() { + if apps[i] != apps[j] && !mutually_exclusive(apps[i], apps[j]) { + out.push((port, apps[i].to_string(), apps[j].to_string())); + } + } + } + } + out +} + /// Internal: track a manifest together with the absolute directory it was loaded /// from, so Build sources can resolve relative `context:` paths. #[derive(Debug, Clone)] @@ -1387,6 +1418,16 @@ impl ProdContainerOrchestrator { ); } } + for (port, a, b) in + host_port_collisions(state.manifests.values().map(|lm| &lm.manifest)) + { + tracing::error!( + port, + app_a = %a, + app_b = %b, + "host-port collision between app manifests — installing both will break whichever starts second" + ); + } Ok(state.manifests.len()) } @@ -5269,6 +5310,58 @@ app: assert!(!calls.iter().any(|c| c.starts_with("start_container:"))); } + #[test] + fn host_port_collisions_flags_pairs_and_skips_exclusive_variants() { + let with_port = |id: &str, port: u16| { + let yaml = format!( + "app:\n id: {id}\n name: {id}\n version: 1.0.0\n container:\n image: {id}:1\n ports:\n - host: {port}\n container: 80\n protocol: tcp\n" + ); + AppManifest::parse(&yaml).unwrap() + }; + let a = with_port("strfry", 8082); + let b = with_port("vaultwarden", 8082); + let c = with_port("grafana", 3001); + let core = with_port("bitcoin-core", 8332); + let knots = with_port("bitcoin-knots", 8332); + + let found = host_port_collisions([&a, &b, &c, &core, &knots].into_iter()); + assert_eq!( + found, + vec![(8082, "strfry".to_string(), "vaultwarden".to_string())] + ); + } + + #[test] + fn repo_app_manifests_have_no_host_port_collisions() { + // The signed catalog regenerates from apps/*/manifest.yml — a latent + // collision here ships fleet-wide at the next ceremony (strfry/ + // vaultwarden 8082, found live 2026-07-09). Keep the tree clean. + let apps_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../apps"); + if !apps_dir.exists() { + return; // not a full checkout (e.g. crate published standalone) + } + let mut manifests = Vec::new(); + for entry in std::fs::read_dir(&apps_dir).unwrap() { + let path = entry.unwrap().path().join("manifest.yml"); + if !path.exists() { + continue; + } + if let Ok(m) = AppManifest::from_file(&path) { + manifests.push(m); + } + } + assert!( + manifests.len() > 10, + "expected to parse the repo app manifests, got {}", + manifests.len() + ); + let found = host_port_collisions(manifests.iter()); + assert!( + found.is_empty(), + "host-port collisions in apps/ manifests: {found:?}" + ); + } + #[test] fn cascade_pairs_cover_backend_recreate_with_running_dependent() { use std::collections::HashSet;