From 3a5c5db187110b740d77ed1bf2156b2655beb45f Mon Sep 17 00:00:00 2001 From: archipelago Date: Fri, 10 Jul 2026 08:48:36 -0400 Subject: [PATCH] fix(orchestrator): install_fresh skips pull when the image is local MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reconcile recreates route through install_fresh, which pulled unconditionally: with git.tx1138.com dead, every boot reconcile of archy-btcpay-db/archy-nbxplorer hard-failed on 'pulling ...' even though both image:tags sat in local storage the whole time (live-testing report 2026-07-10). Check image_exists first and only pull when the ref is truly absent — the same semantics as ensure_resolved_source_available and the generated quadlets' Pull=never. Co-Authored-By: Claude Fable 5 --- .../src/container/prod_orchestrator.rs | 71 +++++++++++++++++-- 1 file changed, 67 insertions(+), 4 deletions(-) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index cf5ff8af..4d8686c7 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -2050,10 +2050,30 @@ impl ProdContainerOrchestrator { lm.manifest.app.id ); } - self.runtime - .pull_image(&image, image_signature.as_deref()) - .await - .with_context(|| format!("pulling {image}"))?; + // Reconcile recreates route through here too: an unreachable + // registry must not brick an app whose exact image:tag is + // already in local storage (boot reconcile of archy-btcpay-db + // /archy-nbxplorer with git.tx1138.com dead, 2026-07-10). + // Same exists-first semantics as + // ensure_resolved_source_available and the quadlets' + // Pull=never. + let exists = match self.runtime.image_exists(&image).await { + Ok(exists) => exists, + Err(err) => { + tracing::warn!( + image = %image, + error = %err, + "image existence check failed; pulling image instead" + ); + false + } + }; + if !exists { + self.runtime + .pull_image(&image, image_signature.as_deref()) + .await + .with_context(|| format!("pulling {image}"))?; + } } ResolvedSource::Build(mut bcfg) => { // Resolve a relative context: against the manifest's own directory. @@ -4195,6 +4215,8 @@ mod tests { StdMutex>>, /// If set, the next `build_image` call fails with this message. fail_build: StdMutex>, + /// If set, every `pull_image` call fails with this message. + fail_pull: StdMutex>, /// If set, `image_exists` fails for this image reference. fail_image_exists: StdMutex>, /// If set, `start_container` for this container fails with this message. @@ -4248,6 +4270,9 @@ mod tests { impl ContainerRuntimeTrait for MockRuntime { async fn pull_image(&self, image: &str, _sig: Option<&str>) -> Result<()> { self.record(format!("pull_image:{image}")); + if let Some(msg) = self.fail_pull.lock().unwrap().clone() { + return Err(anyhow::anyhow!("{msg}")); + } self.mark_image_present(image); Ok(()) } @@ -4691,6 +4716,44 @@ app: assert!(!calls.iter().any(|c| c.starts_with("build_image:"))); } + #[tokio::test] + async fn install_fresh_skips_pull_when_image_local() { + // An unreachable registry must not brick a reconcile recreate whose + // exact image:tag is already in local storage (archy-btcpay-db / + // archy-nbxplorer boot reconcile with git.tx1138.com dead, + // 2026-07-10). + let rt = Arc::new(MockRuntime::default()); + rt.mark_image_present("docker.io/bitcoin/knots:28"); + *rt.fail_pull.lock().unwrap() = Some("registry unreachable".into()); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/fixtures/bitcoin-knots"), + ) + .await; + + let name = orch.install("bitcoin-knots").await.unwrap(); + assert_eq!(name, "bitcoin-knots"); + let calls = rt.calls(); + assert!(!calls.iter().any(|c| c.starts_with("pull_image:"))); + assert!(calls.iter().any(|c| c == "start_container:bitcoin-knots")); + } + + #[tokio::test] + async fn install_fresh_still_fails_when_image_truly_absent() { + let rt = Arc::new(MockRuntime::default()); + *rt.fail_pull.lock().unwrap() = Some("registry unreachable".into()); + let orch = orch_with(rt.clone()).await; + orch.insert_manifest_for_test( + pull_manifest("bitcoin-knots", "docker.io/bitcoin/knots:28"), + PathBuf::from("/tmp/fixtures/bitcoin-knots"), + ) + .await; + + let err = orch.install("bitcoin-knots").await.unwrap_err(); + assert!(err.to_string().contains("pulling"), "err: {err}"); + } + #[tokio::test] async fn knows_app_reflects_loaded_manifests() { // The RPC install gate routes any app with a known manifest through