Skip to main content

clawft_kernel/workload_ctl/
plane_state.rs

1//! Durable controller state (ADR-099 section 7): the known targets (with
2//! the key each was learned with) and the placed and unsettled instances
3//! survive a controller restart, so instances placed on other nodes stay
4//! manageable (`status`, `stop`, `logs`, `unload`).
5//!
6//! The file is written atomically (temp file, fsync, rename) with mode
7//! `0600` after every change. On load, targets come back unreachable and
8//! are re-described with their stored key before use; trust tiers are
9//! re-derived from operator policy on the next sync. Seed placements are
10//! persisted with the store pin they came from; after a restart the first
11//! verb on one re-adopts it into the (fresh) Seed adapter, which first
12//! checks the Seed still holds the pinned cog.
13
14use std::io::Write;
15use std::path::{Path, PathBuf};
16
17use serde::{Deserialize, Serialize};
18
19use super::plane::{PlacementControlPlane, PlacementRecord, PlaneError, TargetInfo};
20use super::plane_lifecycle::ControllerLife;
21use super::plane_seed::SeedEntry;
22
23const STATE_VERSION: u32 = 1;
24const MAX_STATE_BYTES: u64 = 8 * 1024 * 1024;
25
26#[derive(Debug, Default, Serialize, Deserialize)]
27#[serde(deny_unknown_fields)]
28struct PlaneState {
29    version: u32,
30    targets: Vec<TargetInfo>,
31    placements: Vec<PlacementRecord>,
32    unsettled: Vec<PlacementRecord>,
33    /// Handles of instances placed on Seeds (older files have none).
34    #[serde(default)]
35    seed_instances: Vec<SeedEntry>,
36    /// Lifecycle, stored order and policy per instance (older files have none).
37    #[serde(default)]
38    lives: Vec<(String, ControllerLife)>,
39}
40
41fn read_state(path: &Path) -> Result<Option<PlaneState>, PlaneError> {
42    let meta = match std::fs::symlink_metadata(path) {
43        Err(_) => return Ok(None),
44        Ok(m) => m,
45    };
46    if !meta.file_type().is_file() || meta.len() > MAX_STATE_BYTES {
47        return Err(PlaneError::Invalid(format!(
48            "{} is not a regular state file of at most {MAX_STATE_BYTES} bytes",
49            path.display()
50        )));
51    }
52    let text = std::fs::read_to_string(path).map_err(|e| PlaneError::Invalid(e.to_string()))?;
53    let st: PlaneState = serde_json::from_str(&text)
54        .map_err(|e| PlaneError::Invalid(format!("{}: {e}", path.display())))?;
55    if st.version != STATE_VERSION {
56        return Err(PlaneError::Invalid(format!(
57            "{}: unsupported state version {}",
58            path.display(),
59            st.version
60        )));
61    }
62    Ok(Some(st))
63}
64
65fn write_atomic(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
66    use std::os::unix::fs::OpenOptionsExt;
67    let tmp: PathBuf = path.with_extension("json.tmp");
68    {
69        let mut f = std::fs::OpenOptions::new()
70            .create(true)
71            .write(true)
72            .truncate(true)
73            .mode(0o600)
74            .open(&tmp)?;
75        f.write_all(bytes)?;
76        f.sync_all()?;
77    }
78    std::fs::rename(&tmp, path)
79}
80
81impl PlacementControlPlane {
82    /// Persist to `path` after every change, restoring what it holds now.
83    /// A missing file starts empty; a malformed one is refused (the caller
84    /// decides; nothing is overwritten).
85    pub fn with_state_file(mut self, path: impl Into<PathBuf>) -> Result<Self, PlaneError> {
86        let path = path.into();
87        if let Some(st) = read_state(&path)? {
88            if let Ok(mut t) = self.targets.write() {
89                for mut target in st.targets {
90                    target.reachable = false;
91                    t.insert(target.node_id.clone(), target);
92                }
93            }
94            if let Ok(mut p) = self.placements.lock() {
95                for r in st.placements {
96                    p.insert(r.instance_id.clone(), r);
97                }
98            }
99            if let Ok(mut m) = self.seed_handles.lock() {
100                for e in st.seed_instances {
101                    m.insert(e.handle.instance_id.clone(), e);
102                }
103            }
104            if let Ok(mut l) = self.lives.lock() {
105                for (id, life) in st.lives {
106                    l.insert(id, life);
107                }
108            }
109            if let Ok(mut u) = self.unsettled.lock() {
110                for r in st.unsettled {
111                    u.insert(r.decision_id.clone(), r);
112                }
113            }
114        }
115        self.state_file = Some(path);
116        Ok(self)
117    }
118
119    /// Write the current state (no-op without a state file). Failures are
120    /// logged: the in-memory state stays authoritative for this process.
121    pub(super) fn persist(&self) {
122        let Some(path) = &self.state_file else {
123            return;
124        };
125        let _guard = self.state_lock.lock();
126        let st = PlaneState {
127            version: STATE_VERSION,
128            targets: self.targets(),
129            placements: self.placements(),
130            unsettled: self.unsettled(),
131            seed_instances: self
132                .seed_handles
133                .lock()
134                .map(|m| m.values().cloned().collect())
135                .unwrap_or_default(),
136            lives: self
137                .lives
138                .lock()
139                .map(|m| m.iter().map(|(k, v)| (k.clone(), v.clone())).collect())
140                .unwrap_or_default(),
141        };
142        let res = serde_json::to_vec_pretty(&st)
143            .map_err(std::io::Error::other)
144            .and_then(|b| write_atomic(path, &b));
145        if let Err(e) = res {
146            tracing::warn!(path = %path.display(), error = %e, "placement state not saved");
147        }
148    }
149
150    /// Drop the controller's records of `ids` (instances a revocation took
151    /// down on this node, see [`super::host_revoke`]), and their Seed
152    /// handles, and save. Returns how many records were removed.
153    pub fn forget_instances(&self, ids: &[String]) -> usize {
154        let mut n = 0;
155        if let Ok(mut p) = self.placements.lock() {
156            n = ids.iter().filter(|i| p.remove(*i).is_some()).count();
157        }
158        if let Ok(mut h) = self.seed_handles.lock() {
159            for i in ids {
160                h.remove(i);
161            }
162        }
163        if let Ok(mut l) = self.lives.lock() {
164            for i in ids {
165                l.remove(i);
166            }
167        }
168        if n > 0 {
169            self.persist();
170        }
171        n
172    }
173
174    /// Re-describe `node_id` with its stored key when its facts are not
175    /// cached (after a restart), so governance sees its real tier.
176    pub(super) async fn ensure_described(&self, node_id: &str) {
177        if self
178            .facts
179            .get(node_id, super::plane::now_ms() / 1000)
180            .is_some()
181        {
182            return;
183        }
184        if let Ok((t, pk)) = self.target(node_id) {
185            let _ = self.add_target_expecting(&t.addr, t.tier, Some(pk)).await;
186        }
187    }
188}