fix(orchestrator): stop quadlet entrypoint split from reading as permanent command drift

container_command_drifted compared the manifest's entrypoint and
custom_args against Config.Entrypoint and Config.Cmd separately, but
quadlet's Entrypoint= takes a single value, so a manifest entrypoint of
[sh, -lc] is written as Entrypoint=sh + Exec=-lc ... and podman reports
entry=[sh], cmd=[-lc, script]. Same argv, different split — every
quadlet-created app with a multi-element entrypoint read as drifted
forever. On .228 this recreated electrumx on every reconcile pass (114
times in 6h); bitcoin-knots and fedimint-gateway showed the same false
drift and were only spared by the restart-sensitive guard.

Compare the concatenated argv (entrypoint ++ args vs Entrypoint ++ Cmd)
instead — that is what actually runs. When the manifest declares no
custom_args, only the entrypoint prefix must match, since the trailing
Cmd may be the image's baked-in CMD.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-08 17:08:41 -04:00
parent fcd5a065dc
commit 77c5f5641a

View File

@ -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,
&current_entry,
&current_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<String> {
items.iter().map(|s| s.to_string()).collect()
}
#[test]
fn command_drift_tolerates_quadlet_entrypoint_split() {
// Quadlet writes Entrypoint=sh + Exec=-lc "<script>", so podman
// reports entry=["sh"], cmd=["-lc", script] for a manifest that
// declared entrypoint ["sh", "-lc"] + custom_args [script]. Same
// argv, different split — must NOT read as drift (the electrumx
// recreate loop).
let script = "export DAEMON_URL=...; exec electrumx_server";
assert!(!command_argv_drifted(
Some(&sv(&["sh", "-lc"])),
&sv(&[script]),
&sv(&["sh"]),
&sv(&["-lc", script]),
));
// Legacy podman-create layout keeps the halves as declared.
assert!(!command_argv_drifted(
Some(&sv(&["sh", "-lc"])),
&sv(&[script]),
&sv(&["sh", "-lc"]),
&sv(&[script]),
));
}
#[test]
fn command_drift_detected_when_argv_actually_differs() {
assert!(command_argv_drifted(
Some(&sv(&["sh", "-lc"])),
&sv(&["new script"]),
&sv(&["sh"]),
&sv(&["-lc", "old script"]),
));
assert!(command_argv_drifted(
Some(&sv(&["bash", "-c"])),
&sv(&["run"]),
&sv(&["sh"]),
&sv(&["-lc", "run"]),
));
// No entrypoint override: args compare against Cmd directly.
assert!(command_argv_drifted(
None,
&sv(&["-server=1"]),
&sv(&["bitcoind"]),
&sv(&["-server=0"]),
));
assert!(!command_argv_drifted(
None,
&sv(&["-server=1"]),
&sv(&["bitcoind"]),
&sv(&["-server=1"]),
));
}
#[test]
fn command_drift_entrypoint_only_ignores_image_cmd() {
// Entrypoint override with no manifest args: the container's Cmd is
// the image's baked-in CMD — only the entrypoint prefix matters.
assert!(!command_argv_drifted(
Some(&sv(&["/usr/local/bin/bitcoind"])),
&[],
&sv(&["/usr/local/bin/bitcoind"]),
&sv(&["-image-default-arg"]),
));
assert!(command_argv_drifted(
Some(&sv(&["/usr/local/bin/bitcoind"])),
&[],
&sv(&["/other/binary"]),
&sv(&[]),
));
}
#[test]
fn port_drift_detected_when_host_port_differs() {
// The .116 case: container publishes container-port 8080 on host 8080,