1use crate::check;
18use crate::evidence::{Effect, Evidence};
19use crate::sim::{dur, unit, value};
20use planarian_backend_linux::{Paths, Real};
21use planarian_daemon::LogRecord;
22use planarian_daemon::config::{CONFIG, FileConfig};
23use planarian_exec::Submitted;
24use planarian_ipc::{ConsoleReport, Request, Response, SOCKET};
25use planarian_schema::*;
26use planarian_txn::journal::Journal;
27use planarian_txn::ledger::{Entry, Ledger, Record};
28use planarian_txn::store::FileStore;
29use serde::{Deserialize, Serialize};
30use std::collections::{BTreeMap, BTreeSet};
31use std::io::{BufRead, BufReader, Write};
32use std::os::unix::net::UnixStream;
33use std::path::{Path, PathBuf};
34use std::process::Command;
35use std::time::{Duration, Instant};
36use toml::Value as T;
37
38pub const RUN: &str = "/var/lib/gym-run";
40const HOOKS_DIR: &str = "/run/planarian-gym";
41
42fn user_for(actor: &str) -> &'static str {
44 match actor {
45 "root" => "root",
46 "user:1000" => "alice",
47 "user:1002" => "carol",
48 _ => "bob",
50 }
51}
52
53#[derive(Clone, Debug, Default, Serialize, Deserialize)]
54pub struct Plan {
55 pub steps: Vec<String>,
58 pub crash_before: BTreeMap<usize, String>,
60 pub watched: BTreeMap<usize, String>,
62 pub hold: bool,
63 pub window_secs: u64,
64}
65
66#[derive(Clone, Debug, Default, Serialize, Deserialize)]
67struct Start {
68 at: Timestamp,
69 unix: u64,
70 base: GenerationId,
71 boot_entries: Vec<GenerationId>,
72 log_lines: usize,
73 pre_status: BTreeMap<UnitId, String>,
74 touched: BTreeSet<UnitId>,
75 #[serde(default)]
76 probe_defs: String,
77 #[serde(default)]
78 gc_roots: BTreeSet<String>,
79}
80
81#[derive(Clone, Debug, Default, Serialize, Deserialize)]
82struct Sample {
83 at: Timestamp,
84 status: BTreeMap<UnitId, String>,
85 incidents: Vec<Incident>,
86 notices: Vec<String>,
87}
88
89fn steps(entry: &T) -> Vec<T> {
90 ["setup", "inject", "after"]
91 .iter()
92 .flat_map(|k| match entry.get(k) {
93 Some(T::Array(a)) => a.clone(),
94 Some(t @ T::Table(_)) => vec![t.clone()],
95 _ => vec![],
96 })
97 .collect()
98}
99
100fn s<'a>(step: &'a T, k: &str) -> &'a str {
101 step.get(k).and_then(T::as_str).unwrap_or("")
102}
103
104fn is_request(step: &T) -> bool {
105 step.get("transition").is_some() || matches!(s(step, "raw"), "ask" | "cli" | "approve_pending")
106}
107
108pub fn plan(entry: &T) -> Plan {
110 let all = steps(entry);
111 let mut p = Plan { window_secs: crate::window(entry).0 / 1000, ..Default::default() };
112 let mut changed_generation = false;
113 let layouts: Vec<&str> = entry
116 .get("layouts")
117 .and_then(T::as_array)
118 .map(|a| a.iter().filter_map(T::as_str).collect())
119 .unwrap_or_default();
120 if !layouts.is_empty() && !layouts.iter().any(|l| matches!(*l, "ext4" | "btrfs")) {
121 p.steps.push(format!("unsupported: a journal on {} (the gym VM's is ext4 or btrfs)", layouts.join(" or ")));
122 return p;
123 }
124 for (i, st) in all.iter().enumerate() {
125 if matches!(s(st, "transition"), "ApplyChange" | "SwitchGeneration" | "Rollback") {
126 changed_generation = true;
127 }
128 if s(st, "raw") == "reboot" && changed_generation {
131 p.steps.push("unsupported: booting a published generation (the test VM has no boot loader)".into());
132 continue;
133 }
134 let when = st.get("when").and_then(|w| w.get("journal")).and_then(T::as_str);
135 let op_called = st.get("when").and_then(|w| w.get("op_called")).and_then(T::as_str);
136 let after = st.get("when").and_then(|w| w.get("after"));
137 let raw = s(st, "raw");
138 let slow_activation = all.iter().any(|x| s(x, "raw") == "slow_backend" && s(x, "call") == "activate_live");
139 let kind = if raw == "fill_fs" && s(st, "fs") == "device:fs-nix" {
140 "unsupported: a full Nix store (the test VM's store is the host's)".into()
144 } else if op_called == Some("activate_live") && raw == "kill_process" && slow_activation {
145 p.watched.insert(i, "unit:planarian-op-".into());
149 "armed".into()
150 } else if let Some(call) = op_called {
151 match (0..i).rev().find(|j| is_request(&all[*j])) {
152 Some(j) if raw == "kill_process" => {
153 p.crash_before.insert(j, format!("OpCalled:{call}"));
154 "armed".to_string()
155 }
156 _ => format!("unsupported: {raw} timed to {call}"),
157 }
158 } else if let Some(a) = after {
159 p.watched.insert(i, format!("after:{}", a.as_str().unwrap_or("0s")));
160 "armed".into()
161 } else if let Some(state) = when {
162 match raw {
163 "kill_process" if s(st, "process") == "planariad" && state == "Activating" && slow_activation => {
166 p.watched.insert(i, "unit:planarian-op-".into());
167 "armed".into()
168 }
169 "kill_process" if s(st, "process") == "planariad" => {
170 let point =
171 if state == "OpId" { "OpCalled:activate_live".to_string() } else { format!("State:{state}") };
172 match (0..i).rev().find(|j| is_request(&all[*j])) {
173 Some(j) => {
174 p.crash_before.insert(j, point);
175 "armed".to_string()
176 }
177 None => "unsupported: a timed kill with no request before it".into(),
178 }
179 }
180 "edit_owner_source" | "block_network" | "fill_fs" | "kill_unit" | "edit_override_file" => {
181 p.watched.insert(i, state.to_string());
182 "armed".into()
183 }
184 other => format!("unsupported: {other} timed to the journal"),
185 }
186 } else if st.get("when").is_some() && raw != "break_journal_chain" {
187 format!("unsupported: {} timed by {:?}", raw, st.get("when"))
188 } else if st.get("transition").is_some() {
189 match s(st, "transition") {
190 "ApplyChange" | "RestartUnit" | "CollectGarbage" | "Rollback" | "SwitchGeneration" | "BindDriver"
191 | "SetOverride" => "vm".into(),
192 t => format!("unsupported: transition {t}"),
193 }
194 } else {
195 match raw {
196 "reboot" | "suspend" | "hotunplug" | "stop_host_service" | "start_host_service" => "host".into(),
197 "cli"
198 if {
199 let argv: Vec<&str> = st
201 .get("argv")
202 .and_then(T::as_array)
203 .map(|a| a.iter().filter_map(T::as_str).collect())
204 .unwrap_or_default();
205 argv.first() == Some(&"internal")
206 && !matches!(
207 argv.get(1),
208 Some(&"wifi-connect" | &"snapshot" | &"journal-churn" | &"present-token")
209 )
210 } =>
211 {
212 format!("unsupported: {raw} {:?} (no such command)", st.get("argv"))
213 }
214 "kill_unit"
215 | "stop_unit_out_of_band"
216 | "set_knob"
217 | "set_persistent_knob"
218 | "corrupt_state"
219 | "unbind_driver"
220 | "block_network"
221 | "unblock_network"
222 | "kill_process"
223 | "hold_down"
224 | "release"
225 | "restart_process"
226 | "wait"
227 | "ask"
228 | "cli"
229 | "approve_pending"
230 | "greeter_login"
231 | "logout"
232 | "fill_fs"
233 | "edit_owner_source"
234 | "out_of_band_rebuild"
235 | "advance_clock"
236 | "slow_backend"
237 | "withhold_reply"
238 | "backend_result"
239 | "set_setting"
240 | "mask_unit"
241 | "unmask_unit"
242 | "ack_alarm"
243 | "corrupt_core_hash"
244 | "break_journal_chain"
245 | "plant_early_unit"
246 | "agent_run"
247 | "request_then_exit"
248 | "plant_test_build"
249 | "forge_proposal_fields"
250 | "delete_journal_record"
251 | "oom" => "vm".into(),
252 "force_compaction" => "vm".into(),
253 "interrupt_write" => format!("unsupported: {raw} needs to interrupt the daemon's own write"),
254 other => format!("unsupported: {other}"),
255 }
256 };
257 if raw == "hold_down" {
258 p.hold = true;
259 }
260 p.steps.push(kind);
261 }
262 p
263}
264
265fn config() -> FileConfig {
266 let path = std::env::var("PLANARIAN_CONFIG").unwrap_or_else(|_| CONFIG.into());
267 FileConfig::load(Path::new(&path)).unwrap_or_else(|e| die(&format!("{path}: {e}")))
268}
269
270fn real(cfg: &FileConfig) -> Real {
271 let paths = Paths {
272 state: cfg.state_dir.clone(),
273 owner: cfg.owner.clone(),
274 flake_attr: cfg.flake_attr.clone(),
275 ..Default::default()
276 };
277 Real::new(paths, cfg.devices.clone())
278}
279
280fn die(m: &str) -> ! {
281 eprintln!("planarian-gym vm: {m}");
282 std::process::exit(2)
283}
284
285fn sh(cmd: &str) -> bool {
286 Command::new("sh").arg("-c").arg(cmd).status().map(|s| s.success()).unwrap_or(false)
287}
288
289fn sh_out(cmd: &str) -> String {
290 Command::new("sh")
291 .arg("-c")
292 .arg(cmd)
293 .output()
294 .map(|o| String::from_utf8_lossy(&o.stdout).into_owned())
295 .unwrap_or_default()
296}
297
298pub fn call(req: &Request) -> Result<Response, String> {
299 let path = std::env::var("PLANARIAN_SOCKET").unwrap_or_else(|_| SOCKET.into());
300 let mut c = UnixStream::connect(&path).map_err(|e| format!("{path}: {e}"))?;
301 c.set_read_timeout(Some(std::time::Duration::from_secs(600))).map_err(|e| e.to_string())?;
303 c.write_all(req.encode().as_bytes()).map_err(|e| e.to_string())?;
304 let mut line = String::new();
305 BufReader::new(c).read_line(&mut line).map_err(|e| e.to_string())?;
306 serde_json::from_str(&line).map_err(|e| format!("bad answer: {e}"))
307}
308
309fn console() -> Option<ConsoleReport> {
310 match call(&Request::Console) {
311 Ok(Response::Console(r)) => Some(*r),
312 _ => None,
313 }
314}
315
316fn status_name(st: &UnitStatus) -> String {
317 format!("{st:?}").split('(').next().unwrap_or("").to_string()
318}
319
320fn statuses(r: &ConsoleReport) -> BTreeMap<UnitId, String> {
321 r.nodes.iter().map(|n| (n.unit.clone(), status_name(&n.health))).collect()
322}
323
324fn run_dir() -> PathBuf {
325 let d = PathBuf::from(RUN);
326 let _ = std::fs::create_dir_all(&d);
327 let _ = sh(&format!("chmod 1777 {RUN}"));
328 d
329}
330
331fn read_json<V: for<'de> Deserialize<'de>>(p: &Path) -> Option<V> {
332 serde_json::from_slice(&std::fs::read(p).ok()?).ok()
333}
334
335fn write_json<V: Serialize>(p: &Path, v: &V) {
336 let _ = std::fs::write(p, serde_json::to_vec_pretty(v).expect("serializes"));
337}
338
339fn ledger(cfg: &FileConfig) -> (Ledger, bool) {
341 match journal_copy(cfg) {
342 Some(j) => (j.ledger().clone(), true),
343 None => (Ledger::default(), false),
344 }
345}
346
347fn segment(cfg: &FileConfig) -> Vec<Entry> {
349 journal_copy(cfg).and_then(|mut j| j.segment().ok()).unwrap_or_default()
350}
351
352const SAVED: &str = "entries-saved.json";
354
355fn compact_and_save() -> Result<(), String> {
361 let resp = call(&Request::GymCompact)?;
362 if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(PathBuf::from(RUN).join("responses.jsonl")) {
363 let _ = writeln!(f, "{}", serde_json::to_string(&resp).expect("serializes"));
364 }
365 let entries = match resp {
366 Response::Compacted { entries } => entries,
367 Response::Error { message } => return Err(format!("GymCompact: {message}")),
368 _ => return Err("GymCompact: an unexpected answer".into()),
369 };
370 let path = PathBuf::from(RUN).join(SAVED);
371 let mut saved: Vec<Entry> = read_json(&path).unwrap_or_default();
372 for v in entries {
373 let e: Entry = serde_json::from_value(v).map_err(|e| format!("GymCompact: an entry: {e}"))?;
374 if saved.last().is_none_or(|l| l.seq < e.seq) {
375 saved.push(e);
376 }
377 }
378 write_json(&path, &saved);
379 Ok(())
380}
381
382fn journal_copy(cfg: &FileConfig) -> Option<Journal<FileStore>> {
383 let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
384 let tmp = PathBuf::from(format!("{RUN}/journal-copy"));
385 let _ = std::fs::remove_dir_all(&tmp);
386 let _ = sh(&format!(
387 "mkdir -p {t} && cp -a {j} {t}/journal && cp -a {x} {t}/txn 2>/dev/null; true",
388 t = tmp.display(),
389 j = paths.journal_dir().display(),
390 x = paths.txn_dir().display()
391 ));
392 match FileStore::open(&tmp.join("journal"), &tmp.join("txn"), cfg.journal_bytes).map(Journal::open) {
393 Ok(Ok(j)) => Some(j),
394 _ => None,
395 }
396}
397
398fn probe_defs(cfg: &FileConfig) -> String {
400 let b = real(cfg);
401 let g = Backends::generations(&b);
402 let eval = g.current().and_then(|c| g.path_of(c)).and_then(|p| g.evaluate_built(&p));
403 match eval {
404 Ok(e) => {
405 let probes: BTreeMap<String, Vec<ProbeDef>> =
406 e.units.iter().map(|(id, d)| (id.to_string(), d.probes.clone())).collect();
407 serde_json::to_string(&probes).expect("serializes")
408 }
409 Err(o) => format!("unreadable: {o:?}"),
410 }
411}
412
413fn probe_ports(cfg: &FileConfig, u: &UnitId) -> Vec<u16> {
415 let b = real(cfg);
416 let g = Backends::generations(&b);
417 let Ok(e) = g.current().and_then(|c| g.path_of(c)).and_then(|p| g.evaluate_built(&p)) else { return vec![] };
418 let port = |hostport: &str| hostport.rsplit(':').next().and_then(|p| p.parse::<u16>().ok());
419 e.units
420 .get(u)
421 .map(|d| {
422 d.probes
423 .iter()
424 .filter_map(|p| match &p.kind {
425 ProbeKind::Tcp { addr } => port(addr),
426 ProbeKind::Http { url, .. } => {
427 url.split("://").nth(1).and_then(|r| r.split('/').next()).and_then(port)
428 }
429 _ => None,
430 })
431 .collect()
432 })
433 .unwrap_or_default()
434}
435
436fn build_roots() -> BTreeSet<String> {
437 std::fs::read_dir("/nix/var/nix/gcroots/planarian/builds")
438 .map(|rd| rd.flatten().map(|e| e.path().display().to_string()).collect())
439 .unwrap_or_default()
440}
441
442fn effects(entries: &[Entry], since_unix: u64) -> Vec<Effect> {
448 #[derive(Deserialize)]
449 struct J {
450 #[serde(rename = "UNIT")]
451 unit: Option<String>,
452 #[serde(rename = "MESSAGE")]
453 message: Option<String>,
454 #[serde(rename = "__REALTIME_TIMESTAMP")]
455 at: String,
456 }
457 let mut spans: BTreeMap<String, (Option<Timestamp>, Option<Timestamp>)> = BTreeMap::new();
458 for l in sh_out(&format!("journalctl -o json --no-pager --since @{since_unix} _PID=1")).lines() {
459 let Ok(j) = serde_json::from_str::<J>(l) else { continue };
460 let (Some(u), Some(m)) = (j.unit, j.message) else { continue };
461 let Some(name) = u.strip_suffix(".service").filter(|n| n.starts_with("planarian-op-")) else { continue };
462 let at = Timestamp(j.at.parse::<u64>().unwrap_or(0) / 1000);
463 let span = spans.entry(name.to_string()).or_default();
464 if m.starts_with("Starting") || m.starts_with("Started") {
465 span.0.get_or_insert(at);
466 } else if m.starts_with("Finished")
467 || m.starts_with("Stopped")
468 || m.starts_with("Failed")
469 || m.contains("Deactivated")
470 {
471 span.1 = Some(at);
472 }
473 }
474 let revert_name = |call: &str| -> String {
475 match call {
476 "activate_live" => "activate_live(candidate)".into(),
477 other => other.to_string(),
478 }
479 };
480 let acks: BTreeMap<OpId, Outcome> = entries
481 .iter()
482 .filter_map(|x| match &x.record {
483 Record::OpAck { op, outcome } => Some((*op, outcome.clone())),
484 _ => None,
485 })
486 .collect();
487 entries
488 .iter()
489 .filter_map(|x| {
490 let Record::OpBegin { op, call } = &x.record else { return None };
491 if acks.get(op).is_some_and(Outcome::is_fenced) {
492 return None;
493 }
494 let unit = format!("planarian-op-{}-{}-{}", op.txn.0, op.epoch, op.seq);
495 let (start, end) = match spans.get(&unit) {
496 Some((s, e)) => (s.unwrap_or(x.at), *e),
497 None if acks.contains_key(op) => (x.at, Some(x.at)),
498 None => return None,
499 };
500 Some(Effect { call: revert_name(call), txn: op.txn, epoch: op.epoch, seq: op.seq, start, end })
501 })
502 .collect()
503}
504
505fn journal_reached(cfg: &FileConfig, state: &str) -> bool {
506 let (l, _) = ledger(cfg);
507 l.live.values().any(|t| t.path.iter().any(|p| p == state))
508 || l.terminal.values().any(|t| t.path.iter().any(|p| p == state))
509}
510
511fn begin(entry: &T) {
515 let cfg = config();
516 let dir = run_dir();
517 let p = plan(entry);
518 write_json(&dir.join("plan.json"), &p);
519 let _ = std::fs::remove_file(dir.join("third-party-override"));
520 let deadline = Instant::now() + Duration::from_secs(1200);
521 loop {
522 if let Some(r) = console() {
523 let unhealthy: Vec<String> = r
524 .nodes
525 .iter()
526 .filter(|n| n.expected && !matches!(n.health, UnitStatus::Running | UnitStatus::Bound))
527 .map(|n| format!("{} {:?}", n.unit, n.health))
528 .chain(
529 r.status
530 .incidents
531 .iter()
532 .filter(|i| i.state != IncidentState::Closed)
533 .map(|i| format!("incident on {} ({:?})", i.target, i.state)),
534 )
535 .collect();
536 if unhealthy.is_empty() {
537 break;
538 }
539 if Instant::now() > deadline {
540 die(&format!("no healthy baseline: {unhealthy:?}"));
541 }
542 } else if Instant::now() > deadline {
543 die("planariad does not answer");
544 }
545 std::thread::sleep(Duration::from_secs(5));
546 }
547 if ledger(&cfg).0.known_good.is_none() {
548 sh("script -qec 'planarian mark-good' /dev/null");
549 let until = Instant::now() + Duration::from_secs(300);
550 while ledger(&cfg).0.known_good.is_none() && Instant::now() < until {
551 std::thread::sleep(Duration::from_secs(3));
552 }
553 }
554 if p.hold {
555 sh(
557 "mkdir -p /run/systemd/system/planariad.service.d && printf '[Service]\\nRestart=no\\n' > /run/systemd/system/planariad.service.d/gym-hold.conf && systemctl daemon-reload",
558 );
559 }
560 let r = console().unwrap_or_else(|| die("planariad stopped answering"));
561 let gens = real(&cfg);
562 let g = planarian_schema::Backends::generations(&gens);
563 let touched = steps(entry)
564 .iter()
565 .flat_map(|st| ["unit", "device"].map(|k| st.get(k).and_then(T::as_str).map(unit)))
566 .flatten()
567 .collect();
568 let start = Start {
569 at: r.now,
570 unix: std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0),
571 base: g.current().unwrap_or(r.status.generation),
572 boot_entries: g.boot_entries().unwrap_or_default(),
573 log_lines: std::fs::read_to_string(cfg.state_dir.join("log/executor.jsonl"))
574 .map(|t| t.lines().count())
575 .unwrap_or(0),
576 pre_status: statuses(&r),
577 touched,
578 probe_defs: probe_defs(&cfg),
579 gc_roots: build_roots(),
580 };
581 write_json(&dir.join("start.json"), &start);
582 let _ = std::fs::write(dir.join("responses.jsonl"), "");
583 let _ = sh(&format!("chmod 666 {RUN}/responses.jsonl"));
584 let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
585 sh(&format!(
586 "systemctl stop gym-sampler 2>/dev/null; systemd-run --unit=gym-sampler -E PATH -E PLANARIAN_GYM_CATALOG {exe} vm sample"
587 ));
588 for state in p.watched.values() {
589 if let Some(call) = pause_call(state) {
590 hooks_set(call, serde_json::json!({ "pause": state }));
591 }
592 }
593 for (i, state) in &p.watched {
594 sh(&format!(
595 "systemd-run --unit=gym-watch-{i} -E PATH -E PLANARIAN_GYM_CATALOG {exe} vm step --id {} --index {i} --watched {state}",
596 s(entry, "id")
597 ));
598 }
599 println!("begin: base generation {}, {} units", start.base.0, start.pre_status.len());
600}
601
602fn sample() {
604 let path = PathBuf::from(RUN).join("samples.jsonl");
605 loop {
606 if let Some(r) = console()
607 && let Ok(mut f) = std::fs::OpenOptions::new().create(true).append(true).open(&path)
608 {
609 let smp = Sample {
610 at: r.now,
611 status: statuses(&r),
612 incidents: r.status.incidents.clone(),
613 notices: r.status.notices.clone(),
614 };
615 let _ = writeln!(f, "{}", serde_json::to_string(&smp).expect("serializes"));
616 }
617 record_posture();
619 std::thread::sleep(Duration::from_secs(2));
620 }
621}
622
623fn record_posture() {
625 let (classes, responses) = posture_snapshot();
626 if !classes.is_empty()
627 && let Ok(mut f) =
628 std::fs::OpenOptions::new().create(true).append(true).open(PathBuf::from(RUN).join("posture.jsonl"))
629 {
630 let _ = writeln!(f, "{}", serde_json::to_string(&(classes, responses)).expect("serializes"));
631 }
632}
633
634fn posture_snapshot() -> (BTreeSet<String>, BTreeSet<String>) {
637 let classes: BTreeSet<String> = std::fs::read_dir("/run/planarian")
638 .into_iter()
639 .flatten()
640 .flatten()
641 .filter_map(|e| e.file_name().to_str().and_then(|n| n.strip_prefix("failed-")).map(String::from))
642 .collect();
643 let mut r = BTreeSet::new();
644 if classes.is_empty() {
645 return (classes, r);
646 }
647 if Path::new("/run/planarian/alarm").exists() && Path::new("/run/issue.d/50-planarian.issue").exists() {
648 r.insert("alarm".to_string());
649 }
650 if sh("journalctl -q -t planarian-posture -p alert --no-pager | grep -q 'failure class'") {
651 r.insert("notify_admins".into());
652 }
653 let launched = "systemctl list-units --no-legend --state=active,activating 'planarian-agent-*.service' | grep -q .";
655 if sh("systemctl is-active --quiet gym-agent.service") || sh(launched) {
656 r.insert("agents_running".into());
657 } else {
658 r.insert("disable_agents".into());
659 }
660 if Path::new("/run/planarian/sshd/posture.conf").exists() {
661 r.insert("restrict_remote".into());
662 }
663 if Path::new("/run/nologin").exists() || Path::new("/etc/nologin").exists() {
664 r.insert("restrict_login".into());
665 }
666 if sh("systemctl is-active --quiet rescue.target || systemctl is-active --quiet emergency.target") {
667 r.insert("halt_to_recovery".into());
668 }
669 (classes, r)
670}
671
672fn posture_seen(cfg: &FileConfig, since_unix: u64) -> crate::evidence::PostureSeen {
675 let mut p = crate::evidence::PostureSeen::default();
676 let mut responses = BTreeSet::new();
677 for line in std::fs::read_to_string(PathBuf::from(RUN).join("posture.jsonl")).unwrap_or_default().lines() {
678 if let Ok((c, r)) = serde_json::from_str::<(BTreeSet<String>, BTreeSet<String>)>(line) {
679 p.classes.extend(c);
680 responses.extend(r);
681 }
682 }
683 p.responses = Some(responses);
684 let actor = |name: &str| match name {
685 "root" => "root".to_string(),
686 "alice" => "user:1000".into(),
687 "carol" => "user:1002".into(),
688 _ => "user:1001".into(),
689 };
690 for l in sh_out(&format!("journalctl -q -o cat --no-pager --since @{since_unix} -t planarian-posture")).lines() {
691 if let Some(c) = l.strip_prefix("failure class ").and_then(|r| r.split(':').next()) {
692 p.classes.insert(c.to_string());
693 } else if let Some(who) = l.split("acknowledged by ").nth(1) {
694 p.acks.push(actor(who.trim()));
695 } else if let Some(who) = l.strip_prefix("acknowledgement refused for ").and_then(|r| r.split(' ').next()) {
696 p.refused_acks.push(actor(who));
697 } else if l.contains("restrictions lifted") {
698 p.lifted = true;
699 }
700 }
701 for line in std::fs::read_to_string(PathBuf::from(RUN).join("acks.jsonl")).unwrap_or_default().lines() {
702 if let Ok(v) = serde_json::from_str::<serde_json::Value>(line)
703 && let Some(a) = v["actor"].as_str()
704 {
705 p.refused_acks.push(a.to_string());
706 }
707 }
708 p.finding = std::fs::read_to_string("/run/planarian/integrity").ok().map(|s| s.trim().to_string());
709 let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
710 p.chain_broken = matches!(
711 FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes).map(Journal::open),
712 Ok(Err(planarian_txn::journal::JournalError::Integrity(_)))
713 );
714 p
715}
716
717fn request_at(actor: &str, req: &Request, tty: bool) -> Result<(), String> {
721 let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
722 let json = serde_json::to_string(req).expect("serializes");
723 let script = PathBuf::from(RUN).join("request.sh");
724 let user = user_for(actor);
725 let q = json.replace('\'', "'\\''");
726 let inner = if user == "root" {
727 format!("{exe} vm request --json '{q}'")
728 } else {
729 format!("runuser -u {user} -- {exe} vm request --json '{q}'")
730 };
731 std::fs::write(&script, format!("#!/bin/sh\nexec {inner}\n")).map_err(|e| e.to_string())?;
732 sh(&format!("chmod +x {}", script.display()));
733 let cmd = if tty {
734 let helper = PathBuf::from(RUN).join("on-a-terminal.py");
738 std::fs::write(&helper, ON_A_TERMINAL).map_err(|e| e.to_string())?;
739 format!("python3 {} {} < /dev/null", helper.display(), script.display())
740 } else {
741 format!("setsid -w {} < /dev/null", script.display())
742 };
743 if sh(&cmd) { Ok(()) } else { Err(format!("request as {user} failed")) }
744}
745
746const ON_A_TERMINAL: &str = r#"import os, pty, sys
749pid, fd = pty.fork()
750if pid == 0:
751 os.execv(sys.argv[1], [sys.argv[1]])
752out = b""
753while True:
754 try:
755 b = os.read(fd, 4096)
756 except OSError:
757 break
758 if not b:
759 break
760 out += b
761sys.stdout.write(out.decode(errors="replace"))
762_, status = os.waitpid(pid, 0)
763sys.exit(os.waitstatus_to_exitcode(status))
764"#;
765
766fn request_as(actor: &str, req: &Request) -> Result<(), String> {
768 let exe = std::env::current_exe().map(|e| e.display().to_string()).unwrap_or_else(|_| "planarian-gym".into());
769 let json = serde_json::to_string(req).expect("serializes");
770 let user = user_for(actor);
771 let q = json.replace('\'', "'\\''");
772 if let Some(name) = actor.strip_prefix("agent:") {
775 let unit = planarian_schema::backend::agent_unit(name);
776 let cmd = format!(
777 "uid=$(systemctl show -p UID --value {unit}); gid=$(systemctl show -p GID --value {unit}); \
778 [ -n \"$uid\" ] && [ \"$uid\" != \"[not set]\" ] || exit 1; \
779 cg=$(systemctl show -p ControlGroup --value {unit}); [ -n \"$cg\" ] || exit 1; \
780 sh -c 'echo $$ > /sys/fs/cgroup'\"$cg\"'/cgroup.procs && \
781 exec setpriv --reuid=$0 --regid=$1 --clear-groups {exe} vm request --json \"$2\"' \"$uid\" \"$gid\" '{q}'"
782 );
783 return if sh(&cmd) { Ok(()) } else { Err(format!("request as {actor} failed")) };
784 }
785 let cmd = if user == "root" {
786 format!("{exe} vm request --json '{q}'")
787 } else {
788 format!("runuser -u {user} -- {exe} vm request --json '{q}'")
789 };
790 if sh(&cmd) { Ok(()) } else { Err(format!("request as {user} failed")) }
791}
792
793fn request(json: &str) {
794 let req: Request = serde_json::from_str(json).unwrap_or_else(|e| die(&format!("request: {e}")));
795 let resp = call(&req).unwrap_or_else(|e| Response::Error { message: e });
796 if let Ok(mut f) = std::fs::OpenOptions::new().append(true).open(PathBuf::from(RUN).join("responses.jsonl")) {
797 let _ = writeln!(f, "{}", serde_json::to_string(&resp).expect("serializes"));
798 }
799 println!("{}", serde_json::to_string(&resp).expect("serializes"));
800}
801
802fn unit_cmd(u: &UnitId) -> (String, String) {
803 let suffix = match u.kind {
804 UnitKind::Target => "target",
805 _ => "service",
806 };
807 match u.scope {
808 Scope::User { uid, .. } => (
809 format!("systemctl --user --machine={}@", user_for(&format!("user:{uid}"))),
810 format!("{}.{suffix}", u.name),
811 ),
812 Scope::System => ("systemctl".into(), format!("{}.{suffix}", u.name)),
813 }
814}
815
816fn pause_call(state: &str) -> Option<&'static str> {
822 match state {
823 "Staged" | "Built" => Some("build"),
824 "Checking" => Some("publish_boot"),
825 "Published" => Some("swap_override"),
828 _ => None,
829 }
830}
831
832fn step(entry: &T, i: usize, watched: Option<&str>) {
833 let cfg = config();
834 let all = steps(entry);
835 let st = all.get(i).unwrap_or_else(|| die("no such step"));
836 let p: Plan = read_json(&PathBuf::from(RUN).join("plan.json")).unwrap_or_else(|| plan(entry));
837 if let Some(after) = watched.and_then(|w| w.strip_prefix("after:")) {
838 let d = dur(&T::String(after.into())).unwrap_or(Millis(0));
839 std::thread::sleep(Duration::from_millis(d.0));
840 } else if let Some(prefix) = watched.and_then(|w| w.strip_prefix("unit:")) {
841 let until = Instant::now() + Duration::from_secs(3600);
842 while sh_out(&format!("systemctl list-units --state=activating,active --no-legend --plain '{prefix}*'"))
843 .trim()
844 .is_empty()
845 && Instant::now() < until
846 {
847 std::thread::sleep(Duration::from_millis(200));
848 }
849 } else if let Some(state) = watched.filter(|w| pause_call(w).is_some()) {
850 let marker = PathBuf::from(HOOKS_DIR).join(format!("paused-{state}"));
852 let until = Instant::now() + Duration::from_secs(3600);
853 while !marker.exists() && Instant::now() < until {
854 std::thread::sleep(Duration::from_millis(100));
855 }
856 let actor = st.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
857 let r = run_step(&cfg, st, &actor);
858 eprintln!("gym: step {i} at the {state} pause: {r:?}");
859 let _ = std::fs::remove_file(&marker);
860 if let Err(e) = r {
861 die(&format!("step {i}: {e}"));
862 }
863 return;
864 } else if let Some(state) = watched {
865 let until = Instant::now() + Duration::from_secs(3600);
866 while !journal_reached(&cfg, state) && Instant::now() < until {
867 std::thread::sleep(Duration::from_millis(100));
868 }
869 } else if p.steps.get(i).map(String::as_str) == Some("armed") {
870 return;
871 }
872 if let Some(point) = p.crash_before.get(&i) {
873 sh(&format!("mkdir -p {HOOKS_DIR} && printf '%s' '{point}' > {HOOKS_DIR}/crash"));
874 }
875 let actor = st.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
876 let r = run_step(&cfg, st, &actor);
877 if let Err(e) = r {
878 die(&format!("step {i}: {e}"));
879 }
880}
881
882fn hooks_set(key: &str, value: serde_json::Value) {
883 let path = format!("{HOOKS_DIR}/hooks.json");
884 let mut v: serde_json::Value =
885 std::fs::read(&path).ok().and_then(|b| serde_json::from_slice(&b).ok()).unwrap_or(serde_json::json!({}));
886 v[key] = value;
887 let _ = std::fs::create_dir_all(HOOKS_DIR);
888 let _ = std::fs::write(&path, serde_json::to_vec(&v).expect("serializes"));
889}
890
891fn run_step(cfg: &FileConfig, st: &T, actor: &str) -> Result<(), String> {
892 let get = |k: &str| s(st, k).to_string();
893 if let Some(t) = st.get("transition").and_then(T::as_str) {
894 let req = match t {
895 "ApplyChange" => Request::Apply {
896 change: ChangeId(get("change")),
897 args: st
898 .get("args")
899 .and_then(T::as_table)
900 .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
901 .unwrap_or_default(),
902 },
903 "RestartUnit" if st.get("expected").is_some() || st.get("observed").is_some() => Request::GymPropose {
904 transition: Transition::RestartUnit(unit(&get("unit"))),
905 stale_digest: st.get("observed").is_some(),
906 may_change: st
907 .get("expected")
908 .and_then(|e| e.get("may_change"))
909 .and_then(T::as_array)
910 .map(|m| m.iter().filter_map(T::as_str).map(unit).collect()),
911 },
912 "RestartUnit" => Request::Restart { unit: unit(&get("unit")) },
913 "CollectGarbage" => Request::Gc,
914 "SetOverride" => Request::SetOverride {
915 unit: unit(&get("unit")),
916 until: Until::Explicit,
917 desired: planarian_schema::OverrideDesired::LeaveAlone,
918 },
919 "Rollback" => {
920 Request::GymPropose { transition: Transition::Rollback, stale_digest: false, may_change: None }
921 }
922 "SwitchGeneration" => {
923 let g = Backends::generations(&real(cfg)).boot_entries().unwrap_or_default().into_iter().max();
924 let g = g.ok_or("no generation to switch to")?;
925 Request::GymPropose {
926 transition: Transition::SwitchGeneration(g),
927 stale_digest: false,
928 may_change: None,
929 }
930 }
931 "BindDriver" => Request::GymPropose {
932 transition: Transition::BindDriver {
933 device: unit(&get("device")),
934 driver: DriverRef { name: get("driver"), module: None },
935 },
936 stale_digest: false,
937 may_change: None,
938 },
939 other => return Err(format!("transition {other}")),
940 };
941 return request_as(actor, &req);
942 }
943 match get("raw").as_str() {
944 "kill_unit" => {
945 let (ctl, name) = unit_cmd(&unit(&get("unit")));
946 let sig = st.get("signal").and_then(T::as_str).unwrap_or("SIGKILL");
947 sh(&format!("{ctl} kill --signal={sig} {name}"));
948 }
949 "oom" => {
950 let (ctl, name) = unit_cmd(&unit(&get("unit")));
955 let cg = sh_out(&format!("{ctl} show -p ControlGroup --value {name}")).trim().to_string();
956 if cg.is_empty() {
957 return Err(format!("oom: {name} has no cgroup"));
958 }
959 sh("sysctl -w vm.panic_on_oom=0");
962 sh(&format!("echo 4096 > /sys/fs/cgroup{cg}/memory.max"));
963 if !sh(&format!(
964 "for _ in $(seq 100); do [ \"$({ctl} show -p Result --value {name})\" = oom-kill ] && exit 0; sleep 0.1; done; exit 1"
965 )) {
966 return Err(format!(
967 "oom: {name} was not OOM-killed: {}",
968 sh_out(&format!("{ctl} show -p Result,ActiveState {name}")).trim()
969 ));
970 }
971 }
972 "stop_unit_out_of_band" => {
973 let (ctl, name) = unit_cmd(&unit(&get("unit")));
974 sh(&format!("{ctl} stop {name}"));
975 }
976 "set_knob" | "set_persistent_knob" if get("knob") == "probe_delay" => {
977 for port in probe_ports(cfg, &unit(&get("unit"))) {
981 sh(&format!("iptables -I INPUT -p tcp --dport {port} -j DROP"));
982 }
983 }
984 "set_knob" | "set_persistent_knob" => {
985 let u = unit(&get("unit"));
986 let v = match st.get("value") {
987 Some(T::String(x)) => x.clone(),
988 Some(other) => other.to_string(),
989 None => String::new(),
990 };
991 let n = match u.scope {
993 Scope::User { uid, .. } => format!("{}@{uid}", u.name),
994 _ => u.name.clone(),
995 };
996 let once = if get("raw") == "set_knob" {
998 format!(" && touch /var/lib/gym-knobs/{n}/{}.once", get("knob"))
999 } else {
1000 String::new()
1001 };
1002 sh(&format!(
1003 "mkdir -p /var/lib/gym-knobs/{n} && chmod 755 /var/lib/gym-knobs /var/lib/gym-knobs/{n} && printf '%s' '{v}' > /var/lib/gym-knobs/{n}/{k}{once}",
1004 k = get("knob")
1005 ));
1006 }
1007 "corrupt_state" => {
1008 sh(&format!("printf 'corrupt{{' > {}", get("path")));
1009 }
1010 "unbind_driver" => {
1011 let dev = unit(&get("device"));
1012 let sysfs = cfg.devices.get(&dev.name).ok_or(format!("no sysfs path for {dev}"))?;
1013 sh(&format!(
1018 "d=$(readlink -f {s}); drv=$(readlink -f $d/driver); \
1019 if [ -d $d/drm ]; then for v in /sys/class/vtconsole/vtcon*; do \
1020 grep -q 'frame buffer' $v/name && echo 0 > $v/bind; done; fi; \
1021 echo $(basename $d) > $drv/unbind",
1022 s = sysfs.display()
1023 ));
1024 }
1025 "block_network" => {
1026 let port = st.get("port").and_then(T::as_integer);
1027 let rule = match port {
1028 Some(p) => format!("-d {} -p tcp --dport {p}", get("dst")),
1029 None => format!("-d {}", get("dst")),
1030 };
1031 let ok = if get("dst").starts_with("127.") {
1034 let tcp = if port.is_some() { rule.clone() } else { format!("{rule} -p tcp") };
1035 sh(&format!("iptables -I OUTPUT {tcp} -j REJECT --reject-with tcp-reset"))
1036 } else {
1037 sh(&format!(
1038 "iptables -I OUTPUT {rule} -j DROP && iptables -I INPUT {} -j DROP",
1039 rule.replace("-d ", "-s ").replace("--dport", "--sport")
1040 ))
1041 };
1042 if !ok {
1043 return Err(format!("could not block {rule}"));
1044 }
1045 }
1046 "unblock_network" => {
1047 sh("iptables -F OUTPUT; iptables -F INPUT; true");
1048 }
1049 "kill_process" if get("process") == "planariad" && get("repeat") == "past-budget" => {
1051 let sig = if get("signal").is_empty() { "SIGSEGV".to_string() } else { get("signal") };
1055 let until = Instant::now() + Duration::from_secs(300);
1056 while Instant::now() < until
1057 && sh_out("systemctl show -p Result --value planariad.service").trim() != "start-limit-hit"
1058 {
1059 if sh("systemctl is-active --quiet planariad.service") {
1060 sh(&format!("systemctl kill --signal={sig} planariad.service || true"));
1061 }
1062 std::thread::sleep(Duration::from_millis(500));
1063 }
1064 }
1065 "kill_process" => match get("process").as_str() {
1066 "planariad" => {
1067 sh("systemctl kill --signal=SIGKILL planariad");
1068 }
1069 "planarian-recovery" => {
1070 sh("systemctl kill --signal=SIGKILL planarian-recovery");
1071 }
1072 p => return Err(format!("kill_process {p}")),
1073 },
1074 "hold_down" => {
1075 sh(&format!(
1076 "systemctl stop {}",
1077 if get("process") == "planarian-recovery" { "planarian-recovery" } else { "planariad" }
1078 ));
1079 }
1080 "release" if get("process") == "gym-agent" => {
1081 sh("systemctl start gym-agent.service");
1082 }
1083 "mask_unit" | "unmask_unit" => {
1085 let u = unit(&get("unit"));
1086 let file = format!("{}.service", u.name);
1087 let list = "/var/lib/gym-posture/masked";
1088 let mut v: Vec<String> =
1089 std::fs::read_to_string(list).unwrap_or_default().lines().map(String::from).collect();
1090 v.retain(|l| l != &file);
1091 if get("raw") == "mask_unit" {
1092 v.push(file.clone());
1093 }
1094 let _ = std::fs::create_dir_all("/var/lib/gym-posture");
1095 std::fs::write(list, v.join("\n") + "\n").map_err(|e| e.to_string())?;
1096 let verb = if get("raw") == "mask_unit" { "mask" } else { "unmask" };
1097 sh(&format!("systemctl {verb} --runtime {file}; systemctl daemon-reload"));
1098 }
1099 "ack_alarm" => {
1102 record_posture();
1105 let user = user_for(actor);
1106 let ok = if user == "root" {
1107 sh("planarian-ack")
1108 } else {
1109 sh(&format!("runuser -u {user} -- sudo -n planarian-ack"))
1110 };
1111 if !ok {
1112 let line = serde_json::json!({ "actor": actor, "ok": false }).to_string();
1113 let _ = std::fs::OpenOptions::new()
1114 .create(true)
1115 .append(true)
1116 .open(PathBuf::from(RUN).join("acks.jsonl"))
1117 .and_then(|mut f| writeln!(f, "{line}"));
1118 }
1119 }
1120 "corrupt_core_hash" => match st.get("file").and_then(T::as_str) {
1124 Some(f) => {
1125 let ok = sh(&format!(
1126 "mkdir -p /var/lib/gym-posture && grep -q '/{f}$' /etc/planarian/core.sha256 && \
1127 sed -E 's|^[0-9a-f]{{64}} (.*/{f})$|{zero} \\1|' /etc/planarian/core.sha256 > /var/lib/gym-posture/core.sha256",
1128 zero = "0".repeat(64)
1129 ));
1130 if !ok {
1131 return Err(format!("corrupt_core_hash: no {f} in the record"));
1132 }
1133 }
1134 None => {
1135 sh("mkdir -p /var/lib/gym-posture && printf '%064d\\n' 0 > /var/lib/gym-posture/core.sha256");
1136 }
1137 },
1138 "break_journal_chain" => {
1140 let off = get("when") == "powered-off";
1141 if off {
1142 sh("systemctl stop planariad planarian-recovery");
1143 }
1144 let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
1145 let store = FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes)
1146 .map_err(|e| format!("journal: {e}"))?;
1147 let n = segment(cfg).len();
1148 if n < 3 {
1149 return Err("break_journal_chain: fewer than 3 records".into());
1150 }
1151 let edited = planarian_txn::journal::tamper(&store, n / 2, |p| {
1152 if let Some(b) = p.iter_mut().find(|b| b.is_ascii_digit()) {
1153 *b = if *b == b'9' { b'1' } else { *b + 1 };
1154 }
1155 });
1156 if !matches!(edited, Ok(true)) {
1157 return Err(format!("break_journal_chain: {edited:?}"));
1158 }
1159 }
1160 "plant_early_unit" => {
1164 let name = unit(&get("unit")).name;
1165 let dir = "/etc/systemd/system.control";
1166 sh(&format!(
1167 "mkdir -p {dir}/sysinit.target.wants && \
1168 printf '[Unit]\\nDefaultDependencies=no\\nBefore=sysinit.target planariad.service\\n[Service]\\nExecStart=/run/current-system/sw/bin/sleep infinity\\n' > {dir}/{name}.service && \
1169 ln -sf ../{name}.service {dir}/sysinit.target.wants/{name}.service && systemctl daemon-reload"
1170 ));
1171 }
1172 "release" | "restart_process" => {
1173 sh("rm -f /run/systemd/system/planariad.service.d/gym-hold.conf; systemctl daemon-reload");
1174 let name = if get("process") == "planarian-recovery" { "planarian-recovery" } else { "planariad" };
1175 sh(&format!("systemctl reset-failed {name}; systemctl restart {name}"));
1177 }
1178 "wait" => match st.get("for") {
1179 Some(T::Table(t)) if t.contains_key("journal") => {
1180 let state = t["journal"].as_str().unwrap_or("");
1181 let until = Instant::now() + Duration::from_secs(1800);
1182 while !journal_reached(cfg, state) && Instant::now() < until {
1183 std::thread::sleep(Duration::from_secs(1));
1184 }
1185 }
1186 Some(v) => {
1187 let d = dur(v).ok_or(format!("wait {v}"))?;
1188 std::thread::sleep(Duration::from_millis(d.0));
1189 }
1190 None => {}
1191 },
1192 "ask" => request_as(actor, &Request::Ask { phrase: get("phrase"), choice: None })?,
1193 "cli" => {
1194 let argv: Vec<String> = st
1195 .get("argv")
1196 .and_then(T::as_array)
1197 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1198 .unwrap_or_default();
1199 let a: Vec<&str> = argv.iter().map(String::as_str).collect();
1200 let req = match a.as_slice() {
1201 ["override", u] => Request::SetOverride {
1202 unit: unit(u),
1203 until: Until::Explicit,
1204 desired: planarian_schema::OverrideDesired::LeaveAlone,
1205 },
1206 ["override", u, "--stop" | "--stopped"] => Request::SetOverride {
1207 unit: unit(u),
1208 until: Until::Explicit,
1209 desired: planarian_schema::OverrideDesired::Stopped,
1210 },
1211 ["stop", u] => Request::Stop { unit: unit(u) },
1212 ["restart", u] => Request::Restart { unit: unit(u) },
1213 ["ack"] => Request::Acknowledge { unit: None },
1214 ["ack", u] => Request::Acknowledge { unit: Some(unit(u)) },
1215 ["release", u] => Request::LiftOverride { unit: unit(u) },
1216 ["resolve", t, how] => {
1217 let txn = match (ledger(cfg).0.interlock.keys().next(), *t) {
1220 (Some(x), _) => *x,
1221 (None, "any") => TxnId(u64::MAX),
1222 (None, _) => return Err("resolve: nothing interlocked".into()),
1223 };
1224 let resolution = match *how {
1225 "--as-committed" => Resolution::AsCommitted,
1226 "--as-reverted" => Resolution::AsReverted,
1227 _ => Resolution::ManualRepaired,
1228 };
1229 Request::Resolve { txn, resolution }
1230 }
1231 ["diagnose", u, "--probe", p] => {
1232 Request::Diagnose { unit: unit(u), probe: Some(ProbeId(p.to_string())) }
1233 }
1234 ["internal", "present-token", _] => {
1237 let held = std::fs::read_to_string(Path::new(RUN).join("held-tokens")).unwrap_or_default();
1238 for h in held.lines().filter_map(|l| Hash::from_hex(l.trim())) {
1239 let _ = request_at(&get("actor"), &Request::Approve { hash: h }, true);
1240 }
1241 return Ok(());
1242 }
1243 ["internal", "snapshot", "journal-volume"] => {
1247 if !sh("btrfs subvolume snapshot -r /var/lib/planarian /var/lib/planarian/.gym-snapshot") {
1248 return Err("could not snapshot the journal's volume".into());
1249 }
1250 return Ok(());
1251 }
1252 ["internal", "journal-churn", "--wraps", n] => {
1257 let n: u32 = n.parse().map_err(|_| format!("journal-churn: {n}"))?;
1258 let u = unit("service:gym-cache");
1259 for _ in 0..n {
1260 request_as(
1261 "root",
1262 &Request::SetOverride {
1263 unit: u.clone(),
1264 until: Until::Explicit,
1265 desired: planarian_schema::OverrideDesired::LeaveAlone,
1266 },
1267 )?;
1268 request_as("root", &Request::LiftOverride { unit: u.clone() })?;
1269 compact_and_save()?;
1270 }
1271 return Ok(());
1272 }
1273 ["internal", "wifi-connect", ssid] => {
1274 if !sh(&format!("nmcli connection up id {ssid}")) {
1275 return Err(format!("nmcli could not connect to {ssid}"));
1276 }
1277 return Ok(());
1278 }
1279 other => return Err(format!("cli {other:?}")),
1280 };
1281 request_as(actor, &req)?;
1282 }
1283 "approve_pending" => {
1284 let actor = st.get("actor").and_then(T::as_str).unwrap_or("root");
1286 let tty = st.get("via").and_then(T::as_str) != Some("no-tty");
1287 let r = console().ok_or("daemon down")?;
1288 if st.get("hold_token").and_then(T::as_bool) == Some(true) {
1289 let held: String = r.status.pending.iter().map(|p| format!("{}\n", p.approve.to_hex())).collect();
1290 std::fs::write(Path::new(RUN).join("held-tokens"), held).map_err(|e| e.to_string())?;
1291 return Ok(());
1292 }
1293 for p in r.status.pending {
1294 if let Err(e) = request_at(actor, &Request::Approve { hash: p.approve }, tty) {
1298 eprintln!("approve_pending: {e}");
1299 }
1300 }
1301 }
1302 "forge_proposal_fields" => {
1305 let r = st.get("request").and_then(T::as_table).ok_or("request")?;
1306 let args: BTreeMap<String, Value> = r
1307 .get("args")
1308 .and_then(T::as_table)
1309 .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1310 .unwrap_or_default();
1311 let change = ChangeId(r.get("change").and_then(T::as_str).unwrap_or("").into());
1312 let mut line = serde_json::to_value(Request::Apply { change, args }).expect("serializes");
1313 let forged = serde_json::to_value(st.get("fields").cloned().unwrap_or(T::Table(Default::default())))
1314 .map_err(|e| e.to_string())?;
1315 if let (Some(o), Some(f)) = (line.as_object_mut(), forged.as_object()) {
1316 o.extend(f.clone());
1317 }
1318 let line = line.to_string().replace('\'', "'\\''");
1319 let user = user_for(&get("actor"));
1320 sh(&format!(
1321 "runuser -u {user} -- python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(sys.argv[2].encode()+b\"\\n\"); s.recv(65536)' {sock} '{line}'",
1322 sock = planarian_ipc::SOCKET
1323 ));
1324 }
1325 "delete_journal_record" => {
1329 sh("systemctl stop planariad");
1330 let paths = Paths { state: cfg.state_dir.clone(), ..Default::default() };
1331 let store = FileStore::open(&paths.journal_dir(), &paths.txn_dir(), cfg.journal_bytes)
1332 .map_err(|e| format!("journal: {e}"))?;
1333 let n = st.get("record").and_then(T::as_integer).unwrap_or(1) as usize;
1334 if !matches!(planarian_txn::journal::delete(&store, n), Ok(true)) {
1335 return Err(format!("delete_journal_record: no record {n}"));
1336 }
1337 }
1338 "request_then_exit" => {
1342 let req: Request = serde_json::from_value(
1343 serde_json::to_value(st.get("request").ok_or("request")?).map_err(|e| e.to_string())?,
1344 )
1345 .map_err(|e| format!("request_then_exit: {e}"))?;
1346 let line = serde_json::to_string(&req).expect("serializes").replace('\'', "'\\''");
1347 let user = user_for(&get("actor"));
1348 let ok = sh(&format!(
1349 "pid=$(systemctl show -p MainPID --value planariad); kill -STOP $pid; \
1350 runuser -u {user} -- python3 -c 'import socket,sys; s=socket.socket(socket.AF_UNIX); s.connect(sys.argv[1]); s.sendall(sys.argv[2].encode()+b\"\\n\")' {sock} '{line}'; \
1351 r=$?; kill -CONT $pid; exit $r",
1352 sock = planarian_ipc::SOCKET
1353 ));
1354 if !ok {
1355 return Err("request_then_exit: the client could not send".into());
1356 }
1357 }
1358 "plant_test_build" => {
1362 let ok = sh("mkdir -p /run/planarian-gym /run/systemd/system/planariad.service.d && \
1363 jq '.allowTestBuild = false' /etc/planarian/config.json > /run/planarian-gym/config.json && \
1364 printf '[Service]\\nEnvironment=PLANARIAN_CONFIG=/run/planarian-gym/config.json\\n' \
1365 > /run/systemd/system/planariad.service.d/test-build.conf && \
1366 systemctl daemon-reload && systemctl restart planariad; true");
1367 if !ok {
1368 return Err("plant_test_build".into());
1369 }
1370 }
1371 "agent_run" => {
1372 let argv: Vec<String> = st
1373 .get("argv")
1374 .and_then(T::as_array)
1375 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1376 .unwrap_or_default();
1377 let by = st.get("as").and_then(T::as_str).unwrap_or("user:1001");
1378 request_as(by, &Request::AgentRun { name: get("name"), argv })?;
1379 }
1380 "greeter_login" => {
1381 let uid = st.get("uid").and_then(T::as_integer).unwrap_or(1000);
1382 let user = user_for(&format!("user:{uid}"));
1383 let tty = 2 + (uid - 1000).clamp(0, 5);
1384 sh(&format!(
1385 "systemd-run --unit=gym-session-{uid} -p PAMName=login -p User={user} -p TTYPath=/dev/tty{tty} \
1386 -p StandardInput=tty -p StandardOutput=tty sleep infinity; sleep 5"
1387 ));
1388 }
1389 "logout" => {
1390 let uid = st.get("uid").and_then(T::as_integer).unwrap_or(1000);
1391 sh(&format!(
1392 "systemctl stop gym-session-{uid}; loginctl terminate-user {}; true",
1393 user_for(&format!("user:{uid}"))
1394 ));
1395 }
1396 "fill_fs" => {
1397 let path = match get("fs").as_str() {
1398 "device:fs-nix" => "/nix/.rw-store",
1399 "device:fs-boot" => "/boot",
1400 "device:fs-journal" => "/var/lib/planarian",
1401 _ => "/",
1402 };
1403 let leave = if get("until") == "reserved-boundary" { 1 << 20 } else { 0 };
1405 sh(&format!(
1406 "avail=$(df --output=avail -B1 {path} | tail -1); n=$((avail - {leave})); \
1407 [ $n -gt 0 ] && fallocate -l $n {path}/.gym-fill || dd if=/dev/zero of={path}/.gym-fill bs=1M 2>/dev/null; true"
1408 ));
1409 }
1410 "edit_override_file" => {
1415 let dir = cfg.owner.join("planarian");
1416 let mut set = OverrideSet::default();
1417 set.assignments.insert("gym.thirdParty".into(), Value::Bool(true));
1418 let staged = PathBuf::from(RUN).join("third-party.nix");
1419 std::fs::write(&staged, planarian_changes::render(&set)).map_err(|e| e.to_string())?;
1420 let held = PathBuf::from(RUN).join("override-lock-held");
1421 let _ = std::fs::remove_file(&held);
1422 let d = dir.display();
1423 let mut writer = Command::new("flock")
1424 .arg(dir.as_os_str())
1425 .args(["sh", "-c"])
1426 .arg(format!(
1427 "touch {h} && sleep 3 && cp {staged} {d}/.gym-third-party && mv {d}/.gym-third-party {d}/overrides.nix",
1428 h = held.display(),
1429 staged = staged.display()
1430 ))
1431 .spawn()
1432 .map_err(|e| format!("flock: {e}"))?;
1433 let until = Instant::now() + Duration::from_secs(30);
1434 while !held.exists() && Instant::now() < until {
1435 std::thread::sleep(Duration::from_millis(50));
1436 }
1437 if !held.exists() {
1438 let _ = writer.kill();
1439 return Err("the cooperating writer never took the lock".into());
1440 }
1441 sh(&format!("rm -f {HOOKS_DIR}/paused-*"));
1443 let ok = writer.wait().map(|s| s.success()).unwrap_or(false);
1444 if !ok {
1445 return Err("the cooperating writer failed".into());
1446 }
1447 if let Ok((_, h)) = Backends::generations(&real(cfg)).read_overrides() {
1448 let _ = std::fs::write(PathBuf::from(RUN).join("third-party-override"), h.to_hex());
1449 }
1450 eprintln!("gym: a cooperating writer replaced {d}/overrides.nix under the lock");
1451 }
1452 "edit_owner_source" => {
1453 let root = cfg.owner.display().to_string();
1454 let path = get("path");
1455 if !sh(&format!("echo '# edited by the gym' >> {root}/{path}")) {
1456 return Err(format!("could not edit {root}/{path}"));
1457 }
1458 if st.get("tracked").and_then(T::as_bool) != Some(false) {
1459 sh(&format!("cd {root} && git add {path} 2>/dev/null; true"));
1460 }
1461 eprintln!("gym: edited {root}/{path}: {}", sh_out(&format!("git -C {root} status --short")).trim());
1462 }
1463 "out_of_band_rebuild" => {
1464 let edit = get("edit");
1466 let name = if edit.contains("12 throwaway") {
1467 "throwaway"
1468 } else if edit.contains("sshd-tcp.timeout") {
1469 "weak-sshd-probe"
1470 } else if edit.contains("ExecStart=/bin/false") {
1471 "gym-web-false"
1472 } else if edit == "enable gym-bad-payload" {
1473 "bad-payload"
1474 } else if edit.starts_with("update input gym-modules") {
1475 "input-update"
1476 } else {
1477 return Err(format!("out_of_band_rebuild {edit}"));
1478 };
1479 let activate =
1481 st.get("activate").and_then(T::as_bool).unwrap_or(matches!(name, "bad-payload" | "input-update"));
1482 let n = if name == "throwaway" { 12 } else { 1 };
1483 for k in 0..n {
1484 let spec = if name == "throwaway" { format!("throwaway{}", k % 2) } else { name.to_string() };
1485 sh(&format!(
1486 "p=$(readlink -f /run/booted-system)/specialisation/{spec}; \
1487 nix-env -p /nix/var/nix/profiles/system --set $(readlink -f $p)"
1488 ));
1489 }
1490 if activate {
1491 sh(&format!(
1492 "$(readlink -f /run/booted-system)/specialisation/{name}/bin/switch-to-configuration test"
1493 ));
1494 }
1495 }
1496 "force_compaction" => compact_and_save()?,
1497 "advance_clock" => {
1498 let d = st.get("by").and_then(dur).ok_or("advance_clock")?;
1499 sh(&format!("timedatectl set-ntp false 2>/dev/null; date -s \"+{} seconds\"", d.0 / 1000));
1500 }
1501 "slow_backend" => {
1502 let d = st.get("delay").and_then(dur).unwrap_or(Millis::secs(120));
1503 let uncancellable = st.get("uncancellable").and_then(T::as_bool).unwrap_or(false);
1504 hooks_set(&get("call"), serde_json::json!({ "delay_secs": d.0 / 1000, "uncancellable": uncancellable }));
1505 }
1506 "withhold_reply" => {
1507 hooks_set(&get("call"), serde_json::json!({ "withhold": true }));
1508 }
1509 "backend_result" => {
1510 let key = match st.get("generation").and_then(T::as_str) {
1511 Some("revert_to") => {
1512 let start: Start = read_json(&PathBuf::from(RUN).join("start.json")).unwrap_or_default();
1513 format!("{}@{}", get("call"), start.base.0)
1514 }
1515 _ => get("call"),
1516 };
1517 hooks_set(&key, serde_json::json!({ "result": get("result") }));
1518 }
1519 "set_setting" => {
1520 let (path, value): (Vec<&str>, _) = match get("key").as_str() {
1523 "gc.generation_budget_percent" => {
1524 let pct = st.get("value").and_then(T::as_float).ok_or("set_setting value")?;
1525 (vec!["gc", "generationsPermille"], serde_json::json!((pct * 10.0).round() as u64))
1526 }
1527 "posture" => (vec!["posture"], serde_json::json!(get("value"))),
1528 "userControllableChanges" => {
1530 let add: Vec<&str> =
1531 st.get("value").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str).collect();
1532 let mut have: Vec<String> = config().user_controllable_changes.into_iter().collect();
1533 have.extend(add.iter().map(|s| s.to_string()));
1534 (vec!["userControllableChanges"], serde_json::json!(have))
1535 }
1536 k => return Err(format!("set_setting {k}")),
1537 };
1538 let src = std::env::var("PLANARIAN_CONFIG").unwrap_or_else(|_| CONFIG.into());
1539 let mut v: serde_json::Value = std::fs::read(&src)
1540 .ok()
1541 .and_then(|b| serde_json::from_slice(&b).ok())
1542 .ok_or_else(|| format!("{src}: unreadable"))?;
1543 match path.as_slice() {
1544 [a] => v[*a] = value,
1545 [a, b] => v[*a][*b] = value,
1546 _ => unreachable!("one or two keys"),
1547 }
1548 let copy = format!("{HOOKS_DIR}/config.json");
1549 let _ = std::fs::create_dir_all(HOOKS_DIR);
1550 std::fs::write(©, v.to_string()).map_err(|e| e.to_string())?;
1551 let ok = sh(&format!(
1552 "mkdir -p /run/systemd/system/planariad.service.d && \
1553 printf '[Service]\\nEnvironment=PLANARIAN_CONFIG={copy}\\n' > /run/systemd/system/planariad.service.d/gym-config.conf && \
1554 systemctl daemon-reload && systemctl restart planariad"
1555 ));
1556 let until = Instant::now() + Duration::from_secs(60);
1557 while console().is_none() && Instant::now() < until {
1558 std::thread::sleep(Duration::from_millis(500));
1559 }
1560 if !ok || console().is_none() {
1561 return Err("planariad did not come back on the changed config".into());
1562 }
1563 }
1564 other => return Err(format!("raw {other}")),
1565 }
1566 Ok(())
1567}
1568
1569fn evidence() -> Evidence {
1571 let cfg = config();
1572 let dir = PathBuf::from(RUN);
1573 let start: Start = read_json(&dir.join("start.json")).unwrap_or_default();
1574 let (ledger, journal_replays) = ledger(&cfg);
1575 let log: Vec<LogRecord> = std::fs::read_to_string(cfg.state_dir.join("log/executor.jsonl"))
1576 .unwrap_or_default()
1577 .lines()
1578 .skip(start.log_lines)
1579 .filter_map(|l| serde_json::from_str(l).ok())
1580 .collect();
1581 let samples: Vec<Sample> = std::fs::read_to_string(dir.join("samples.jsonl"))
1582 .unwrap_or_default()
1583 .lines()
1584 .filter_map(|l| serde_json::from_str(l).ok())
1585 .filter(|smp: &Sample| smp.at >= start.at)
1586 .collect();
1587 let responses: Vec<Response> = std::fs::read_to_string(dir.join("responses.jsonl"))
1588 .unwrap_or_default()
1589 .lines()
1590 .filter_map(|l| serde_json::from_str(l).ok())
1591 .collect();
1592 let notices: Vec<String> = sh_out(&format!("journalctl -u planariad -o cat --since @{} --no-pager", start.unix))
1593 .lines()
1594 .filter_map(|l| l.strip_prefix("planariad: ").map(String::from))
1595 .chain(samples.iter().flat_map(|smp| smp.notices.clone()))
1596 .collect();
1597 let mut seen_status: BTreeMap<UnitId, BTreeSet<String>> = BTreeMap::new();
1598 let mut seen_incidents: BTreeMap<IncidentId, Incident> = BTreeMap::new();
1599 let mut first_incident_at: BTreeMap<UnitId, Timestamp> = BTreeMap::new();
1600 for smp in &samples {
1601 for (u, st) in &smp.status {
1602 seen_status.entry(u.clone()).or_default().insert(st.clone());
1603 }
1604 for i in &smp.incidents {
1605 first_incident_at.entry(i.target.clone()).or_insert(smp.at);
1606 seen_incidents.insert(i.id, i.clone());
1607 }
1608 }
1609 let final_report = console();
1610 let finals = final_report
1611 .as_ref()
1612 .map(statuses)
1613 .unwrap_or_else(|| samples.last().map(|smp| smp.status.clone()).unwrap_or_default());
1614 let open_incidents = final_report
1615 .as_ref()
1616 .map(|r| r.status.incidents.iter().map(|i| (i.target.clone(), i.clone())).collect())
1617 .unwrap_or_default();
1618 let mut restarts: BTreeMap<UnitId, i64> = BTreeMap::new();
1619 for r in &log {
1620 if let (Transition::RestartUnit(u), Submitted::Finished { .. }) = (&r.proposal.transition, &r.result) {
1621 *restarts.entry(u.clone()).or_default() += 1;
1622 }
1623 }
1624 let saved: Option<Vec<Entry>> = read_json(&dir.join(SAVED));
1627 let mut entries = saved.clone().unwrap_or_default();
1628 for x in segment(&cfg) {
1629 if entries.last().is_none_or(|l| l.seq < x.seq) {
1630 entries.push(x);
1631 }
1632 }
1633 let full_replay = saved.map(|_| {
1634 let mut l = Ledger::default();
1635 for x in &entries {
1636 let _ = l.apply(x.clone());
1637 }
1638 l
1639 });
1640 let mut timeline: BTreeMap<UnitId, Vec<(Timestamp, String)>> = BTreeMap::new();
1641 for smp in &samples {
1642 for (u, st) in &smp.status {
1643 let line = timeline.entry(u.clone()).or_default();
1644 if line.last().is_none_or(|(_, n)| n != st) {
1645 line.push((smp.at, st.clone()));
1646 }
1647 }
1648 }
1649 let mut options: BTreeMap<String, Value> = BTreeMap::new();
1652 for r in &log {
1653 if let (Submitted::Finished { state: TxnState::Committed, .. }, Some(f)) = (&r.result, &r.facts)
1654 && let Some(d) = &f.config_diff
1655 {
1656 for (k, (_, new)) in d.options.iter() {
1657 match new {
1658 Some(v) => options.insert(k.clone(), v.clone()),
1659 None => options.remove(k),
1660 };
1661 }
1662 }
1663 }
1664 let backend = real(&cfg);
1665 let drivers: BTreeMap<UnitId, String> = start
1666 .pre_status
1667 .keys()
1668 .filter(|u| u.kind == UnitKind::Device)
1669 .filter_map(|u| Backends::inventory(&backend).bound_driver(u).ok().flatten().map(|d| (u.clone(), d.name)))
1670 .collect();
1671 let g = Backends::generations(&backend);
1672 let boot_entries = g.boot_entries().unwrap_or_default();
1673 let collected = start.boot_entries.iter().copied().filter(|x| !boot_entries.contains(x)).collect();
1674 let candidate = ledger
1675 .live
1676 .values()
1677 .filter_map(|t| t.txn.candidate.as_ref().map(|(c, _)| *c))
1678 .max()
1679 .or_else(|| boot_entries.iter().copied().filter(|x| *x > start.base).max());
1680 Evidence {
1681 current: g.current().unwrap_or_default(),
1682 boot_default: g.boot_default().unwrap_or_default(),
1683 base: start.base,
1684 candidate,
1685 boot_entries,
1686 collected,
1687 work: None,
1688 overrides_empty: g.read_overrides().map(|(o, _)| o.assignments.is_empty()).unwrap_or(true),
1689 override_hash: g.read_overrides().ok().map(|(_, h)| h),
1690 agreement: None,
1691 third_party_override: std::fs::read_to_string(dir.join("third-party-override"))
1692 .ok()
1693 .and_then(|h| Hash::from_hex(h.trim())),
1694 ledger,
1695 journal_replays,
1696 log,
1697 pre_status: start.pre_status,
1698 finals,
1699 touched: start.touched,
1700 seen_status,
1701 seen_incidents,
1702 open_incidents,
1703 first_incident_at,
1704 start: start.at,
1705 restarts,
1706 notices,
1707 responses,
1708 entries: Some(entries.iter().filter(|x| x.at >= start.at).cloned().collect()),
1709 full_replay,
1710 effects: Some(effects(&entries, start.unix)),
1711 status_timeline: Some(timeline),
1712 foreground: None,
1713 drivers: Some(drivers),
1714 options: Some(options),
1715 probe_defs: Some((start.probe_defs, probe_defs(&cfg))),
1716 gc_roots: Some((start.gc_roots, build_roots())),
1717 posture: posture_seen(&cfg, start.unix),
1718 }
1719}
1720
1721fn needs_full_window(post: &T) -> bool {
1723 post.as_table().is_some_and(|t| {
1724 t.keys().any(|k| {
1725 k.starts_with("no_")
1726 || matches!(k.as_str(), "others_unchanged" | "status_never" | "metrics" | "invariants" | "one_of")
1727 })
1728 })
1729}
1730
1731fn stalls(entry: &T, failures: &mut Vec<String>) {
1736 let timed_out = sh_out(
1737 "journalctl -b --no-pager -o cat -u 'planarian-op-*' 2>/dev/null | grep -F 'start operation timed out' || true",
1738 );
1739 if let Some(line) = timed_out.lines().next() {
1740 failures.push(format!("harness: an activation unit hit its start timeout ({}): a stall", line.trim()));
1741 }
1742 let deliberate = entry
1743 .get("setup")
1744 .and_then(T::as_array)
1745 .into_iter()
1746 .flatten()
1747 .filter(|st| st.get("raw").and_then(T::as_str) == Some("slow_backend"))
1748 .filter_map(|st| st.get("delay").and_then(crate::sim::dur))
1749 .map(|d| d.0 / 1000)
1750 .max()
1751 .unwrap_or(0);
1752 let limit = deliberate + 300;
1753 let stuck = sh_out(&format!(
1754 "now=$(awk '{{printf \"%d\", $1}}' /proc/uptime); \
1755 for u in $(systemctl list-units --plain --no-legend --state=activating 'planarian-op-*' | awk '{{print $1}}'); do \
1756 t=$(systemctl show -P InactiveExitTimestampMonotonic \"$u\"); age=$((now - t / 1000000)); \
1757 [ \"$age\" -gt {limit} ] && echo \"$u activating for $age s\"; \
1758 done; true"
1759 ));
1760 if let Some(line) = stuck.lines().next() {
1761 failures.push(format!("harness: {} (allowed {limit} s): a stall", line.trim()));
1762 }
1763}
1764
1765fn judge(entry: &T) -> i32 {
1768 let dir = PathBuf::from(RUN);
1769 let p: Plan = read_json(&dir.join("plan.json")).unwrap_or_else(|| plan(entry));
1770 let post = entry.get("required_post").cloned().unwrap_or(T::Table(Default::default()));
1771 let unsupported: Vec<String> = p.steps.iter().filter(|s| s.starts_with("unsupported")).cloned().collect();
1772 let full = needs_full_window(&post);
1773 let end = Instant::now() + Duration::from_secs(p.window_secs);
1774 if p.hold {
1775 let cfg = config();
1779 let until = Instant::now() + Duration::from_secs((p.window_secs / 2).min(300));
1780 while !ledger(&cfg).0.live.is_empty() && Instant::now() < until {
1781 std::thread::sleep(Duration::from_secs(2));
1782 }
1783 sh(
1784 "rm -f /run/systemd/system/planariad.service.d/gym-hold.conf; systemctl daemon-reload; systemctl start planariad",
1785 );
1786 let until = Instant::now() + Duration::from_secs(60);
1787 while console().is_none() && Instant::now() < until {
1788 std::thread::sleep(Duration::from_millis(500));
1789 }
1790 }
1791 let min = Instant::now() + Duration::from_secs(60.min(p.window_secs));
1792 let (mut v, mut e);
1793 loop {
1794 e = evidence();
1795 v = check::judge(&e, &post);
1796 let now = Instant::now();
1797 if now >= end || (!full && now >= min && v.failures.is_empty()) {
1798 break;
1799 }
1800 std::thread::sleep(Duration::from_secs(20));
1801 }
1802 stalls(entry, &mut v.failures);
1803 sh("systemctl stop gym-sampler 2>/dev/null; true");
1804 if unsupported.is_empty() && e.seen_status.is_empty() {
1806 v.failures.push("harness: no status samples (gym-sampler)".into());
1807 }
1808 write_json(&dir.join("evidence.json"), &e);
1809 let result = if !unsupported.is_empty() {
1810 "UNSUPPORTED"
1811 } else if !v.failures.is_empty() {
1812 "FAIL"
1813 } else if !v.unchecked.is_empty() {
1814 "PARTIAL"
1815 } else {
1816 "PASS"
1817 };
1818 let notes: Vec<String> = unsupported
1819 .into_iter()
1820 .chain(v.failures.iter().cloned())
1821 .chain(v.unchecked.iter().map(|u| format!("unchecked: {u}")))
1822 .collect();
1823 let id = s(entry, "id");
1824 write_json(&dir.join("verdict.json"), &serde_json::json!({ "id": id, "result": result, "notes": notes }));
1825 println!("RESULT {result} {id}");
1826 for n in ¬es {
1827 println!(" {n}");
1828 }
1829 0
1830}
1831
1832pub fn main(args: &[String], entries: &[T]) -> i32 {
1833 let arg = |k: &str| args.iter().position(|a| a == k).and_then(|i| args.get(i + 1)).cloned();
1834 let sub = args.first().map(String::as_str).unwrap_or("");
1835 let entry = || -> T {
1836 let id = arg("--id").unwrap_or_else(|| die("--id"));
1837 entries.iter().find(|e| s(e, "id") == id).cloned().unwrap_or_else(|| die(&format!("no entry {id}")))
1838 };
1839 match sub {
1840 "plan" => {
1841 println!("{}", serde_json::to_string(&plan(&entry())).expect("serializes"));
1842 0
1843 }
1844 "begin" => {
1845 begin(&entry());
1846 0
1847 }
1848 "step" => {
1849 let i: usize = arg("--index").and_then(|i| i.parse().ok()).unwrap_or_else(|| die("--index"));
1850 step(&entry(), i, arg("--watched").as_deref());
1851 0
1852 }
1853 "request" => {
1854 request(&arg("--json").unwrap_or_else(|| die("--json")));
1855 0
1856 }
1857 "sample" => {
1858 sample();
1859 0
1860 }
1861 "judge" => judge(&entry()),
1862 "evidence" => {
1863 println!("{}", serde_json::to_string_pretty(&evidence()).expect("serializes"));
1864 0
1865 }
1866 other => die(&format!("unknown vm command {other:?}")),
1867 }
1868}