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
|
||||
|
||||
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
/// 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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user