fix(apps): resolve latent host-port collisions + detect at load time
The strfry/vaultwarden 8082 collision shipped in a signed catalog because nothing checks cross-app host ports. Fix the remaining latent pairs from the cfb8d953 audit — did-wallet 8083→8088 (filebrowser keeps 8083), morphos-server 8086→8089 (netbird-server keeps 8086), lightning-stack 8087→8091/9737→9738 (netbird and fedimint-gateway keep theirs) — and fix their healthchecks, which probed the HOST port inside the container (same class as strfry 6fe62355; now 127.0.0.1 + container port). load_manifests now flags collisions across the merged disk+catalog set (bitcoin variants and the mempool umbrella pair are exempt as mutually exclusive), and a repo-level test parses apps/*/manifest.yml so a future latent pair fails CI instead of shipping. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
9b1c090e21
commit
ebd904262e
@ -27,7 +27,7 @@ app:
|
|||||||
apparmor_profile: did-wallet
|
apparmor_profile: did-wallet
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 8083
|
- host: 8088
|
||||||
container: 8080
|
container: 8080
|
||||||
protocol: tcp # Web UI
|
protocol: tcp # Web UI
|
||||||
|
|
||||||
@ -42,7 +42,7 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8083
|
endpoint: http://127.0.0.1:8080
|
||||||
path: /health
|
path: /health
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@ -29,13 +29,13 @@ app:
|
|||||||
apparmor_profile: lightning-stack
|
apparmor_profile: lightning-stack
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 9737
|
- host: 9738
|
||||||
container: 9735
|
container: 9735
|
||||||
protocol: tcp # P2P
|
protocol: tcp # P2P
|
||||||
- host: 10010
|
- host: 10010
|
||||||
container: 10009
|
container: 10009
|
||||||
protocol: tcp # gRPC
|
protocol: tcp # gRPC
|
||||||
- host: 8087
|
- host: 8091
|
||||||
container: 8080
|
container: 8080
|
||||||
protocol: tcp # REST/Web UI
|
protocol: tcp # REST/Web UI
|
||||||
|
|
||||||
@ -53,7 +53,7 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8087
|
endpoint: http://127.0.0.1:8080
|
||||||
path: /v1/getinfo
|
path: /v1/getinfo
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@ -27,7 +27,7 @@ app:
|
|||||||
apparmor_profile: morphos-server
|
apparmor_profile: morphos-server
|
||||||
|
|
||||||
ports:
|
ports:
|
||||||
- host: 8086
|
- host: 8089
|
||||||
container: 8080
|
container: 8080
|
||||||
protocol: tcp # Web UI
|
protocol: tcp # Web UI
|
||||||
|
|
||||||
@ -43,7 +43,7 @@ app:
|
|||||||
|
|
||||||
health_check:
|
health_check:
|
||||||
type: http
|
type: http
|
||||||
endpoint: http://localhost:8086
|
endpoint: http://127.0.0.1:8080
|
||||||
path: /health
|
path: /health
|
||||||
interval: 30s
|
interval: 30s
|
||||||
timeout: 5s
|
timeout: 5s
|
||||||
|
|||||||
@ -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<Item = &'m AppManifest>,
|
||||||
|
) -> 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<u16, Vec<&str>> = 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
|
/// Internal: track a manifest together with the absolute directory it was loaded
|
||||||
/// from, so Build sources can resolve relative `context:` paths.
|
/// from, so Build sources can resolve relative `context:` paths.
|
||||||
#[derive(Debug, Clone)]
|
#[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())
|
Ok(state.manifests.len())
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -5269,6 +5310,58 @@ app:
|
|||||||
assert!(!calls.iter().any(|c| c.starts_with("start_container:")));
|
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]
|
#[test]
|
||||||
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
fn cascade_pairs_cover_backend_recreate_with_running_dependent() {
|
||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user