clawft_kernel/workload_runtime/
seed_ops.rs1use std::path::{Path, PathBuf};
16
17use clawft_types::secret::SecretString;
18use serde_json::{Value, json};
19
20use super::seed::{API_TIMEOUT, SeedApiRuntime};
21use super::seed_http::Method;
22use super::types::RuntimeError;
23
24pub const KIND_SEED_FIRMWARE: &str = "seed-firmware";
26
27#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct SeedBackup {
35 dir: PathBuf,
36 node_id: String,
37 device_id: String,
38 public_key: String,
39 firmware: String,
40 files: Vec<(String, String)>,
41 manifest_blake3: String,
42}
43
44pub const REQUIRED_BACKUP_FILES: [&str; 4] = [
46 "status.json",
47 "identity.json",
48 "apps.json",
49 "witness-chain.json",
50];
51
52#[derive(Debug, Clone, PartialEq, Eq)]
54pub enum UpgradeOutcome {
55 UpToDate {
57 version: String,
59 },
60 Applied {
62 from: String,
64 },
65}
66
67fn gated(status: &Value) -> bool {
68 status
69 .pointer("/integrity/writes_gated")
70 .and_then(Value::as_bool)
71 .unwrap_or(false)
72}
73
74fn write_private(path: &Path, bytes: &[u8]) -> Result<(), RuntimeError> {
75 use std::io::Write;
76 use std::os::unix::fs::OpenOptionsExt;
77 let mut f = std::fs::OpenOptions::new()
78 .create_new(true)
79 .write(true)
80 .mode(0o600)
81 .open(path)
82 .map_err(|e| RuntimeError::Backend(format!("backup write {}: {e}", path.display())))?;
83 f.write_all(bytes)
84 .map_err(|e| RuntimeError::Backend(format!("backup write: {e}")))
85}
86
87impl SeedBackup {
88 pub fn dir(&self) -> &Path {
90 &self.dir
91 }
92 pub fn node_id(&self) -> &str {
94 &self.node_id
95 }
96 pub fn device_id(&self) -> &str {
98 &self.device_id
99 }
100 pub fn firmware(&self) -> &str {
102 &self.firmware
103 }
104 pub fn files(&self) -> &[(String, String)] {
106 &self.files
107 }
108
109 pub fn verify(&self) -> Result<(), RuntimeError> {
112 let refuse = |m: String| RuntimeError::AdmissionRefused(m);
113 let m = std::fs::read(self.dir.join("manifest.json"))
114 .map_err(|e| refuse(format!("backup manifest: {e}")))?;
115 if blake3::hash(&m).to_hex().as_str() != self.manifest_blake3 {
116 return Err(refuse("backup manifest changed".into()));
117 }
118 let doc: Value =
119 serde_json::from_slice(&m).map_err(|e| refuse(format!("backup manifest: {e}")))?;
120 if doc != self.manifest_doc() {
121 return Err(refuse("backup manifest does not match this backup".into()));
122 }
123 for req in REQUIRED_BACKUP_FILES {
124 if !self.files.iter().any(|(n, _)| n == req) {
125 return Err(refuse(format!("backup lacks {req}")));
126 }
127 }
128 for (name, hash) in &self.files {
129 let b = std::fs::read(self.dir.join(name))
130 .map_err(|e| refuse(format!("backup file {name}: {e}")))?;
131 if blake3::hash(&b).to_hex().as_str() != hash {
132 return Err(refuse(format!("backup file {name} changed")));
133 }
134 }
135 Ok(())
136 }
137
138 fn manifest_doc(&self) -> Value {
139 json!({
140 "node_id": self.node_id,
141 "device_id": self.device_id,
142 "public_key": self.public_key,
143 "firmware": self.firmware,
144 "files": self.files,
145 })
146 }
147
148 pub fn audit(&self) -> Value {
150 json!({
151 "node_id": self.node_id,
152 "device_id": self.device_id,
153 "firmware": self.firmware,
154 "files": self.files.len(),
155 "manifest_blake3": self.manifest_blake3,
156 })
157 }
158}
159
160fn identity_field(v: &Value, key: &str) -> Result<String, RuntimeError> {
161 v.get(key)
162 .and_then(Value::as_str)
163 .filter(|s| !s.is_empty() && s.len() <= 256)
164 .map(str::to_string)
165 .ok_or_else(|| RuntimeError::Backend(format!("seed /api/v1/identity: no {key}")))
166}
167
168impl SeedApiRuntime {
169 pub async fn pair(
173 &self,
174 client_name: &str,
175 bootstrap: &SecretString,
176 ) -> Result<(), RuntimeError> {
177 if !crate::workload_pkg::manifest::valid_token(client_name, 64) {
178 return Err(RuntimeError::InvalidConfig(
179 "client name must be a plain token".into(),
180 ));
181 }
182 let t = &self.transport;
183 let (s, _) = t
184 .request(
185 Method::Post,
186 "/api/v1/pair/window",
187 None,
188 bootstrap,
189 API_TIMEOUT,
190 )
191 .await?;
192 if !(200..300).contains(&s) {
193 return Err(RuntimeError::Backend(format!("seed pair window: HTTP {s}")));
194 }
195 let body = json!({ "client_name": client_name });
196 let (s, v) = t
197 .request(
198 Method::Post,
199 "/api/v1/pair",
200 Some(&body),
201 bootstrap,
202 API_TIMEOUT,
203 )
204 .await?;
205 if !(200..300).contains(&s) {
206 return Err(RuntimeError::Backend(format!("seed pair: HTTP {s}")));
207 }
208 let token = v
209 .get("token")
210 .and_then(Value::as_str)
211 .filter(|t| t.len() >= 16 && t.len() <= 512 && t.bytes().all(|b| b.is_ascii_graphic()))
212 .ok_or_else(|| {
213 RuntimeError::Backend("seed pair: no usable token in response".into())
214 })?;
215 self.creds.put(&self.cfg.node_id, SecretString::new(token))
216 }
217
218 pub async fn writes_gated(&self) -> Result<bool, RuntimeError> {
220 let s = self
221 .api(Method::Get, "/api/v1/status", None, API_TIMEOUT)
222 .await?;
223 Ok(gated(&s))
224 }
225
226 pub async fn backup(&self, dir: &Path) -> Result<SeedBackup, RuntimeError> {
229 let mut docs: Vec<(String, Value)> = Vec::new();
230 for (name, path) in [
231 ("status.json", "/api/v1/status"),
232 ("identity.json", "/api/v1/identity"),
233 ("apps.json", "/api/v1/apps"),
234 ("witness-chain.json", "/api/v1/witness/chain"),
235 ] {
236 docs.push((
237 name.into(),
238 self.api(Method::Get, path, None, API_TIMEOUT).await?,
239 ));
240 }
241 for c in self.installed().await? {
242 let v = self
243 .api(
244 Method::Get,
245 &format!("/api/v1/apps/{}/config", c.id),
246 None,
247 API_TIMEOUT,
248 )
249 .await?;
250 docs.push((format!("config-{}.json", c.id), v));
251 }
252 let identity = docs
253 .iter()
254 .find(|(n, _)| n == "identity.json")
255 .map(|(_, v)| v.clone())
256 .unwrap_or(Value::Null);
257 let device_id = identity_field(&identity, "device_id")?;
258 let public_key = identity_field(&identity, "public_key")?;
259 let firmware = identity_field(&identity, "firmware_version")?;
260 {
261 use std::os::unix::fs::DirBuilderExt;
262 std::fs::DirBuilder::new()
263 .mode(0o700)
264 .create(dir)
265 .map_err(|e| RuntimeError::Backend(format!("backup dir {}: {e}", dir.display())))?;
266 }
267 let mut files = Vec::new();
268 for (name, v) in &docs {
269 let bytes = serde_json::to_vec_pretty(v).unwrap_or_default();
270 write_private(&dir.join(name), &bytes)?;
271 files.push((name.clone(), blake3::hash(&bytes).to_hex().to_string()));
272 }
273 let mut backup = SeedBackup {
274 dir: dir.to_path_buf(),
275 node_id: self.cfg.node_id.clone(),
276 device_id,
277 public_key,
278 firmware,
279 files,
280 manifest_blake3: String::new(),
281 };
282 let manifest = serde_json::to_vec_pretty(&backup.manifest_doc()).unwrap_or_default();
283 write_private(&dir.join("manifest.json"), &manifest)?;
284 backup.manifest_blake3 = blake3::hash(&manifest).to_hex().to_string();
285 Ok(backup)
286 }
287
288 pub async fn check_backup(
294 &self,
295 backup: &SeedBackup,
296 same_firmware: bool,
297 ) -> Result<String, RuntimeError> {
298 backup.verify()?;
299 let refuse = |m: &str| Err(RuntimeError::AdmissionRefused(m.into()));
300 if backup.node_id != self.cfg.node_id {
301 return refuse("backup was taken from a different Seed node");
302 }
303 let id = self
304 .api(Method::Get, "/api/v1/identity", None, API_TIMEOUT)
305 .await?;
306 if identity_field(&id, "device_id")? != backup.device_id
307 || identity_field(&id, "public_key")? != backup.public_key
308 {
309 return refuse("backup device identity does not match this Seed");
310 }
311 let firmware = identity_field(&id, "firmware_version")?;
312 if same_firmware && firmware != backup.firmware {
313 return refuse("backup predates the current firmware; take a new backup");
314 }
315 Ok(firmware)
316 }
317
318 pub async fn upgrade_firmware(
323 &self,
324 backup: &SeedBackup,
325 target_version: &str,
326 ) -> Result<UpgradeOutcome, RuntimeError> {
327 if !super::cog_spec::valid_value(target_version) {
328 return Err(RuntimeError::InvalidConfig(
329 "firmware target version must be a plain version string".into(),
330 ));
331 }
332 self.check_backup(backup, true).await?;
333 if self.writes_gated().await? {
334 return Err(RuntimeError::InvalidState(
335 "writes are gated; recover (truncate-confirm after backup) before upgrading".into(),
336 ));
337 }
338 let check = self
339 .api(Method::Get, "/api/v1/upgrade/check", None, API_TIMEOUT)
340 .await?;
341 let current = check
342 .get("current_version")
343 .and_then(Value::as_str)
344 .unwrap_or_default()
345 .to_string();
346 if !check
347 .get("pending_update")
348 .and_then(Value::as_bool)
349 .unwrap_or(false)
350 {
351 return Ok(UpgradeOutcome::UpToDate { version: current });
352 }
353 match check.get("target_version").and_then(Value::as_str) {
354 Some(t) if t == target_version => {}
355 Some(t) => {
356 return Err(RuntimeError::AdmissionRefused(format!(
357 "pending firmware is {t}, the operator pinned {target_version}"
358 )));
359 }
360 None => {
361 return Err(RuntimeError::AdmissionRefused(
362 "the Seed does not say which firmware is pending; refusing to apply it".into(),
363 ));
364 }
365 }
366 self.api(Method::Post, "/api/v1/upgrade/apply", None, API_TIMEOUT * 6)
367 .await?;
368 Ok(UpgradeOutcome::Applied { from: current })
369 }
370
371 pub async fn recover_writes_gated(&self, backup: &SeedBackup) -> Result<(), RuntimeError> {
374 self.check_backup(backup, false).await?;
376 if !self.writes_gated().await? {
377 return Err(RuntimeError::InvalidState(
378 "writes are not gated; nothing to recover".into(),
379 ));
380 }
381 self.api(
382 Method::Post,
383 "/api/v1/store/truncate-confirm",
384 Some(&json!({ "confirm": true })),
385 API_TIMEOUT,
386 )
387 .await
388 .map(|_| ())
389 }
390}