diff --git a/core/archipelago/src/api/rpc/package/dependencies.rs b/core/archipelago/src/api/rpc/package/dependencies.rs index ccb8e752..5725248c 100644 --- a/core/archipelago/src/api/rpc/package/dependencies.rs +++ b/core/archipelago/src/api/rpc/package/dependencies.rs @@ -611,12 +611,53 @@ pub(super) async fn ordered_containers_for_start(package_id: &str) -> Result &'static [&'static str] { + match package_id { + "immich" => &["immich-postgres", "immich-redis", "immich"], + "indeedhub" => &[ + "indeedhub-postgres", + "indeedhub-redis", + "indeedhub-minio", + "indeedhub-relay", + "indeedhub-api", + "indeedhub-ffmpeg", + "indeedhub", + ], + "btcpay-server" | "btcpayserver" | "btcpay" => { + &["archy-btcpay-db", "archy-nbxplorer", "btcpay-server"] + } + "netbird" => &["netbird-server", "netbird-dashboard", "netbird"], + // The legacy umbrella id maps to the split stack (the orchestrator's + // umbrella alias handles this too; listing it here keeps the RPC + // layer's fan-out explicit). + "mempool" | "mempool-web" => { + &["archy-mempool-db", "mempool-api", "archy-mempool-web"] + } + _ => &[], + } +} + fn order_present_containers(package_id: &str, containers: Vec) -> Vec { if containers.is_empty() { - // Nothing is live under any known name. Fall back to the package id so - // a single-container app whose container matches its id still gets one - // start attempt; multi-container stacks with no live members are - // surfaced as "no containers" by the caller's emptiness check. + // Nothing is live under any known name. For known stacks, resurrect + // every member via its app id (see stack_member_app_ids). Otherwise + // fall back to the package id so a single-container app whose + // container matches its id still gets one start attempt. + let members = stack_member_app_ids(package_id); + if !members.is_empty() { + return members.iter().map(|s| s.to_string()).collect(); + } return vec![package_id.to_string()]; } let order = startup_order(package_id); @@ -732,10 +773,35 @@ mod tests { } #[test] - fn order_present_containers_empty_falls_back_to_package_id() { + fn order_present_containers_empty_resurrects_stack_members() { + // Under quadlet, package.stop removes every member container; the + // start fallback must name each member APP id so the orchestrator can + // recreate the whole stack (.228 indeedhub, 2026-07-09) — not just + // the umbrella/package id. assert_eq!( order_present_containers("mempool", vec![]), - vec!["mempool".to_string()] + vec!["archy-mempool-db", "mempool-api", "archy-mempool-web"] + ); + assert_eq!( + order_present_containers("indeedhub", vec![]), + vec![ + "indeedhub-postgres", + "indeedhub-redis", + "indeedhub-minio", + "indeedhub-relay", + "indeedhub-api", + "indeedhub-ffmpeg", + "indeedhub", + ] + ); + assert_eq!( + order_present_containers("immich", vec![]), + vec!["immich-postgres", "immich-redis", "immich"] + ); + // Single-container apps keep the package-id fallback. + assert_eq!( + order_present_containers("vaultwarden", vec![]), + vec!["vaultwarden".to_string()] ); } diff --git a/core/archipelago/src/api/rpc/package/runtime.rs b/core/archipelago/src/api/rpc/package/runtime.rs index 6b0add47..efa8f699 100644 --- a/core/archipelago/src/api/rpc/package/runtime.rs +++ b/core/archipelago/src/api/rpc/package/runtime.rs @@ -213,14 +213,21 @@ impl RpcHandler { let single_orchestrator_app = self.orchestrator.is_some() && uses_single_orchestrator_app(package_id); - let containers = if single_orchestrator_app { + let mut containers = if single_orchestrator_app { vec![orchestrator_app_id(package_id).to_string()] } else { get_containers_for_app(package_id).await? }; if containers.is_empty() { - tracing::warn!("package.restart {}: no containers found", package_id); - return Err(anyhow::anyhow!("No containers found for {}", package_id)); + // A stack whose containers were all removed (quadlet stop) can + // still be restarted: resurrect it member-by-member, same as + // package.start's no-live-containers fallback. + let members = super::dependencies::stack_member_app_ids(package_id); + if members.is_empty() { + tracing::warn!("package.restart {}: no containers found", package_id); + return Err(anyhow::anyhow!("No containers found for {}", package_id)); + } + containers = members.iter().map(|s| s.to_string()).collect(); } // Restart does not mark user-stopped; user wants the app to keep @@ -765,7 +772,13 @@ async fn do_orchestrator_package_start( match orchestrator.start(name).await { Ok(()) => wait_after_orchestrator_start(name).await, Err(e) if is_unknown_app_id_error(&e) => { - do_package_start(&[name.clone()]).await?; + // Collect instead of `?`: aborting here skipped every later + // stack member (mempool gate flakes #73/#74 — see + // order_present_containers). + if let Err(e) = do_package_start(&[name.clone()]).await { + tracing::error!(container = %name, error = %e, "fallback container start failed"); + errors.push(format!("{}: {:#}", name, e)); + } } Err(e) => { tracing::error!(container = %name, error = %e, "orchestrator start failed"); @@ -1949,6 +1962,18 @@ pub(super) fn orchestrator_uninstall_app_ids(package_id: &str) -> Vec { "archy-btcpay-db".into(), ], "fedimint" => vec!["fedimint".into(), "fedimint-gateway".into()], + // IndeedHub: 7-container stack; same rationale as immich below — + // without this, uninstalling "indeedhub" leaves the six companions + // enabled for the reconciler to resurrect forever. + "indeedhub" => vec![ + "indeedhub-postgres".into(), + "indeedhub-redis".into(), + "indeedhub-minio".into(), + "indeedhub-relay".into(), + "indeedhub-api".into(), + "indeedhub-ffmpeg".into(), + "indeedhub".into(), + ], // Immich: multi-container stack, mirrors `immich_stack_app_ids` in // stacks.rs. Without this, uninstalling "immich" only disabled the // orchestrator-tracked "immich" app_id — "immich-postgres" and @@ -1996,6 +2021,25 @@ mod tests { assert_eq!(runtime_host_ports("gitea"), vec![3001, 2222, 3000]); } + #[test] + fn indeedhub_uninstall_covers_every_sibling_orchestrator_app_id() { + let ids = orchestrator_uninstall_app_ids("indeedhub"); + for expected in [ + "indeedhub-postgres", + "indeedhub-redis", + "indeedhub-minio", + "indeedhub-relay", + "indeedhub-api", + "indeedhub-ffmpeg", + "indeedhub", + ] { + assert!( + ids.iter().any(|id| id == expected), + "missing {expected} in {ids:?}" + ); + } + } + #[test] fn immich_uninstall_covers_every_sibling_orchestrator_app_id() { // Regression: uninstalling "immich" used to only disable the diff --git a/docs/1.8.0-RELEASE-HARDENING-PLAN.md b/docs/1.8.0-RELEASE-HARDENING-PLAN.md index 11124a4c..55d9dfd8 100644 --- a/docs/1.8.0-RELEASE-HARDENING-PLAN.md +++ b/docs/1.8.0-RELEASE-HARDENING-PLAN.md @@ -306,7 +306,9 @@ media (latest artifact only one minor behind). `prod_orchestrator.rs:54-83` baseline/restart-sensitive lists). Move `baseline`, `restart_sensitive`, `stack_members`, `multi_container` into the manifest schema; collapse the five near-identical `install_*_stack()` wrappers into one generic call. **Biggest - maintainability win.** + maintainability win.** (Grew again 2026-07-09: `stack_member_app_ids` in + `package/dependencies.rs` — the quadlet stack-resurrection fallback — is a fifth + per-app map that must fold into the same manifest field.) - [ ] 🟢 **Route all podman/systemctl through `podman_client`.** 113 raw `Command::new("podman")` + 32 `systemctl` calls bypass the existing 952-LOC wrapper → untestable + the blocking-call risk (§C). Consolidating also unlocks unit tests for the thinly-tested `package/` handlers