diff --git a/core/archipelago/src/container/supervised_runtime.rs b/core/archipelago/src/container/supervised_runtime.rs index 315d0b46..c1476a90 100644 --- a/core/archipelago/src/container/supervised_runtime.rs +++ b/core/archipelago/src/container/supervised_runtime.rs @@ -50,7 +50,7 @@ fn owned_record(path: &Path, limit: u64, exact_private: bool) -> Result> ); 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. fn validate_maintenance_override( data: &Path, @@ -62,10 +62,12 @@ fn validate_maintenance_override( if dropins.is_empty() { return Ok(()); } - anyhow::ensure!( - name == "indeedhub-api" && restart == "no", - "External service override refused" - ); + let obligation = match name { + "indeedhub-api" => "api_restart_override", + "indeedhub-relay" => "relay_restart_override", + _ => anyhow::bail!("External service override refused"), + }; + anyhow::ensure!(restart == "no", "External service override refused"); owned_directory(data)?; for relative in [ "update-transactions", @@ -77,7 +79,7 @@ fn validate_maintenance_override( owned_directory(&data.join(relative))?; } let operation = String::from_utf8(owned_record( - &data.join("update-transactions/holds/indeedhub-api"), + &data.join("update-transactions/holds").join(name), 128, false, )?)?; @@ -131,17 +133,17 @@ fn validate_maintenance_override( .iter() .filter(|m| m["original"]["name"] == name) .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 members = journal["original_members"] .as_array() .context("Missing maintenance members")?; 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 body = original["body"] .as_str() - .context("Missing original API unit")?; + .context("Missing original managed unit")?; anyhow::ensure!( ["container_id", "image", "config_sha256"] .iter() @@ -154,10 +156,10 @@ fn validate_maintenance_override( && original["running"].is_boolean() && member["running"] == original["running"] && 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 saved = &journal["api_restart_override"]; + let saved = &journal[obligation]; anyhow::ensure!( saved["operation_id"].as_str() == Some(operation.as_str()) && saved["released"] == false @@ -165,16 +167,14 @@ fn validate_maintenance_override( "Maintenance restart override obligation changed" ); owned_directory(runtime)?; - for relative in [ - "systemd", - "systemd/user", - "systemd/user/indeedhub-api.service.d", - ] { + for relative in ["systemd", "systemd/user"] { owned_directory(&runtime.join(relative))?; } - let expected = runtime - .join("systemd/user/indeedhub-api.service.d") - .join(format!("zz-archipelago-maintenance-{operation}.conf")); + let service_dir = runtime + .join("systemd/user") + .join(format!("{name}.service.d")); + owned_directory(&service_dir)?; + let expected = service_dir.join(format!("zz-archipelago-maintenance-{operation}.conf")); anyhow::ensure!( expected.to_str() == Some(dropins), "Unexpected or additional service override" @@ -863,6 +863,7 @@ impl Supervisor for SystemdSupervisor { mod tests { use super::*; struct OverrideFixture { + name: &'static str, _root: tempfile::TempDir, data: PathBuf, runtime: PathBuf, @@ -876,6 +877,9 @@ mod tests { } impl OverrideFixture { 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 data = root.path().join("data"); let runtime = root.path().join("runtime"); @@ -888,26 +892,29 @@ mod tests { data.join("update-transactions/supervised"), maintenance.clone(), 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(); } private_write( - &data.join("update-transactions/holds/indeedhub-api"), + &data.join("update-transactions/holds").join(name), operation.as_bytes(), ); private_write( &data.join("app-maintenance/indeedhub"), operation.as_bytes(), ); - let body = "[Container]\nContainerName=indeedhub-api\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 body = format!("[Container]\nContainerName={name}\n"); + 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 bytes = 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 - .join("systemd/user/indeedhub-api.service.d") + .join("systemd/user") + .join(format!("{name}.service.d")) .join(format!("zz-archipelago-maintenance-{operation}.conf")); let native_path = data .join("update-transactions/supervised") @@ -917,6 +924,7 @@ mod tests { private_write(&native_path, &serde_json::to_vec(&native).unwrap()); private_write(&journal_path, &serde_json::to_vec(&journal).unwrap()); Self { + name, _root: root, data, runtime, @@ -929,7 +937,7 @@ mod tests { validate_maintenance_override( &self.data, &self.runtime, - "indeedhub-api", + self.name, self.override_path.to_str().unwrap(), "no", ) @@ -971,6 +979,42 @@ mod tests { .unwrap(); } #[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() { for (native, key, value) in [ (