From 32317236d9a119cf64e5f378abbe043e513dc06d Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 23:00:37 -0400 Subject: [PATCH] Version supervised launch identity without promoting legacy journals --- .../src/container/supervised_runtime.rs | 15 ++- .../src/container/supervised_update.rs | 45 ++++++- .../src/container/update_transaction.rs | 125 ++++++++++++++++++ 3 files changed, 177 insertions(+), 8 deletions(-) diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs index 1637b72f..09ed1202 100644 --- a/core/archipelago/src/container/supervised_runtime.rs +++ b/core/archipelago/src/container/supervised_runtime.rs @@ -88,7 +88,14 @@ impl LegacyIndeeMaintenance { // and the inherited flock open description (unlike a detached service). let mut command = tokio::process::Command::new("/usr/bin/systemd-run"); command - .args(["--user", "--scope", "--quiet", "--collect", "--", "/usr/bin/python3"]) + .args([ + "--user", + "--scope", + "--quiet", + "--collect", + "--", + "/usr/bin/python3", + ]) .arg(path) .arg(action) .env("ARCHY_UPDATE_LOCK_FD", fd.to_string()) @@ -572,6 +579,11 @@ impl Supervisor for SystemdSupervisor { file_mode: meta.mode() & 0o777, running: observed.running, config_sha256: observed.config_sha256, + launch_config_sha256_v2: Some( + observed + .launch_config_sha256_v2 + .context("Original launch configuration is ambiguous or incomplete")?, + ), }) } async fn validate_original_file(&self, original: &Unit) -> Result<()> { @@ -707,6 +719,7 @@ mod tests { file_mode: 0o600, running: true, config_sha256: "d".repeat(64), + launch_config_sha256_v2: None, }; let mut target = Target { name: "movie".into(), diff --git a/core/archipelago/src/container/supervised_update.rs b/core/archipelago/src/container/supervised_update.rs index 3ae3370a..f2a02c5d 100644 --- a/core/archipelago/src/container/supervised_update.rs +++ b/core/archipelago/src/container/supervised_update.rs @@ -18,6 +18,14 @@ pub(crate) struct Unit { pub file_mode: u32, pub running: bool, pub config_sha256: String, + #[serde(default)] + pub launch_config_sha256_v2: Option, +} +fn configuration_matches(current: &Observed, original: &Unit) -> bool { + match &original.launch_config_sha256_v2 { + Some(expected) => current.launch_config_sha256_v2.as_ref() == Some(expected), + None => current.config_sha256 == original.config_sha256, + } } #[derive(Clone, Debug, Serialize, Deserialize)] pub(crate) struct PreparedTarget { @@ -210,7 +218,7 @@ pub(crate) async fn capture_local_recovery_image( current.id == original.container_id && current.image == original.image && current.running == original.running - && current.config_sha256 == original.config_sha256, + && configuration_matches(¤t, original), "Original runtime changed before writable-layer snapshot" ); let exists = command(&["image", "exists", tag]).await?; @@ -445,7 +453,12 @@ fn validate(record: &Journal) -> Result<()> { && digest(&member.original.container_id) && member.original.file_mode & !0o777 == 0 && member.original.file_mode & 0o022 == 0 - && digest(&member.original.config_sha256), + && digest(&member.original.config_sha256) + && member + .original + .launch_config_sha256_v2 + .as_ref() + .is_none_or(|v| record.schema == 2 && digest(v)), "Invalid original supervised identity" ); anyhow::ensure!( @@ -904,7 +917,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu && current.id == member.original.container_id && current.image == member.original.image && current.running == member.original.running - && current.config_sha256 == member.original.config_sha256 + && configuration_matches(¤t, &member.original) }); if member.preserve_original == Some(true) { anyhow::ensure!( @@ -923,7 +936,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu .image; if let Some(current) = observed { let own_recovery = current.image == *recovery - && current.config_sha256 == member.original.config_sha256 + && configuration_matches(¤t, &member.original) && body == member.pinned_original_body; if current.running { anyhow::ensure!( @@ -940,7 +953,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu own_recovery || (current.id == member.original.container_id && current.image == member.original.image - && current.config_sha256 == member.original.config_sha256), + && configuration_matches(¤t, &member.original)), "Unexpected stopped replacement; retain its data for inspection" ); } @@ -1030,7 +1043,7 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis && original.id == member.original.container_id && original.image == member.original.image && original.running == member.original.running - && original.config_sha256 == member.original.config_sha256, + && configuration_matches(&original, &member.original), "Original service changed during preparation; recovery requires inspection" ); } @@ -1120,7 +1133,7 @@ async fn restore(guard: &Guard, record: &mut Journal, supervisor: &impl Supervis .as_ref() .context("Missing recovery image")? .image - && current.config_sha256 == member.original.config_sha256, + && configuration_matches(¤t, &member.original), "Original launch configuration did not recover" ); } else { @@ -1232,6 +1245,7 @@ mod tests { file_mode: 0o600, running: true, config_sha256: "c".repeat(64), + launch_config_sha256_v2: None, }, body: Mutex::new(body), running: AtomicBool::new(true), @@ -1382,12 +1396,29 @@ mod tests { running: true, retainable: false, config_sha256: if new { "d".repeat(64) } else { "c".repeat(64) }, + launch_config_sha256_v2: None, })) } async fn healthy(&self, _name: &str) -> Result { Ok(true) } } + #[tokio::test] + async fn launch_v2_comparison_never_promotes_legacy_record_or_falls_back_to_raw_digest() { + let runtime = Mock::new(); + let mut current = runtime.observed("movie").await.unwrap().unwrap(); + let mut original = runtime.original.clone(); + current.launch_config_sha256_v2 = Some("e".repeat(64)); + current.config_sha256 = "f".repeat(64); + assert!(!configuration_matches(¤t, &original)); + original.launch_config_sha256_v2 = Some("e".repeat(64)); + assert!(configuration_matches(¤t, &original)); + current.config_sha256 = original.config_sha256.clone(); + current.launch_config_sha256_v2 = Some("d".repeat(64)); + assert!(!configuration_matches(¤t, &original)); + current.launch_config_sha256_v2 = None; + assert!(!configuration_matches(¤t, &original)); + } struct DrainFailureStack { members: std::collections::BTreeMap, partial_frontend: bool, diff --git a/core/archipelago/src/container/update_transaction.rs b/core/archipelago/src/container/update_transaction.rs index 4db3742d..418c8aea 100644 --- a/core/archipelago/src/container/update_transaction.rs +++ b/core/archipelago/src/container/update_transaction.rs @@ -17,6 +17,8 @@ pub(crate) struct Observed { pub image: String, pub running: bool, pub config_sha256: String, + #[serde(default)] + pub launch_config_sha256_v2: Option, /// False for auto-remove, external supervision, pods, paused/unknown states. pub retainable: bool, } @@ -648,6 +650,7 @@ impl Runtime for Podman { running: state == "running", retainable, config_sha256: hex::encode(sha2::Sha256::digest(serde_json::to_vec(&config)?)), + launch_config_sha256_v2: launch_fingerprint_v2(row, &config).ok(), })) } async fn stop(&self, id: &str) -> Result<()> { @@ -702,6 +705,64 @@ impl Runtime for Podman { } } +/// Domain-separated launch identity for fresh supervised recovery records. +/// Keep the raw legacy digest unchanged: old records cannot be promoted using +/// information that was never persisted with their original capture. +fn launch_fingerprint_v2(row: &serde_json::Value, config: &serde_json::Value) -> Result { + use sha2::Digest; + let id = row + .get("Id") + .and_then(|v| v.as_str()) + .context("Missing container ID")?; + anyhow::ensure!( + id.len() == 64 && id.bytes().all(|b| b.is_ascii_hexdigit()), + "Invalid container ID" + ); + let hostname = row + .pointer("/Config/Hostname") + .and_then(|v| v.as_str()) + .context("Missing launch hostname")?; + let generated = hostname == &id[..12]; + let entries = row + .pointer("/Config/Env") + .and_then(|v| v.as_array()) + .context("Missing launch environment")?; + let mut env = std::collections::BTreeMap::::new(); + for entry in entries { + let entry = entry.as_str().context("Invalid launch environment entry")?; + let (key, value) = entry + .split_once('=') + .context("Missing launch environment value")?; + anyhow::ensure!( + !key.is_empty() && !key.contains('\0') && !value.contains('\0'), + "Invalid launch environment key/value" + ); + let value = if key == "HOSTNAME" && generated && value == hostname { + serde_json::json!({"generated_container_hostname":true}) + } else { + serde_json::Value::String(value.into()) + }; + anyhow::ensure!( + env.insert(key.into(), value).is_none(), + "Duplicate launch environment key" + ); + } + let mut normalized = config.as_object().context("Invalid launch config")?.clone(); + normalized.insert("env".into(), serde_json::to_value(env)?); + normalized.insert( + "hostname".into(), + if generated { + serde_json::json!({"generated_container_hostname":true}) + } else { + serde_json::Value::String(hostname.into()) + }, + ); + normalized.insert("fingerprint_version".into(), serde_json::json!(2)); + Ok(hex::encode(sha2::Sha256::digest(serde_json::to_vec( + &normalized, + )?))) +} + #[cfg(test)] mod tests { use super::*; @@ -709,6 +770,68 @@ mod tests { atomic::{AtomicBool, AtomicUsize, Ordering}, Mutex, }; + fn launch_fixture(id: &str) -> (serde_json::Value, serde_json::Value) { + let row = serde_json::json!({"Id":id,"Config":{"Hostname":&id[..12],"Env":[format!("HOSTNAME={}",&id[..12]),"A=one","B=two=three"]}}); + let config = serde_json::json!({"env":row["Config"]["Env"],"mounts":[{"Source":"/original","Destination":"/data","RW":true}],"command":["node","dist/main"],"entrypoint":["entry.sh"],"user":"1000","network":"indeehub"}); + (row, config) + } + #[test] + fn launch_v2_normalizes_only_generated_hostname_and_unique_environment_order() { + let (a, config) = launch_fixture(&"a".repeat(64)); + let (mut b, _) = launch_fixture(&"b".repeat(64)); + b["Config"]["Env"].as_array_mut().unwrap().reverse(); + assert_eq!( + launch_fingerprint_v2(&a, &config).unwrap(), + launch_fingerprint_v2(&b, &config).unwrap() + ); + let expected = launch_fingerprint_v2(&a, &config).unwrap(); + for changed in [ + serde_json::json!(["HOSTNAME=operator-host", "A=one", "B=two=three"]), + serde_json::json!([ + format!("HOSTNAME={}", "a".repeat(12)), + "A=changed", + "B=two=three" + ]), + serde_json::json!(["A=one", "B=two=three"]), + ] { + let mut row = a.clone(); + row["Config"]["Env"] = changed; + assert_ne!(expected, launch_fingerprint_v2(&row, &config).unwrap()); + } + let mut custom = a.clone(); + custom["Config"]["Hostname"] = serde_json::json!("operator-host"); + assert_ne!(expected, launch_fingerprint_v2(&custom, &config).unwrap()); + } + #[test] + fn launch_v2_rejects_ambiguous_environment_and_preserves_command_and_mount_semantics() { + let (row, config) = launch_fixture(&"a".repeat(64)); + for env in [ + serde_json::Value::Null, + serde_json::json!(["A=one", "A=one"]), + serde_json::json!(["NO_VALUE"]), + serde_json::json!(["=empty-key"]), + serde_json::json!([42]), + ] { + let mut bad = row.clone(); + bad["Config"]["Env"] = env; + assert!(launch_fingerprint_v2(&bad, &config).is_err()); + } + let expected = launch_fingerprint_v2(&row, &config).unwrap(); + for (key, value) in [ + ("command", serde_json::json!(["dist/main", "node"])), + ("entrypoint", serde_json::json!(["other.sh"])), + ( + "mounts", + serde_json::json!([{"Source":"/other","Destination":"/data","RW":true}]), + ), + ("user", serde_json::json!("0")), + ("network", serde_json::json!("other")), + ] { + let mut changed = config.clone(); + changed[key] = value; + assert_ne!(expected, launch_fingerprint_v2(&row, &changed).unwrap()); + } + } struct Mock { rows: Mutex>, calls: Mutex>, @@ -728,6 +851,7 @@ mod tests { running: true, retainable: true, config_sha256: "a".repeat(64), + launch_config_sha256_v2: None, }, Observed { id: format!("{:064x}", 2), @@ -736,6 +860,7 @@ mod tests { running: false, retainable: true, config_sha256: "b".repeat(64), + launch_config_sha256_v2: None, }, ]), calls: Default::default(),