From 2512a9f62bc490a10fbf8d8ba7ee779b5dc34e6d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 02:57:31 -0400 Subject: [PATCH] Retain verified restored units and validate original installer identity bindings --- .../src/container/prod_orchestrator.rs | 21 +++++++ .../src/container/supervised_runtime.rs | 52 +++++++++++++++++ .../src/container/supervised_update.rs | 24 ++++++-- .../managed-update-recovery-implementation.md | 56 +++++++++++++++++++ 4 files changed, 149 insertions(+), 4 deletions(-) create mode 100644 docs/managed-update-recovery-implementation.md diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 9396e306..993993f1 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3283,6 +3283,16 @@ impl ProdContainerOrchestrator { } async fn prepare_for_start(&self, manifest: &AppManifest) -> Result<()> { + if super::supervised_update::installed_unit( + &self.data_dir, + &compute_container_name(manifest), + )? + .is_some() + { + // Already-reviewed managed configuration is authoritative. Applying + // today's catalog files/mount preparation could mutate restored data. + return Ok(()); + } self.run_pre_start_hooks(&manifest.app.id).await?; self.ensure_bind_mount_sockets(manifest).await?; self.ensure_bind_mount_dirs(manifest).await?; @@ -3585,6 +3595,17 @@ impl ProdContainerOrchestrator { } async fn ensure_resolved_source_available(&self, lm: &LoadedManifest) -> Result<()> { + if let Some((body, _)) = super::supervised_update::installed_unit( + &self.data_dir, + &compute_container_name(&lm.manifest), + )? { + let image = body + .lines() + .find_map(|line| line.trim().strip_prefix("Image=")) + .context("Saved managed image missing")?; + anyhow::ensure!(self.runtime.image_exists(image).await?, "Saved managed image is unavailable; refusing to pull or recreate from a changed catalog"); + return Ok(()); + } let resolved = lm.manifest.app.container.resolve().ok_or_else(|| { anyhow::anyhow!( "manifest for {} has invalid container source (neither image nor build)", diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs index d633f304..ab7d5d8c 100644 --- a/core/archipelago/src/container/supervised_runtime.rs +++ b/core/archipelago/src/container/supervised_runtime.rs @@ -434,6 +434,58 @@ impl Supervisor for SystemdSupervisor { == target.name, "Original unit or reviewed immutable target changed before update" ); + if plan + .prepared + .manifest + .app + .container + .media_registration_identity + { + let identity = + crate::identity::NodeIdentity::load_existing(&self.data_dir.join("identity")) + .await?; + let pin = super::registration_pin::load_existing( + &self.data_dir, + &plan.prepared.manifest.app.id, + &identity, + )?; + let mut expected = plan.prepared.manifest.clone(); + super::registration_pin::apply_environment(&mut expected, &pin)?; + for entry in expected + .app + .environment + .iter() + .filter(|entry| entry.starts_with("ARCHIPELAGO_REGISTRATION_")) + { + let key = entry + .split_once('=') + .context("Malformed registration binding")? + .0; + let in_manifest: Vec<_> = plan + .prepared + .manifest + .app + .environment + .iter() + .filter(|value| value.split_once('=').is_some_and(|(name, _)| name == key)) + .collect(); + let in_unit: Vec<_> = plan + .prepared + .body + .lines() + .filter_map(|line| line.trim().strip_prefix("Environment=")) + .map(|value| value.trim_matches('"')) + .filter(|value| value.split_once('=').is_some_and(|(name, _)| name == key)) + .collect(); + anyhow::ensure!( + in_manifest.len() == 1 + && in_manifest[0] == entry + && in_unit.len() == 1 + && in_unit[0] == entry, + "Reviewed registration identity does not match the existing installer pin" + ); + } + } Ok(plan.prepared.clone()) } async fn target_hooks( diff --git a/core/archipelago/src/container/supervised_update.rs b/core/archipelago/src/container/supervised_update.rs index 3c0770a6..a3f0ee59 100644 --- a/core/archipelago/src/container/supervised_update.rs +++ b/core/archipelago/src/container/supervised_update.rs @@ -532,8 +532,8 @@ fn installed_path(data: &Path, name: &str) -> Result { } fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> { anyhow::ensure!( - record.phase == Phase::Committed, - "Only committed units may be published" + matches!(record.phase, Phase::Committed | Phase::Restored), + "Only verified terminal units may be published" ); let dir = guard.directory().join("installed-units"); match std::fs::DirBuilder::new().mode(0o700).create(&dir) { @@ -550,7 +550,11 @@ fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> { schema: 1, operation: record.id.clone(), name: member.original.name.clone(), - body: member.target_body.clone(), + body: if record.phase == Phase::Restored { + member.pinned_original_body.clone() + } else { + member.target_body.clone() + }, mode: member.original.file_mode, }; let temporary = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4())); @@ -904,9 +908,13 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis } record.phase = Phase::Restored; save(guard, record)?; + publish_installed(guard, record)?; supervisor .release_barrier(&record.id, Completion::Restored) .await?; + for member in &record.members { + guard.release_hold(&member.original.name, &record.id)?; + } record.cleanup_done = true; save(guard, record) } @@ -931,9 +939,13 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu } } Phase::Restored => { + publish_installed(guard, &record)?; supervisor .release_barrier(&record.id, Completion::Restored) .await?; + for member in &record.members { + guard.release_hold(&member.original.name, &record.id)?; + } } // Never release a newer owner. _ => restore(guard, &mut record, supervisor).await?, } @@ -1300,7 +1312,11 @@ mod tests { assert_eq!(restored.image, "e".repeat(64)); assert_eq!(restored.config_sha256, runtime.original.config_sha256); assert_ne!(restored.id, format!("{:064x}", 1)); - assert!(super::super::update_transaction::is_held(root.path(), "movie").unwrap()); + assert!(!super::super::update_transaction::is_held(root.path(), "movie").unwrap()); + assert_eq!( + installed_unit(root.path(), "movie").unwrap().unwrap().0, + *runtime.body.lock().unwrap() + ); assert_eq!(records(&guard).unwrap()[0].phase, Phase::Restored); } #[tokio::test] diff --git a/docs/managed-update-recovery-implementation.md b/docs/managed-update-recovery-implementation.md new file mode 100644 index 00000000..bb153fa4 --- /dev/null +++ b/docs/managed-update-recovery-implementation.md @@ -0,0 +1,56 @@ +# Managed update runtime recovery + +Status: isolated source implementation; Rust and real Podman/systemd qualification +pending. No live update, snapshot, stop, backup or rollback has been performed by +this work. Active deployed source is unchanged. + +The managed path captures original source Quadlet bytes, mode, immutable image, +container identity, launch configuration and running intent. It requires an +original-hash-bound reviewed forward plan and verifies every planned image against +the already prepared catalog image. New manifest configuration/hooks are applied +on the forward path; rollback uses the captured original recipe and a private, +local-only writable-layer image. AutoRemove rollback recreates containers and does +not claim to restore their original IDs. Stopped supervised stacks currently fail +before mutation; the retained-container and separate stopped-stage paths cover +only their respective supported cases. + +The legacy IndeeHub controller runs under the same inherited lifecycle lock and +operation-owned reconciliation holds. Original writable layers are captured +before any destructive stop. The controller fences ingress, drains supported +legacy work, takes coherent quiescent volume/database backups and retains the +fence through cutover or recovery. Its exact source hash must match the separately +installed script; a backend binary alone does not install the controller. + +Completed updates and verified runtime restorations publish exact unit recipes +before releasing holds. Routine drift reconciliation validates those recipes; +it does not regenerate them from a newer catalog. Catalog-driven pre-start file +and mount mutations are skipped for these pinned installations. Missing saved +units/images are explicit recovery failures, never permission to reconstruct a +different runtime. Explicit uninstall removes the installed recipe, and completed +old journals cannot recreate it. A new reviewed transaction replaces the recipe. + +Opted-in API registration environments must match an already provisioned pin +and the existing node identity in both manifest and exact Quadlet. Administrative +plan preparation must use the existing installer provisioning code. Execution +never invents a node identity or accepts a browser-supplied unit or hook. + +Runtime restoration does not establish database compatibility. The controller's +restored-release verifier compares original table schemas and row commitments, +permitting only the exact reviewed additive migration prefix and empty new +application tables. Any other data/schema change keeps ingress closed. This is +not automatic database rollback or a promise that arbitrary migrations are +reversible. + +Qualification required before integration/activation: + +- Isolated backend compile and injectable lifecycle/fault tests, including lost + replies, daemon interruption, foreign units/holds and preflight failures. +- Disposable real Podman/systemd and PostgreSQL execution of the controller and + adapter. Sixteen pure controller tests currently pass; SQL/runtime behavior is + not yet qualified. +- Final seven-member private plan with installer-resolved identity environment, + verified local images and source-unit provenance; review required changes and + retained operator configuration without exposing secret values. +- Disk-capacity and recovery-image retention checks, backup integrity and a + documented recovery path for missing runtime artifacts. +- Actual-node controlled deployment and acceptance, preserving persistent data.