fix(orchestrator): normalize YAML-folded newlines in command drift comparison

Second false-drift source found live on .228 right after the entrypoint
split fix: a YAML `>-` script with more-indented continuation lines
keeps literal newlines in custom_args, but shell_join flattens them to
spaces when writing the quadlet Exec= line, so Config.Cmd stores spaces
where the manifest has newlines. bitcoin-knots and fedimint-gateway
read as permanently command-drifted over line breaks alone (only the
restart-sensitive guard kept them from recreate-looping like electrumx).

Normalize both sides the way shell_join does before comparing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-07-08 17:27:50 -04:00
parent 6e2d128c51
commit 9ce8a0df05

View File

@ -3882,12 +3882,28 @@ fn command_argv_drifted(
current_entry: &[String], current_entry: &[String],
current_cmd: &[String], current_cmd: &[String],
) -> bool { ) -> bool {
// Mirror shell_join's newline flattening: a YAML-folded manifest script
// keeps literal newlines in custom_args, but the quadlet Exec= line (and
// therefore Config.Cmd) has them as spaces. Normalize both sides so the
// same script never reads as drift over line breaks alone (bitcoin-knots
// and fedimint-gateway on .228, 2026-07-08).
let norm = |v: &[String]| -> Vec<String> {
v.iter().map(|s| s.replace(['\r', '\n'], " ")).collect()
};
let current_cmd = norm(current_cmd);
let expected_args = norm(expected_args);
let Some(expected_entry) = expected_entry else { let Some(expected_entry) = expected_entry else {
// No entrypoint override: the manifest args map to Cmd directly. // No entrypoint override: the manifest args map to Cmd directly.
return !expected_args.is_empty() && current_cmd != expected_args; return !expected_args.is_empty() && current_cmd != expected_args;
}; };
let expected: Vec<&String> = expected_entry.iter().chain(expected_args).collect(); let expected: Vec<String> = norm(expected_entry)
let actual: Vec<&String> = current_entry.iter().chain(current_cmd).collect(); .into_iter()
.chain(expected_args.iter().cloned())
.collect();
let actual: Vec<String> = norm(current_entry)
.into_iter()
.chain(current_cmd.iter().cloned())
.collect();
if expected_args.is_empty() { if expected_args.is_empty() {
// With no manifest args, the container's trailing Cmd may be the // With no manifest args, the container's trailing Cmd may be the
// image's baked-in CMD — only the entrypoint prefix must match. // image's baked-in CMD — only the entrypoint prefix must match.
@ -3943,6 +3959,30 @@ mod tests {
)); ));
} }
#[test]
fn command_drift_tolerates_yaml_folded_newlines() {
// A YAML `>-` script with more-indented continuation lines keeps
// literal newlines in custom_args; shell_join flattens them to
// spaces when writing Exec=, so Config.Cmd has spaces where the
// manifest has newlines. Same script — not drift (bitcoin-knots /
// fedimint-gateway on .228).
let manifest_script = "if [ -z \"$X\" ]; then\n X=1;\nfi; exec daemon";
let stored_script = "if [ -z \"$X\" ]; then X=1; fi; exec daemon";
assert!(!command_argv_drifted(
Some(&sv(&["sh", "-lc"])),
&sv(&[manifest_script]),
&sv(&["sh"]),
&sv(&["-lc", stored_script]),
));
// A genuinely different script still drifts after normalization.
assert!(command_argv_drifted(
Some(&sv(&["sh", "-lc"])),
&sv(&[manifest_script]),
&sv(&["sh"]),
&sv(&["-lc", "exec other_daemon"]),
));
}
#[test] #[test]
fn command_drift_detected_when_argv_actually_differs() { fn command_drift_detected_when_argv_actually_differs() {
assert!(command_argv_drifted( assert!(command_argv_drifted(