From 9ce8a0df057226e4e1414ee4a17f74cf1c776347 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 8 Jul 2026 17:27:50 -0400 Subject: [PATCH] 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 --- .../src/container/prod_orchestrator.rs | 44 ++++++++++++++++++- 1 file changed, 42 insertions(+), 2 deletions(-) diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index fa78e912..49a3644a 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -3882,12 +3882,28 @@ fn command_argv_drifted( current_entry: &[String], current_cmd: &[String], ) -> 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 { + 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 { // 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(); + let expected: Vec = norm(expected_entry) + .into_iter() + .chain(expected_args.iter().cloned()) + .collect(); + let actual: Vec = norm(current_entry) + .into_iter() + .chain(current_cmd.iter().cloned()) + .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. @@ -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] fn command_drift_detected_when_argv_actually_differs() { assert!(command_argv_drifted(