Bind relay restart override to its own active maintenance role

This commit is contained in:
archipelago
2026-10-08 00:27:04 -04:00
parent 838ad745f3
commit f78ee25258
@@ -50,7 +50,7 @@ fn owned_record(path: &Path, limit: u64, exact_private: bool) -> Result<Vec<u8>>
); );
Ok(bytes) Ok(bytes)
} }
/// The sole admitted drop-in is the currently held Indee API restart fence. /// Only the currently held Indee API or relay restart fence is admitted.
/// This never grants ownership to arbitrary operator or historical overrides. /// This never grants ownership to arbitrary operator or historical overrides.
fn validate_maintenance_override( fn validate_maintenance_override(
data: &Path, data: &Path,
@@ -62,10 +62,12 @@ fn validate_maintenance_override(
if dropins.is_empty() { if dropins.is_empty() {
return Ok(()); return Ok(());
} }
anyhow::ensure!( let obligation = match name {
name == "indeedhub-api" && restart == "no", "indeedhub-api" => "api_restart_override",
"External service override refused" "indeedhub-relay" => "relay_restart_override",
); _ => anyhow::bail!("External service override refused"),
};
anyhow::ensure!(restart == "no", "External service override refused");
owned_directory(data)?; owned_directory(data)?;
for relative in [ for relative in [
"update-transactions", "update-transactions",
@@ -77,7 +79,7 @@ fn validate_maintenance_override(
owned_directory(&data.join(relative))?; owned_directory(&data.join(relative))?;
} }
let operation = String::from_utf8(owned_record( let operation = String::from_utf8(owned_record(
&data.join("update-transactions/holds/indeedhub-api"), &data.join("update-transactions/holds").join(name),
128, 128,
false, false,
)?)?; )?)?;
@@ -131,17 +133,17 @@ fn validate_maintenance_override(
.iter() .iter()
.filter(|m| m["original"]["name"] == name) .filter(|m| m["original"]["name"] == name)
.collect(); .collect();
anyhow::ensure!(matching.len() == 1, "Ambiguous native API identity"); anyhow::ensure!(matching.len() == 1, "Ambiguous native member identity");
let original = &matching[0]["original"]; let original = &matching[0]["original"];
let members = journal["original_members"] let members = journal["original_members"]
.as_array() .as_array()
.context("Missing maintenance members")?; .context("Missing maintenance members")?;
let matching: Vec<_> = members.iter().filter(|m| m["name"] == name).collect(); let matching: Vec<_> = members.iter().filter(|m| m["name"] == name).collect();
anyhow::ensure!(matching.len() == 1, "Ambiguous maintenance API identity"); anyhow::ensure!(matching.len() == 1, "Ambiguous maintenance member identity");
let member = matching[0]; let member = matching[0];
let body = original["body"] let body = original["body"]
.as_str() .as_str()
.context("Missing original API unit")?; .context("Missing original managed unit")?;
anyhow::ensure!( anyhow::ensure!(
["container_id", "image", "config_sha256"] ["container_id", "image", "config_sha256"]
.iter() .iter()
@@ -154,10 +156,10 @@ fn validate_maintenance_override(
&& original["running"].is_boolean() && original["running"].is_boolean()
&& member["running"] == original["running"] && member["running"] == original["running"]
&& member["unit_sha256"] == hex::encode(Sha256::digest(body.as_bytes())), && member["unit_sha256"] == hex::encode(Sha256::digest(body.as_bytes())),
"Maintenance API binding changed" "Maintenance member binding changed"
); );
let bytes = format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n"); let bytes = format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n");
let saved = &journal["api_restart_override"]; let saved = &journal[obligation];
anyhow::ensure!( anyhow::ensure!(
saved["operation_id"].as_str() == Some(operation.as_str()) saved["operation_id"].as_str() == Some(operation.as_str())
&& saved["released"] == false && saved["released"] == false
@@ -165,16 +167,14 @@ fn validate_maintenance_override(
"Maintenance restart override obligation changed" "Maintenance restart override obligation changed"
); );
owned_directory(runtime)?; owned_directory(runtime)?;
for relative in [ for relative in ["systemd", "systemd/user"] {
"systemd",
"systemd/user",
"systemd/user/indeedhub-api.service.d",
] {
owned_directory(&runtime.join(relative))?; owned_directory(&runtime.join(relative))?;
} }
let expected = runtime let service_dir = runtime
.join("systemd/user/indeedhub-api.service.d") .join("systemd/user")
.join(format!("zz-archipelago-maintenance-{operation}.conf")); .join(format!("{name}.service.d"));
owned_directory(&service_dir)?;
let expected = service_dir.join(format!("zz-archipelago-maintenance-{operation}.conf"));
anyhow::ensure!( anyhow::ensure!(
expected.to_str() == Some(dropins), expected.to_str() == Some(dropins),
"Unexpected or additional service override" "Unexpected or additional service override"
@@ -863,6 +863,7 @@ impl<B: DrainBarrier> Supervisor for SystemdSupervisor<B> {
mod tests { mod tests {
use super::*; use super::*;
struct OverrideFixture { struct OverrideFixture {
name: &'static str,
_root: tempfile::TempDir, _root: tempfile::TempDir,
data: PathBuf, data: PathBuf,
runtime: PathBuf, runtime: PathBuf,
@@ -876,6 +877,9 @@ mod tests {
} }
impl OverrideFixture { impl OverrideFixture {
fn new() -> Self { fn new() -> Self {
Self::for_role("indeedhub-api", "api_restart_override")
}
fn for_role(name: &'static str, obligation: &str) -> Self {
let root = tempfile::tempdir().unwrap(); let root = tempfile::tempdir().unwrap();
let data = root.path().join("data"); let data = root.path().join("data");
let runtime = root.path().join("runtime"); let runtime = root.path().join("runtime");
@@ -888,26 +892,29 @@ mod tests {
data.join("update-transactions/supervised"), data.join("update-transactions/supervised"),
maintenance.clone(), maintenance.clone(),
data.join("app-maintenance"), data.join("app-maintenance"),
runtime.join("systemd/user/indeedhub-api.service.d"), runtime
.join("systemd/user")
.join(format!("{name}.service.d")),
] { ] {
std::fs::create_dir_all(path).unwrap(); std::fs::create_dir_all(path).unwrap();
} }
private_write( private_write(
&data.join("update-transactions/holds/indeedhub-api"), &data.join("update-transactions/holds").join(name),
operation.as_bytes(), operation.as_bytes(),
); );
private_write( private_write(
&data.join("app-maintenance/indeedhub"), &data.join("app-maintenance/indeedhub"),
operation.as_bytes(), operation.as_bytes(),
); );
let body = "[Container]\nContainerName=indeedhub-api\n"; let body = format!("[Container]\nContainerName={name}\n");
let original = serde_json::json!({"name":"indeedhub-api","container_id":"a".repeat(64),"image":"b".repeat(64),"config_sha256":"c".repeat(64),"body":body,"running":true}); let original = serde_json::json!({"name":name,"container_id":"a".repeat(64),"image":"b".repeat(64),"config_sha256":"c".repeat(64),"body":body,"running":true});
let native = serde_json::json!({"schema":2,"id":operation,"package":"indeedhub","phase":"Restoring","cleanup_done":false,"members":[{"original":original}]}); let native = serde_json::json!({"schema":2,"id":operation,"package":"indeedhub","phase":"Restoring","cleanup_done":false,"members":[{"original":original}]});
let bytes = let bytes =
format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n"); format!("# Archipelago maintenance operation {operation}\n[Service]\nRestart=no\n");
let journal = serde_json::json!({"operation_id":operation,"phase":"Recovering","original_members":[{"name":"indeedhub-api","container_id":original["container_id"],"image_id":original["image"],"config_sha256":original["config_sha256"],"unit_sha256":hex::encode(Sha256::digest(body.as_bytes())),"running":true}],"api_restart_override":{"operation_id":operation,"released":false,"sha256":hex::encode(Sha256::digest(bytes.as_bytes()))}}); let journal = serde_json::json!({"operation_id":operation,"phase":"Recovering","original_members":[{"name":name,"container_id":original["container_id"],"image_id":original["image"],"config_sha256":original["config_sha256"],"unit_sha256":hex::encode(Sha256::digest(body.as_bytes())),"running":true}],(obligation):{"operation_id":operation,"released":false,"sha256":hex::encode(Sha256::digest(bytes.as_bytes()))}});
let override_path = runtime let override_path = runtime
.join("systemd/user/indeedhub-api.service.d") .join("systemd/user")
.join(format!("{name}.service.d"))
.join(format!("zz-archipelago-maintenance-{operation}.conf")); .join(format!("zz-archipelago-maintenance-{operation}.conf"));
let native_path = data let native_path = data
.join("update-transactions/supervised") .join("update-transactions/supervised")
@@ -917,6 +924,7 @@ mod tests {
private_write(&native_path, &serde_json::to_vec(&native).unwrap()); private_write(&native_path, &serde_json::to_vec(&native).unwrap());
private_write(&journal_path, &serde_json::to_vec(&journal).unwrap()); private_write(&journal_path, &serde_json::to_vec(&journal).unwrap());
Self { Self {
name,
_root: root, _root: root,
data, data,
runtime, runtime,
@@ -929,7 +937,7 @@ mod tests {
validate_maintenance_override( validate_maintenance_override(
&self.data, &self.data,
&self.runtime, &self.runtime,
"indeedhub-api", self.name,
self.override_path.to_str().unwrap(), self.override_path.to_str().unwrap(),
"no", "no",
) )
@@ -971,6 +979,42 @@ mod tests {
.unwrap(); .unwrap();
} }
#[test] #[test]
fn api_and_relay_overrides_cannot_borrow_each_others_obligation() {
for (name, own, other) in [
(
"indeedhub-api",
"api_restart_override",
"relay_restart_override",
),
(
"indeedhub-relay",
"relay_restart_override",
"api_restart_override",
),
] {
let f = OverrideFixture::for_role(name, own);
f.validate().unwrap();
let bytes = std::fs::read(&f.journal_path).unwrap();
let mut journal: serde_json::Value = serde_json::from_slice(&bytes).unwrap();
let value = journal.as_object_mut().unwrap().remove(own).unwrap();
journal[other] = value;
private_write(&f.journal_path, &serde_json::to_vec(&journal).unwrap());
assert!(f.validate().is_err());
private_write(&f.journal_path, &bytes);
let borrowed = f.override_path.to_str().unwrap().replace(
name,
if name == "indeedhub-api" {
"indeedhub-relay"
} else {
"indeedhub-api"
},
);
assert!(
validate_maintenance_override(&f.data, &f.runtime, name, &borrowed, "no").is_err()
);
}
}
#[test]
fn restart_override_requires_matching_live_native_and_controller_obligations() { fn restart_override_requires_matching_live_native_and_controller_obligations() {
for (native, key, value) in [ for (native, key, value) in [
( (