Skip to main content

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}