clawft_kernel/workload_ctl/host_revoke.rs
1//! Forced unload on revocation: a node stops and unloads every instance it
2//! hosts whose package, signer key or artifact hash was revoked (ADR-099
3//! section 7, card mesh-placement-revoke).
4//!
5//! Revoking a subject drops its grants and evicts its bytes, but a cog that
6//! is already running keeps running from what it loaded. This closes that:
7//! when a revocation is applied (the operator's verb, a signed mesh notice,
8//! or the daemon's start-up and 60 s sweeps, which catch one applied while the
9//! node was down or whose teardown failed) the host walks
10//! its instances and tears down each one that now names a revoked subject.
11//! The teardown is not gated: the applied revocation is the authority, so
12//! enforcement does not depend on the operator having written a stop permit.
13//! Every step is chained (`workload.stop`, `workload.unload`, with
14//! `forced_by_revocation` naming the subject).
15
16use std::time::Duration;
17
18use serde::Serialize;
19use serde_json::json;
20
21use super::host_service::WorkloadHostService;
22use crate::workload_runtime::RuntimeError;
23use crate::revocation::{RevocationList, RevokedSubject};
24
25/// Grace period given to a revoked instance to exit before it is killed.
26const FORCED_GRACE: Duration = Duration::from_secs(2);
27
28/// One instance a revocation took down (or failed to).
29#[derive(Debug, Clone, PartialEq, Serialize)]
30pub struct ForcedTeardown {
31 /// Instance id on this node.
32 pub instance_id: String,
33 /// Workload name (cog id).
34 pub workload: String,
35 /// The revoked subject that matched.
36 pub subject: RevokedSubject,
37 /// Whether the instance is gone. `false`: it is still held and the next
38 /// sweep tries again.
39 pub unloaded: bool,
40 /// Why it is not gone, when it is not.
41 #[serde(skip_serializing_if = "Option::is_none")]
42 pub error: Option<String>,
43}
44
45impl WorkloadHostService {
46 /// Stop and unload every hosted instance that `list` now revokes.
47 /// Idempotent: instances already gone are not touched. Instances that
48 /// cannot be torn down stay held and are reported with `unloaded:
49 /// false`, so the next call retries them.
50 ///
51 /// The instance map is locked only to pick the targets and to forget the
52 /// ones that went; the teardowns (each up to the stop grace) run outside
53 /// it, so a revocation sweep never holds up `status`, `stop` or `place`
54 /// on this node.
55 pub async fn enforce_revocations(&self, list: &RevocationList) -> Vec<ForcedTeardown> {
56 let mut targets = Vec::new();
57 {
58 let map = self.instances.lock().await;
59 for (id, p) in map.iter() {
60 let Some(host) = self.routes.get(&p.route).cloned() else {
61 continue;
62 };
63 let Some(w) = host.workload_for(&p.handle).await else {
64 continue;
65 };
66 let (package_id, keys, hashes) = w.revocation_refs();
67 let Some(subject) = list.first_revoked(Some(&package_id), &keys, &hashes) else {
68 continue;
69 };
70 // The token goes first: a revoked cog must not keep a
71 // credential this node no longer vouches for.
72 if let (Some(hk), Some(l)) = (&self.ingest, &p.ingest) {
73 hk.deactivate(l);
74 }
75 targets.push((id.clone(), p.handle.clone(), host, w.id.clone(), subject));
76 }
77 }
78 let mut out = Vec::new();
79 for (id, handle, host, workload, subject) in targets {
80 let r = host
81 .revoke_teardown(&handle, FORCED_GRACE, json!(subject))
82 .await;
83 // An instance someone else already took down is gone: that is
84 // what was asked for.
85 let gone = matches!(r, Ok(()) | Err(RuntimeError::UnknownInstance(_)));
86 if gone {
87 self.instances.lock().await.remove(&id);
88 self.note_departed(&id, "revoked");
89 }
90 out.push(ForcedTeardown {
91 instance_id: id,
92 workload,
93 subject,
94 unloaded: gone,
95 error: r.err().filter(|_| !gone).map(|e| e.to_string()),
96 });
97 }
98 out
99 }
100}