From 39852ab38121a80ea05fa2cb5835530dd7fcbb29 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 02:49:31 -0400 Subject: [PATCH] Preserve reviewed managed unit recipes after update completion --- .../archipelago/src/api/rpc/package/update.rs | 1 + .../src/container/prod_orchestrator.rs | 15 +++ .../src/container/supervised_runtime.rs | 36 ++++++ .../src/container/supervised_update.rs | 119 ++++++++++++++++++ 4 files changed, 171 insertions(+) diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs index 61379e10..5680b165 100644 --- a/core/archipelago/src/api/rpc/package/update.rs +++ b/core/archipelago/src/api/rpc/package/update.rs @@ -342,6 +342,7 @@ impl RpcHandler { LegacyIndeeMaintenance::new(guard)?, ) .await?; + let targets = adapter.reviewed_targets(&targets).await?; crate::container::supervised_update::execute(guard, package_id, &targets, &adapter) .await } else { diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index fbb9478d..9396e306 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3055,6 +3055,7 @@ impl ProdContainerOrchestrator { } async fn install_fresh_with_pin(&self, lm: &LoadedManifest, pinned: bool) -> Result<()> { + anyhow::ensure!(super::supervised_update::installed_unit(&self.data_dir, &compute_container_name(&lm.manifest))?.is_none(), "Reviewed managed runtime is missing; restore its saved unit/image explicitly instead of recreating from the current catalog"); self.ensure_app_secrets(&lm.manifest.app.id).await?; let mut resolved_manifest = lm.manifest.clone(); self.resolve_dynamic_env(&mut resolved_manifest).await?; @@ -3354,6 +3355,9 @@ impl ProdContainerOrchestrator { lm: &LoadedManifest, name: &str, ) -> Result> { + if super::supervised_update::installed_unit(&self.data_dir, name)?.is_some() { + return Ok(None); // Never remove an original runtime to migrate it from changed catalog data. + } // Skip companion apps — bitcoin-ui / electrs-ui / lnd-ui have shipped // via Quadlet since v1.7.41 (companion.rs renders the unit). Running // migration for them races companion rendering: when migration ran @@ -3456,6 +3460,15 @@ impl ProdContainerOrchestrator { if super::update_transaction::is_held(&self.data_dir, name)? { return Ok(()); // Preserve the recovered unit instead of current catalog drift. } + if let Some((body, mode)) = super::supervised_update::installed_unit(&self.data_dir, name)? + { + use std::os::unix::fs::PermissionsExt; + let path = quadlet::unit_dir().await?.join(format!("{name}.container")); + let meta = std::fs::symlink_metadata(&path) + .context("Saved managed unit is missing; recovery required")?; + anyhow::ensure!(meta.is_file() && meta.permissions().mode() & 0o777 == mode && std::fs::read_to_string(&path)? == body, "Reviewed managed unit changed; preserve it for explicit reconciliation instead of overwriting operator configuration"); + return Ok(()); + } // Companions: same reasoning as migrate_to_quadlet_if_needed — // companion.rs renders these units with a different shape, syncing // here would clobber them. @@ -5363,6 +5376,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { let _guard = lock.lock().await; for name in [app_id.to_string(), format!("archy-{app_id}")] { self.remove_quadlet_unit_if_present(&name).await?; + super::supervised_update::forget_installed(&self.data_dir, &name)?; } self.state.write().await.disabled.insert(app_id.to_string()); super::staged_update::clear(&self.data_dir, app_id).await?; @@ -5422,6 +5436,7 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { crate::crash_recovery::clear_installed(&self.data_dir, app_id).await; super::staged_update::clear(&self.data_dir, app_id).await?; super::staged_update::clear_installed(&self.data_dir, app_id).await?; + super::supervised_update::forget_installed(&self.data_dir, &name)?; Ok(()) } diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs index 68a79287..d633f304 100644 --- a/core/archipelago/src/container/supervised_runtime.rs +++ b/core/archipelago/src/container/supervised_runtime.rs @@ -307,6 +307,42 @@ impl SystemdSupervisor { barrier, }) } + /// A reviewed plan may replace a mutable catalog spelling with its exact + /// locally verified digest, but never select different image bytes. + pub(crate) async fn reviewed_targets(&self, targets: &[Target]) -> Result> { + let mut refs = Vec::new(); + for target in targets { + let plan = self + .plans + .get(&target.name) + .context("Missing reviewed managed target")?; + let reference = plan + .prepared + .manifest + .app + .container + .image + .as_ref() + .context("Reviewed image missing")?; + anyhow::ensure!( + reference.rsplit_once("@sha256:").is_some_and( + |(_, hash)| hash.len() == 64 && hash.bytes().all(|b| b.is_ascii_hexdigit()) + ), + "Managed target must use an immutable digest" + ); + refs.push((target.name.clone(), reference.clone())); + } + let resolved = Podman::targets(&refs, false).await?; + anyhow::ensure!( + resolved + .iter() + .zip(targets) + .all(|(reviewed, catalog)| reviewed.name == catalog.name + && reviewed.image == catalog.image), + "Reviewed managed image differs from prepared catalog image" + ); + Ok(resolved) + } fn path(&self, name: &str) -> Result { anyhow::ensure!( !name.is_empty() diff --git a/core/archipelago/src/container/supervised_update.rs b/core/archipelago/src/container/supervised_update.rs index 05d62528..3c0770a6 100644 --- a/core/archipelago/src/container/supervised_update.rs +++ b/core/archipelago/src/container/supervised_update.rs @@ -513,6 +513,91 @@ fn save(guard: &Guard, record: &Journal) -> Result<()> { } result } +// The committed unit is installation evidence, not a cache of today's catalog. +// Keep it until explicit uninstall or the next reviewed managed transaction. +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct InstalledUnit { + schema: u8, + operation: String, + name: String, + body: String, + mode: u32, +} +fn installed_path(data: &Path, name: &str) -> Result { + anyhow::ensure!(simple(name), "Invalid managed member name"); + Ok(data + .join("update-transactions/installed-units") + .join(format!("{name}.json"))) +} +fn publish_installed(guard: &Guard, record: &Journal) -> Result<()> { + anyhow::ensure!( + record.phase == Phase::Committed, + "Only committed units may be published" + ); + let dir = guard.directory().join("installed-units"); + match std::fs::DirBuilder::new().mode(0o700).create(&dir) { + Ok(()) => {} + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => return Err(e.into()), + } + anyhow::ensure!( + std::fs::symlink_metadata(&dir)?.is_dir(), + "Invalid installed unit directory" + ); + for member in &record.members { + let saved = InstalledUnit { + schema: 1, + operation: record.id.clone(), + name: member.original.name.clone(), + body: member.target_body.clone(), + mode: member.original.file_mode, + }; + let temporary = dir.join(format!(".{}.tmp", uuid::Uuid::new_v4())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(&temporary)?; + file.write_all(&serde_json::to_vec(&saved)?)?; + file.sync_all()?; + std::fs::rename(&temporary, dir.join(format!("{}.json", saved.name)))?; + } + std::fs::File::open(&dir)?.sync_all()?; + std::fs::File::open(guard.directory())?.sync_all()?; + Ok(()) +} +pub(crate) fn installed_unit(data: &Path, name: &str) -> Result> { + let path = installed_path(data, name)?; + let meta = match std::fs::symlink_metadata(&path) { + Ok(meta) => meta, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(e.into()), + }; + anyhow::ensure!( + meta.is_file() && meta.len() <= 4 * 1024 * 1024, + "Invalid installed managed recipe" + ); + let saved: InstalledUnit = serde_json::from_slice(&std::fs::read(path)?)?; + anyhow::ensure!( + saved.schema == 1 + && saved.name == name + && uuid::Uuid::parse_str(&saved.operation)?.to_string() == saved.operation + && saved.mode & !0o777 == 0 + && saved.mode & 0o022 == 0, + "Invalid installed managed recipe binding" + ); + Ok(Some((saved.body, saved.mode))) +} +pub(crate) fn forget_installed(data: &Path, name: &str) -> Result<()> { + let path = installed_path(data, name)?; + match std::fs::remove_file(&path) { + Ok(()) => std::fs::File::open(path.parent().unwrap())?.sync_all()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(e.into()), + }; + Ok(()) +} fn records(guard: &Guard) -> Result> { let dir = root(guard)?; let mut records = Vec::new(); @@ -673,6 +758,10 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor record.cleanup_done = false; save(guard, record)?; for member in &record.members { + anyhow::ensure!( + supervisor.read(&member.original.name).await? == member.target_body, + "Reviewed unit changed before target start; recovery required" + ); supervisor.start(&member.original.name).await?; supervisor .target_hooks(&member.original.name, &member.target_manifest) @@ -690,6 +779,7 @@ async fn apply(guard: &Guard, record: &mut Journal, supervisor: &impl Supervisor } record.phase = Phase::Committed; save(guard, record)?; + publish_installed(guard, record)?; supervisor .release_barrier(&record.id, Completion::Committed) .await?; @@ -782,6 +872,10 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis supervisor.reload().await?; for member in &record.members { if member.original.running { + anyhow::ensure!( + supervisor.read(&member.original.name).await? == member.pinned_original_body, + "Original recovery unit changed before start" + ); supervisor.start(&member.original.name).await?; } let observed = supervisor.observed(&member.original.name).await?; @@ -823,6 +917,9 @@ pub(crate) async fn recover(guard: &Guard, supervisor: &impl Supervisor) -> Resu } match record.phase { Phase::Committed | Phase::Aborted => { + if record.phase == Phase::Committed { + publish_installed(guard, &record)?; + } let outcome = if record.phase == Phase::Committed { Completion::Committed } else { @@ -1122,6 +1219,28 @@ mod tests { assert_eq!(*runtime.calls.lock().unwrap(), ["snapshot-original"]); } #[tokio::test] + async fn committed_unit_survives_restart_until_explicit_uninstall() { + let root = tempfile::tempdir().unwrap(); + let guard = Guard::acquire(root.path()).unwrap(); + let runtime = Mock::new(); + execute(&guard, "movie", &[Mock::target()], &runtime) + .await + .unwrap(); + let saved = installed_unit(root.path(), "movie").unwrap().unwrap(); + assert_eq!(saved.0, *runtime.body.lock().unwrap()); + assert!(saved.0.contains("OPERATOR_VALUE=retained")); + runtime.calls.lock().unwrap().clear(); + recover(&guard, &runtime).await.unwrap(); + assert_eq!( + installed_unit(root.path(), "movie").unwrap().unwrap(), + saved + ); + assert!(runtime.calls.lock().unwrap().is_empty()); + forget_installed(root.path(), "movie").unwrap(); + recover(&guard, &runtime).await.unwrap(); + assert!(installed_unit(root.path(), "movie").unwrap().is_none()); + } + #[tokio::test] async fn committed_restart_releases_only_its_own_hold_without_runtime_mutation() { let root = tempfile::tempdir().unwrap(); let guard = Guard::acquire(root.path()).unwrap();