Version supervised launch identity without promoting legacy journals
This commit is contained in:
@@ -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<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
|
||||
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(),
|
||||
|
||||
@@ -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<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)]
|
||||
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<bool> {
|
||||
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<String, Mock>,
|
||||
partial_frontend: bool,
|
||||
|
||||
@@ -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<String>,
|
||||
/// 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<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)]
|
||||
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<Vec<Observed>>,
|
||||
calls: Mutex<Vec<String>>,
|
||||
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user