Version supervised launch identity without promoting legacy journals

This commit is contained in:
archipelago
2026-10-07 23:00:37 -04:00
parent f42f32980d
commit 32317236d9
3 changed files with 177 additions and 8 deletions
@@ -88,7 +88,14 @@ impl LegacyIndeeMaintenance {
// and the inherited flock open description (unlike a detached service). // and the inherited flock open description (unlike a detached service).
let mut command = tokio::process::Command::new("/usr/bin/systemd-run"); let mut command = tokio::process::Command::new("/usr/bin/systemd-run");
command command
.args(["--user", "--scope", "--quiet", "--collect", "--", "/usr/bin/python3"]) .args([
"--user",
"--scope",
"--quiet",
"--collect",
"--",
"/usr/bin/python3",
])
.arg(path) .arg(path)
.arg(action) .arg(action)
.env("ARCHY_UPDATE_LOCK_FD", fd.to_string()) .env("ARCHY_UPDATE_LOCK_FD", fd.to_string())
@@ -572,6 +579,11 @@ impl<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
file_mode: meta.mode() & 0o777, file_mode: meta.mode() & 0o777,
running: observed.running, running: observed.running,
config_sha256: observed.config_sha256, 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<()> { async fn validate_original_file(&self, original: &Unit) -> Result<()> {
@@ -707,6 +719,7 @@ mod tests {
file_mode: 0o600, file_mode: 0o600,
running: true, running: true,
config_sha256: "d".repeat(64), config_sha256: "d".repeat(64),
launch_config_sha256_v2: None,
}; };
let mut target = Target { let mut target = Target {
name: "movie".into(), name: "movie".into(),
@@ -18,6 +18,14 @@ pub(crate) struct Unit {
pub file_mode: u32, pub file_mode: u32,
pub running: bool, pub running: bool,
pub config_sha256: String, pub config_sha256: String,
#[serde(default)]
pub launch_config_sha256_v2: Option<String>,
}
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)] #[derive(Clone, Debug, Serialize, Deserialize)]
pub(crate) struct PreparedTarget { pub(crate) struct PreparedTarget {
@@ -210,7 +218,7 @@ pub(crate) async fn capture_local_recovery_image(
current.id == original.container_id current.id == original.container_id
&& current.image == original.image && current.image == original.image
&& current.running == original.running && current.running == original.running
&& current.config_sha256 == original.config_sha256, && configuration_matches(&current, original),
"Original runtime changed before writable-layer snapshot" "Original runtime changed before writable-layer snapshot"
); );
let exists = command(&["image", "exists", tag]).await?; let exists = command(&["image", "exists", tag]).await?;
@@ -445,7 +453,12 @@ fn validate(record: &Journal) -> Result<()> {
&& digest(&member.original.container_id) && digest(&member.original.container_id)
&& member.original.file_mode & !0o777 == 0 && member.original.file_mode & !0o777 == 0
&& member.original.file_mode & 0o022 == 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" "Invalid original supervised identity"
); );
anyhow::ensure!( anyhow::ensure!(
@@ -904,7 +917,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu
&& current.id == member.original.container_id && current.id == member.original.container_id
&& current.image == member.original.image && current.image == member.original.image
&& current.running == member.original.running && current.running == member.original.running
&& current.config_sha256 == member.original.config_sha256 && configuration_matches(&current, &member.original)
}); });
if member.preserve_original == Some(true) { if member.preserve_original == Some(true) {
anyhow::ensure!( anyhow::ensure!(
@@ -923,7 +936,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu
.image; .image;
if let Some(current) = observed { if let Some(current) = observed {
let own_recovery = current.image == *recovery let own_recovery = current.image == *recovery
&& current.config_sha256 == member.original.config_sha256 && configuration_matches(&current, &member.original)
&& body == member.pinned_original_body; && body == member.pinned_original_body;
if current.running { if current.running {
anyhow::ensure!( anyhow::ensure!(
@@ -940,7 +953,7 @@ async fn pre_target_state(member: &Member, supervisor: &impl Supervisor) -> Resu
own_recovery own_recovery
|| (current.id == member.original.container_id || (current.id == member.original.container_id
&& current.image == member.original.image && current.image == member.original.image
&& current.config_sha256 == member.original.config_sha256), && configuration_matches(&current, &member.original)),
"Unexpected stopped replacement; retain its data for inspection" "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.id == member.original.container_id
&& original.image == member.original.image && original.image == member.original.image
&& original.running == member.original.running && 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" "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() .as_ref()
.context("Missing recovery image")? .context("Missing recovery image")?
.image .image
&& current.config_sha256 == member.original.config_sha256, && configuration_matches(&current, &member.original),
"Original launch configuration did not recover" "Original launch configuration did not recover"
); );
} else { } else {
@@ -1232,6 +1245,7 @@ mod tests {
file_mode: 0o600, file_mode: 0o600,
running: true, running: true,
config_sha256: "c".repeat(64), config_sha256: "c".repeat(64),
launch_config_sha256_v2: None,
}, },
body: Mutex::new(body), body: Mutex::new(body),
running: AtomicBool::new(true), running: AtomicBool::new(true),
@@ -1382,12 +1396,29 @@ mod tests {
running: true, running: true,
retainable: false, retainable: false,
config_sha256: if new { "d".repeat(64) } else { "c".repeat(64) }, config_sha256: if new { "d".repeat(64) } else { "c".repeat(64) },
launch_config_sha256_v2: None,
})) }))
} }
async fn healthy(&self, _name: &str) -> Result<bool> { async fn healthy(&self, _name: &str) -> Result<bool> {
Ok(true) 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(&current, &original));
original.launch_config_sha256_v2 = Some("e".repeat(64));
assert!(configuration_matches(&current, &original));
current.config_sha256 = original.config_sha256.clone();
current.launch_config_sha256_v2 = Some("d".repeat(64));
assert!(!configuration_matches(&current, &original));
current.launch_config_sha256_v2 = None;
assert!(!configuration_matches(&current, &original));
}
struct DrainFailureStack { struct DrainFailureStack {
members: std::collections::BTreeMap<String, Mock>, members: std::collections::BTreeMap<String, Mock>,
partial_frontend: bool, partial_frontend: bool,
@@ -17,6 +17,8 @@ pub(crate) struct Observed {
pub image: String, pub image: String,
pub running: bool, pub running: bool,
pub config_sha256: String, pub config_sha256: String,
#[serde(default)]
pub launch_config_sha256_v2: Option<String>,
/// False for auto-remove, external supervision, pods, paused/unknown states. /// False for auto-remove, external supervision, pods, paused/unknown states.
pub retainable: bool, pub retainable: bool,
} }
@@ -648,6 +650,7 @@ impl Runtime for Podman {
running: state == "running", running: state == "running",
retainable, retainable,
config_sha256: hex::encode(sha2::Sha256::digest(serde_json::to_vec(&config)?)), 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<()> { 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<String> {
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::<String, serde_json::Value>::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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -709,6 +770,68 @@ mod tests {
atomic::{AtomicBool, AtomicUsize, Ordering}, atomic::{AtomicBool, AtomicUsize, Ordering},
Mutex, 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 { struct Mock {
rows: Mutex<Vec<Observed>>, rows: Mutex<Vec<Observed>>,
calls: Mutex<Vec<String>>, calls: Mutex<Vec<String>>,
@@ -728,6 +851,7 @@ mod tests {
running: true, running: true,
retainable: true, retainable: true,
config_sha256: "a".repeat(64), config_sha256: "a".repeat(64),
launch_config_sha256_v2: None,
}, },
Observed { Observed {
id: format!("{:064x}", 2), id: format!("{:064x}", 2),
@@ -736,6 +860,7 @@ mod tests {
running: false, running: false,
retainable: true, retainable: true,
config_sha256: "b".repeat(64), config_sha256: "b".repeat(64),
launch_config_sha256_v2: None,
}, },
]), ]),
calls: Default::default(), calls: Default::default(),