diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index f72a3d8a..fa78e912 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3106,17 +3106,12 @@ impl ProdContainerOrchestrator { .flatten() .unwrap_or_default(); - if let Some(expected_entry) = &manifest.app.container.entrypoint { - if current_entry != *expected_entry { - return true; - } - } - if !manifest.app.container.custom_args.is_empty() - && current_cmd != manifest.app.container.custom_args - { - return true; - } - false + command_argv_drifted( + manifest.app.container.entrypoint.as_deref(), + &manifest.app.container.custom_args, + ¤t_entry, + ¤t_cmd, + ) } /// True when a *running* container publishes a manifest container-port to a @@ -3872,6 +3867,36 @@ impl ContainerOrchestrator for ProdContainerOrchestrator { } } +/// Compare a manifest's command against a live container's, over the +/// concatenated argv (entrypoint ++ args) rather than each half separately. +/// The halves are not stable across create paths: quadlet's `Entrypoint=` +/// takes a single value, so a manifest entrypoint of `sh -lc` is written as +/// `Entrypoint=sh` + `Exec=-lc ...`, and podman then reports +/// `Config.Entrypoint=["sh"]`, `Config.Cmd=["-lc", ...]` — a per-half +/// comparison reads that as permanent drift and recreates the container on +/// every reconcile pass (electrumx on .228, 2026-07-08: 114 recreates in 6h). +/// The concatenation is what actually runs, so it is the thing to compare. +fn command_argv_drifted( + expected_entry: Option<&[String]>, + expected_args: &[String], + current_entry: &[String], + current_cmd: &[String], +) -> bool { + let Some(expected_entry) = expected_entry else { + // No entrypoint override: the manifest args map to Cmd directly. + return !expected_args.is_empty() && current_cmd != expected_args; + }; + let expected: Vec<&String> = expected_entry.iter().chain(expected_args).collect(); + let actual: Vec<&String> = current_entry.iter().chain(current_cmd).collect(); + if expected_args.is_empty() { + // With no manifest args, the container's trailing Cmd may be the + // image's baked-in CMD — only the entrypoint prefix must match. + actual.len() < expected.len() || actual[..expected.len()] != expected[..] + } else { + actual != expected + } +} + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -3891,6 +3916,80 @@ mod tests { } } + fn sv(items: &[&str]) -> Vec { + items.iter().map(|s| s.to_string()).collect() + } + + #[test] + fn command_drift_tolerates_quadlet_entrypoint_split() { + // Quadlet writes Entrypoint=sh + Exec=-lc "