From 2bfa84efa9b4909ddbfc0a5a340d715e697ac364 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 17:04:00 -0400 Subject: [PATCH] Resolve complete reviewed IndeeHub stack before image preparation --- .../archipelago/src/api/rpc/package/update.rs | 108 +++++++++++++++++- .../src/container/image_versions.rs | 10 ++ .../managed-update-recovery-implementation.md | 23 ++++ 3 files changed, 136 insertions(+), 5 deletions(-) diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index 5680b165..3a4546f8 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -204,12 +204,36 @@ impl RpcHandler { let images_to_pull = targets.unwrap_or_else(|| self.resolve_images_to_pull(package_id, &pinned)); - // Get all containers for this app - let containers = get_containers_for_app(package_id).await?; - if containers.is_empty() { - self.clear_update_state(package_id).await; - return Err(anyhow::anyhow!("No containers found for {}", package_id)); + // Managed imports are intentionally private. Resolve the trusted catalog + // against the reviewed local digest plan before any registry preparation; + // never pull mutable dependency tags over an approved local plan. Every + // preparation refusal clears Updating, including inventory/plan failures. + let preparation = async { + let containers = get_containers_for_app(package_id).await?; + anyhow::ensure!( + !containers.is_empty(), + "No containers found for {}", + package_id + ); + let images = self + .reviewed_managed_image_references( + package_id, + &containers, + &images_to_pull, + &lifecycle_guard, + ) + .await?; + Ok::<_, anyhow::Error>((containers, images)) } + .await; + let (containers, images_to_pull) = match preparation { + Ok(prepared) => prepared, + Err(error) => { + self.clear_install_progress(package_id).await; + self.clear_update_state(package_id).await; + return Err(error); + } + }; // Resolve every image while the old stack is still available. A // registry outage or missing private import must not stop the app or @@ -350,6 +374,57 @@ impl RpcHandler { } } + async fn reviewed_managed_image_references( + &self, + package_id: &str, + containers: &[String], + images: &[(String, String)], + guard: &crate::container::update_transaction::Guard, + ) -> Result> { + use crate::container::supervised_runtime::{ + load_reviewed_plans, LegacyIndeeMaintenance, SystemdSupervisor, + }; + use crate::container::supervised_update::Supervisor; + use crate::container::update_transaction::Podman; + let mut managed = 0; + for name in containers { + if crate::container::quadlet::unit_exists(name).await { + managed += 1; + } + } + if managed == 0 { + return Ok(images.to_vec()); + } + anyhow::ensure!( + managed == containers.len() && package_id == "indeedhub", + "Managed stack requires its complete qualified maintenance plan; originals unchanged" + ); + let catalog = Podman::targets(images, false).await?; + let names: HashSet<_> = containers.iter().map(String::as_str).collect(); + anyhow::ensure!( + catalog.len() == names.len() + && catalog + .iter() + .all(|target| names.contains(target.name.as_str())), + "Reviewed managed target membership differs from installed stack" + ); + let adapter = SystemdSupervisor::new( + self.config.data_dir.clone(), + load_reviewed_plans(&self.config.data_dir, package_id)?, + LegacyIndeeMaintenance::new(guard)?, + ) + .await?; + let targets = adapter.reviewed_targets(&catalog).await?; + for target in &targets { + let original = adapter.capture(&target.name).await?; + adapter.prepare_target(target, &original).await?; + } + Ok(targets + .into_iter() + .map(|target| (target.name, target.reference)) + .collect()) + } + async fn recreate_container_for_update( &self, package_id: &str, @@ -853,6 +928,29 @@ mod tests { } } + #[test] + fn indeehub_update_resolves_every_member_in_dependency_order() { + let members = crate::container::image_versions::containers_for_stack("indeedhub"); + let expected = super::all_container_names("indeedhub"); + assert_eq!( + members.iter().map(|(name, _)| *name).collect::>(), + expected + ); + let source = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../scripts/image-versions.sh" + )); + for (_, variable) in members { + assert!( + source + .lines() + .any(|line| line.starts_with(&format!("{variable}="))), + "Missing image pin {variable}" + ); + } + assert!(super::uses_legacy_update_flow("indeedhub")); + } + #[tokio::test] async fn stack_image_failure_precedes_every_lifecycle_action() { use std::sync::{Arc, Mutex}; diff --git a/core/archipelago/src/container/image_versions.rs b/core/archipelago/src/container/image_versions.rs index 3acb4b94..5f69a601 100644 --- a/core/archipelago/src/container/image_versions.rs +++ b/core/archipelago/src/container/image_versions.rs @@ -361,6 +361,16 @@ pub fn containers_for_stack(app_id: &str) -> Vec<(&'static str, &'static str)> { ("immich_redis", "REDIS_IMAGE"), ("immich_server", "IMMICH_SERVER_IMAGE"), ], + // Dependency order is also the supervised startup/rollback order. + "indeedhub" => vec![ + ("indeedhub-postgres", "INDEEDHUB_POSTGRES_IMAGE"), + ("indeedhub-redis", "INDEEDHUB_REDIS_IMAGE"), + ("indeedhub-minio", "MINIO_IMAGE"), + ("indeedhub-relay", "NOSTR_RS_RELAY_IMAGE"), + ("indeedhub-api", "INDEEDHUB_API_IMAGE"), + ("indeedhub-ffmpeg", "INDEEDHUB_FFMPEG_IMAGE"), + ("indeedhub", "INDEEDHUB_IMAGE"), + ], "netbird" => vec![ ("netbird", "NETBIRD_PROXY_IMAGE"), ("netbird-dashboard", "NETBIRD_DASHBOARD_IMAGE"), diff --git a/docs/managed-update-recovery-implementation.md b/docs/managed-update-recovery-implementation.md index 534d2c23..f856ac2d 100644 --- a/docs/managed-update-recovery-implementation.md +++ b/docs/managed-update-recovery-implementation.md @@ -167,3 +167,26 @@ files, which verifies the intended absence of a fabricated completed transaction Evidence: `/tmp/archy-20261007-indeehub-final-backend-recheck.log`; initial failed fixture evidence remains in `/tmp/archy-20261007-indeehub-final-backend.log`. Optimized artifact build and full real VM adapter acceptance remain pending. + +## Full RPC dispatch correction — 2026-10-07 + +Tracing the actual `package.update` path found that IndeeHub was marked as a stack +but missing from the pinned stack-image mapping. It therefore resolved only its +frontend before the seven-member runtime membership check. The mapping now covers +PostgreSQL, Redis, MinIO, relay, API, worker and frontend in dependency order. + +Managed preflight now resolves the signed catalog references locally against the +reviewed immutable plan and checks original unit hashes/registration pins before +image preparation. Only verified local digest references reach preparation, so +unchanged mutable dependency tags cannot trigger a registry pull before the private +plan is checked. Unmanaged updates retain registry preparation. Inventory and plan +refusals clear progress and the inner Updating state; the asynchronous RPC wrapper +also retains its existing failure/scanner cleanup. + +The final source passes **2,004 isolated backend tests, zero failures, five ignored**: +`/tmp/archy-20261007-indeehub-dispatch-final-backend.log`. A prior 2,004-test receipt +predated the last preparation-state cleanup and is not the final-byte result. +The optimized build was deliberately interrupted after these integration gaps were +found. A test-profile application executable is being built solely for the isolated +full RPC rehearsal; final release optimization/deployment remains pending that +rehearsal. No live IndeeHub stack or catalog has been changed.