diff --git a/core/archipelago/src/api/rpc/package/update.rs b/core/archipelago/src/api/rpc/package/update.rs
index f6454ed0..f6daf30e 100644
--- a/core/archipelago/src/api/rpc/package/update.rs
+++ b/core/archipelago/src/api/rpc/package/update.rs
@@ -180,10 +180,17 @@ impl RpcHandler {
return Err(anyhow::anyhow!("No containers found for {}", package_id));
}
- // Execute update — on failure, attempt rollback by restarting old containers
- match self
- .execute_update(package_id, &containers, &images_to_pull)
- .await
+ // Resolve every image while the old stack is still available. A
+ // registry outage or missing private import must not stop the app or
+ // enter rollback (which could start a deliberately stopped member).
+ self.set_install_phase(package_id, InstallPhase::PullingImage)
+ .await;
+ match preflighted_stack_update(
+ &images_to_pull,
+ |image| async move { self.pull_update_image(package_id, &image).await },
+ || self.execute_update(package_id, &containers, &images_to_pull),
+ )
+ .await
{
Ok(()) => {
install_log(&format!("UPDATE OK: {}", package_id)).await;
@@ -193,7 +200,12 @@ impl RpcHandler {
"package_id": package_id,
}))
}
- Err(e) => {
+ Err(UpdateFailure::Preparation(error)) => {
+ self.clear_install_progress(package_id).await;
+ self.clear_update_state(package_id).await;
+ Err(error)
+ }
+ Err(UpdateFailure::Execution(e)) => {
error!("Update {} failed: {}. Attempting rollback.", package_id, e);
install_log(&format!(
"UPDATE FAIL: {} — {}. Rolling back.",
@@ -248,7 +260,7 @@ impl RpcHandler {
}
}
- /// Core update execution: stop → pull → remove → recreate → verify.
+ /// Images are prepared first; then stop → remove → recreate → verify.
async fn execute_update(
&self,
package_id: &str,
@@ -289,29 +301,6 @@ impl RpcHandler {
}
}
- // Phase: PullingImage — about to fetch each pinned image in turn.
- self.set_install_phase(package_id, InstallPhase::PullingImage)
- .await;
-
- // 2. Pull new images with progress
- info!(
- "Update {}: pulling {} images",
- package_id,
- images_to_pull.len()
- );
- for (i, (name, image)) in images_to_pull.iter().enumerate() {
- info!(
- "Update {}: pulling image {}/{} ({})",
- package_id,
- i + 1,
- images_to_pull.len(),
- image
- );
- self.pull_update_image(package_id, image)
- .await
- .context(format!("Failed to pull {} for {}", image, name))?;
- }
-
// 3. Remove old containers
info!("Update {}: removing old containers", package_id);
for name in containers {
@@ -430,6 +419,32 @@ impl RpcHandler {
async fn pull_update_image(&self, package_id: &str, image: &str) -> Result<()> {
self.set_install_progress(package_id, 0, 0).await;
+ if immutable_update_image(image) {
+ // A digest-addressed lookup asks Podman for these exact bytes,
+ // unlike a mutable tag lookup. Private imports need no registry.
+ let local = tokio::time::timeout(
+ std::time::Duration::from_secs(30),
+ tokio::process::Command::new("podman")
+ .args(["image", "exists", image])
+ .kill_on_drop(true)
+ .output(),
+ )
+ .await
+ .context("Local image lookup timed out; existing app remains unchanged")??;
+ match local.status.code() {
+ Some(0) => {
+ self.set_install_progress(package_id, 100, 100).await;
+ return Ok(());
+ }
+ Some(1) => {}
+ _ => anyhow::bail!("Cannot inspect local image storage; update cancelled"),
+ }
+ }
+ anyhow::ensure!(
+ !image.starts_with("localhost/"),
+ "The exact private image must be imported before updating this app"
+ );
+
let mut cmd = tokio::process::Command::new("podman");
cmd.arg("pull");
if archipelago_container::image_uses_insecure_registry(image) {
@@ -655,6 +670,44 @@ fn should_try_orchestrator_update(package_id: &str, orchestrator_available: bool
orchestrator_available && !uses_legacy_update_flow(package_id)
}
+fn immutable_update_image(image: &str) -> bool {
+ image.rsplit_once("@sha256:").is_some_and(|(name, digest)| {
+ !name.is_empty()
+ && !name.contains('@')
+ && digest.len() == 64
+ && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
+ })
+}
+
+#[derive(Debug)]
+enum UpdateFailure {
+ Preparation(anyhow::Error),
+ Execution(anyhow::Error),
+}
+
+/// A preparation failure never enters the lifecycle/rollback path. Keep this
+/// sequencing injectable so failed second-image pulls are tested without apps.
+async fn preflighted_stack_update
(
+ images: &[(String, String)],
+ mut prepare: P,
+ execute: E,
+) -> std::result::Result<(), UpdateFailure>
+where
+ P: FnMut(String) -> PF,
+ PF: std::future::Future