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).
|
// 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(¤t, 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(¤t, &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(¤t, &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(¤t, &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(¤t, &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(¤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 {
|
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(),
|
||||||
|
|||||||
Reference in New Issue
Block a user