1use planarian_changes::{CatalogConfig, ListenAddress};
5use planarian_daemon::{Core, CoreConfig, Peer};
6use planarian_exec::Config as ExecConfig;
7use planarian_exec::host::ExecHost;
8use planarian_fake::{Fake, Health, MemStore, fixtures, gym_world};
9use planarian_ipc::{Request, Response};
10use planarian_policy::{Settings, Tables};
11use planarian_schema::*;
12use planarian_txn::journal::{Journal, JournalError};
13use planarian_txn::kernel::{CrashPoint, Kernel};
14use planarian_txn::ledger::{Entry, Ledger, Record};
15use planarian_txn::machine::tag;
16use planarian_txn::store::Store as _;
17use std::collections::{BTreeMap, BTreeSet};
18use toml::Value as T;
19
20pub const TICK: Millis = Millis::secs(5);
21pub const DAEMON_PID: u32 = 100;
22pub const WATCHDOG_PID: u32 = 200;
23
24#[derive(Debug)]
25pub struct Unsupported(pub String);
26
27pub fn config() -> CoreConfig {
28 CoreConfig {
29 exec: ExecConfig {
30 settings: Settings::default(),
31 tables: Tables {
32 catalog: [
33 "service-enable",
34 "font-scale",
35 "listen-address",
36 "repair:reenable-predicted",
37 "repair:reapply",
38 "gym:security-fix",
39 "gym:kernel-param",
40 "gym:touch-core",
41 "gym:newlib",
42 "gym:drop-probe",
43 "gym:firewall-step",
44 ]
45 .map(|s| ChangeId(s.into()))
46 .into(),
47 runtime_allowlist: ["font.scale", "wifi.network"].map(String::from).into(),
48 option_sensitivity: vec![
49 ("services.openssh".into(), Sensitivities::of(&[Sensitivity::Security])),
50 ("networking.firewall".into(), Sensitivities::of(&[Sensitivity::Security])),
51 ],
52 model_catalog: Default::default(),
53 },
54 catalog: CatalogConfig {
55 services: [
56 ("sshd".into(), "services.openssh.enable".into()),
57 ("gym-bad-payload".into(), "services.gym-bad-payload.enable".into()),
59 ]
60 .into(),
61 font_scale_option: "planarian.reference.fontScale".into(),
62 font_scales: ["0.75", "1", "1.25", "1.5", "2"].map(String::from).into(),
63 listen_addresses: [(
64 "gym-web".into(),
65 ListenAddress {
66 option: "services.gym-web.listenAddress".into(),
67 permitted: vec!["127.0.0.1".into(), "192.0.2.10".into()],
68 },
69 )]
70 .into(),
71 gym_choices: [
72 (
73 "gym:security-fix".into(),
74 planarian_changes::GymChoice {
75 option: "gym.vulnlib".into(),
76 arg: "version".into(),
77 permitted: vec!["1.1".into(), "1.2".into()],
78 },
79 ),
80 (
81 "gym:newlib".into(),
82 planarian_changes::GymChoice {
83 option: "gym.newlib".into(),
84 arg: "version".into(),
85 permitted: vec!["1.0".into(), "2.0".into()],
86 },
87 ),
88 (
89 "gym:touch-core".into(),
90 planarian_changes::GymChoice {
91 option: "gym.touchCore".into(),
92 arg: "value".into(),
93 permitted: vec!["on".into()],
94 },
95 ),
96 (
97 "gym:drop-probe".into(),
98 planarian_changes::GymChoice {
99 option: "gym.dropProbe".into(),
100 arg: "probe".into(),
101 permitted: vec!["sshd-tcp".into()],
102 },
103 ),
104 (
105 "gym:firewall-step".into(),
106 planarian_changes::GymChoice {
107 option: "networking.firewall.gymStep".into(),
108 arg: "step".into(),
109 permitted: vec!["1".into()],
110 },
111 ),
112 (
113 "gym:kernel-param".into(),
114 planarian_changes::GymChoice {
115 option: "gym.kernelParam".into(),
116 arg: "param".into(),
117 permitted: vec!["virtio_gpu.modeset=0".into()],
118 },
119 ),
120 ]
121 .into(),
122 ..Default::default()
123 },
124 owner: PinnedSource { root: "/etc/nixos".into(), kind: SourceKind::Git },
125 canonical: fixtures::canonical(),
126 },
127 runtime_options: vec![],
128 fresh_boot: true,
129 startup_grace: planarian_daemon::STARTUP_GRACE,
130 posture: planarian_schema::Posture::Standard,
131 posture_decl: Default::default(),
132 agent_limits: Default::default(),
133 defer_recovery: true,
135 }
136}
137
138pub struct Sim {
139 pub fake: Fake,
140 pub core: Option<Core<Fake, MemStore>>,
141 pub posture: planarian_schema::Posture,
143 pub boots: u64,
145 fresh_boot: bool,
146 daemon_pid: u32,
147 held: bool,
149 crash: Option<CrashPoint>,
150 pub start: Timestamp,
151 pub responses: Vec<Response>,
152 pub notices: Vec<String>,
153 pub base: GenerationId,
155 pub candidate: Option<GenerationId>,
156 pub log: Vec<planarian_daemon::LogRecord>,
158 pub seen_status: BTreeMap<UnitId, std::collections::BTreeSet<String>>,
160 pub seen_incidents: BTreeMap<IncidentId, Incident>,
162 pub first_incident_at: BTreeMap<UnitId, Timestamp>,
163 pub pre_status: BTreeMap<UnitId, String>,
165 pub touched: std::collections::BTreeSet<UnitId>,
167 history: Vec<Entry>,
169 compacted: bool,
170 torn_seen: bool,
171 held_tokens: Vec<Hash>,
173 timeline: BTreeMap<UnitId, Vec<(Timestamp, String)>>,
174 foreground_steps: Vec<(Timestamp, String, bool)>,
176 start_probe_defs: String,
177 start_work: (usize, usize),
179 start_roots: std::collections::BTreeSet<String>,
180 pub posture_seen: crate::evidence::PostureSeen,
182}
183
184pub fn peer(actor: &str) -> Peer {
186 let (who, session) = match actor.split_once('@') {
187 Some((w, s)) => (w, Some(s)),
188 None => (actor, None),
189 };
190 if let Some(name) = who.strip_prefix("agent:") {
191 return Peer::agent(61184, name, None);
195 }
196 let p = match who {
197 "root" => Peer::new(0, true),
198 "root:nogroup" => Peer::new(0, false),
201 "user:1001" => Peer::new(1001, true),
202 a => Peer::new(a.strip_prefix("user:").and_then(|u| u.parse().ok()).unwrap_or(1000), false),
203 };
204 match session {
205 Some(s) => p.in_session(s),
206 None => p,
207 }
208}
209
210fn gym_admins() -> std::sync::Arc<dyn Fn(u32) -> bool + Send + Sync> {
214 std::sync::Arc::new(|uid| uid == 0 || uid == 1001)
215}
216
217fn gym_authz(actor: &str) -> planarian_schema::Authorization {
218 let pe = peer(actor);
219 let session = pe.session.clone().unwrap_or_else(|| "gym".into());
220 let a = Actor::User { uid: pe.uid, session: Some(session), seat: Some("seat0".into()), active: true };
221 planarian_policy::user_authorization(a, pe.admin)
222}
223
224pub fn dur(v: &T) -> Option<Millis> {
227 let s = v.as_str()?;
228 if let Some(rest) = s.strip_prefix("stale_after") {
229 let extra = match rest.strip_prefix('+') {
230 Some(r) => dur(&T::String(r.into()))?,
231 None => Millis(0),
232 };
233 return Some(Millis(35_000 + extra.0));
234 }
235 let mut total = 0u64;
236 let mut rest = s;
237 while !rest.is_empty() {
238 let i = rest.find(|c: char| !c.is_ascii_digit())?;
239 let n: u64 = rest[..i].parse().ok()?;
240 let unit_end = rest[i..].find(|c: char| c.is_ascii_digit()).map(|j| i + j).unwrap_or(rest.len());
241 total += match &rest[i..unit_end] {
242 "ms" => n,
243 "s" => n * 1000,
244 "m" => n * 60_000,
245 "h" => n * 3_600_000,
246 _ => return None,
247 };
248 rest = &rest[unit_end..];
249 }
250 Some(Millis(total))
251}
252
253pub fn unit(s: &str) -> UnitId {
254 s.parse().unwrap_or_else(|_| UnitId::service(s))
255}
256
257pub fn value(v: &T) -> Value {
258 match v {
259 T::Boolean(b) => Value::Bool(*b),
260 T::Integer(i) => Value::Int(*i),
261 T::String(s) => Value::Str(s.clone()),
262 other => Value::Str(other.to_string()),
263 }
264}
265
266impl Sim {
267 pub fn new() -> Sim {
268 let store = MemStore::new(16 << 20);
269 let fake = gym_world(store.clone());
270 fake.w().alive.extend([DAEMON_PID, WATCHDOG_PID]);
271 let mut j = Journal::open(store).expect("journal");
272 let now = fake.w().now;
273 j.append(now, Record::KnownGood(GenerationId(1))).expect("enroll");
274 let mut core = Core::start(j, fake.clone(), OwnerId::Daemon { pid: DAEMON_PID, boot_id: Hash::ZERO }, config());
275 core.admin_of = gym_admins();
276 Sim {
277 fake,
278 core: Some(core),
279 posture: planarian_schema::Posture::Standard,
280 boots: 0,
281 fresh_boot: false,
282 daemon_pid: DAEMON_PID,
283 held: false,
284 crash: None,
285 start: now,
286 responses: vec![],
287 notices: vec![],
288 base: GenerationId(1),
289 candidate: None,
290 log: vec![],
291 seen_status: BTreeMap::new(),
292 seen_incidents: BTreeMap::new(),
293 first_incident_at: BTreeMap::new(),
294 pre_status: BTreeMap::new(),
295 touched: Default::default(),
296 history: vec![],
297 compacted: false,
298 torn_seen: false,
299 held_tokens: vec![],
300 timeline: BTreeMap::new(),
301 foreground_steps: vec![],
302 start_probe_defs: String::new(),
303 start_work: (0, 0),
304 start_roots: Default::default(),
305 posture_seen: Default::default(),
306 }
307 }
308
309 pub fn begin(&mut self) {
311 self.start = self.now();
312 self.pre_status = self.statuses();
313 self.start_probe_defs = self.probe_defs();
314 let w = self.fake.w();
315 self.start_work = (w.stages, w.builds);
316 drop(w);
317 self.start_roots = self.build_roots();
318 }
319
320 fn build_roots(&self) -> std::collections::BTreeSet<String> {
321 self.fake.w().gc_roots.iter().filter(|r| r.contains("/builds/")).cloned().collect()
322 }
323
324 fn probe_defs(&mut self) -> String {
326 let defs = self.observe().1;
327 let probes: BTreeMap<String, Vec<ProbeDef>> =
328 defs.iter().map(|(id, d)| (id.to_string(), d.probes.clone())).collect();
329 serde_json::to_string(&probes).expect("serializes")
330 }
331
332 fn save_segment(&mut self) {
334 if let Ok(mut j) = Journal::open(self.fake.store.clone())
335 && let Ok(seg) = j.segment()
336 {
337 for e in seg {
338 if self.history.last().is_none_or(|h| h.seq < e.seq) {
339 self.history.push(e);
340 }
341 }
342 }
343 }
344
345 pub fn now(&self) -> Timestamp {
346 self.fake.w().now
347 }
348
349 pub fn ledger(&self) -> Ledger {
350 Journal::open(self.fake.store.clone()).map(|j| j.ledger().clone()).unwrap_or_default()
351 }
352
353 fn harvest(&mut self) {
354 let now = self.now();
355 if let Some(c) = self.core.as_mut() {
356 self.notices.extend(c.notices.drain(..));
357 self.log.append(&mut c.log);
358 for i in c.incidents.incidents.values() {
359 self.first_incident_at.entry(i.target.clone()).or_insert(now);
360 self.seen_incidents.insert(i.id, i.clone());
361 }
362 }
363 }
364
365 pub fn evidence(&mut self) -> crate::evidence::Evidence {
367 let finals = self.statuses();
368 self.save_segment();
369 let entries: Vec<Entry> = self.history.iter().filter(|e| e.at >= self.start).cloned().collect();
370 let full_replay = self.compacted.then(|| {
371 let mut l = Ledger::default();
372 for e in &self.history {
373 let _ = l.apply(e.clone());
374 }
375 l
376 });
377 let probe_defs = (self.start_probe_defs.clone(), self.probe_defs());
378 let gc_roots = (self.start_roots.clone(), self.build_roots());
379 let revert_to: BTreeMap<TxnId, GenerationId> = self
381 .history
382 .iter()
383 .filter_map(|e| match &e.record {
384 Record::Prepared(t) => Some((t.id, t.revert_to)),
385 _ => None,
386 })
387 .collect();
388 let journal_replays = Journal::open(self.fake.store.clone()).is_ok();
389 let w = self.fake.w();
390 let mut restarts: BTreeMap<UnitId, i64> = BTreeMap::new();
391 for c in w.calls.iter().filter(|c| !c.fenced) {
392 if let Some(u) = c.call.strip_prefix("restart(").and_then(|r| r.strip_suffix(')')) {
393 *restarts.entry(unit(u)).or_default() += 1;
394 }
395 }
396 let name = |call: &str, txn: TxnId| -> String {
397 match call.strip_prefix("activate_live(").and_then(|g| g.strip_suffix(')')).and_then(|g| g.parse().ok()) {
398 Some(g) if revert_to.get(&txn) == Some(&GenerationId(g)) => "activate_live(revert_to)".into(),
399 Some(_) => "activate_live(candidate)".into(),
400 None => call.to_string(),
401 }
402 };
403 let jobs: std::collections::BTreeSet<OpId> = w.job_spans.values().map(|(j, _, _)| j.op).collect();
404 let effects: Vec<crate::evidence::Effect> = w
405 .calls
406 .iter()
407 .filter(|c| !c.fenced && !jobs.contains(&c.op))
408 .map(|c| crate::evidence::Effect {
409 call: name(&c.call, c.op.txn),
410 txn: c.op.txn,
411 epoch: c.op.epoch,
412 seq: c.op.seq,
413 start: c.at,
414 end: Some(c.at),
415 })
416 .chain(w.job_spans.values().map(|(j, start, end)| crate::evidence::Effect {
417 call: name(&format!("activate_live({})", j.activates.0), j.op.txn),
418 txn: j.op.txn,
419 epoch: j.op.epoch,
420 seq: j.op.seq,
421 start: *start,
422 end: *end,
423 }))
424 .collect();
425 let drivers = w.devices.iter().filter_map(|(d, r)| r.as_ref().map(|r| (d.clone(), r.name.clone()))).collect();
426 let options = w.gens.get(&w.current).map(|g| g.eval.options.clone()).unwrap_or_default();
427 let (current, boot_default, boot_entries, collected, overrides_empty) = (
428 w.current,
429 w.boot_default,
430 w.boot_entries.iter().copied().collect(),
431 w.collected.to_vec(),
432 w.overrides.assignments.is_empty(),
433 );
434 let override_hash = Some(planarian_fake::World::override_hash_of(&w.overrides));
435 let work = Some((w.stages - self.start_work.0, w.builds - self.start_work.1));
436 let third_party_override = w.third_party_override;
437 let ledger = self.ledger();
439 let mut disagree = vec![];
440 if w.boot_default != w.current {
441 disagree.push(format!("boot default {} is not the running generation {}", w.boot_default.0, w.current.0));
442 }
443 if let Some(g) = w.one_shot {
444 disagree.push(format!("a one-shot entry for generation {} is still armed", g.0));
445 }
446 if let (Some(h), Some(rec)) = (override_hash, w.gen_overrides.get(&w.current))
447 && h != *rec
448 {
449 disagree.push(format!("the override file is not the one generation {} was committed with", w.current.0));
450 }
451 let live_roots: BTreeSet<&String> = ledger.live.values().filter_map(|t| t.txn.gc_root.as_ref()).collect();
452 for r in w.gc_roots.iter().filter(|r| r.contains("/builds/") && !live_roots.contains(r)) {
453 let entry =
454 w.boot_entries.iter().any(|g| w.gens.get(g).is_some_and(|x| r.ends_with(&x.eval.source.to_hex())));
455 if !entry {
456 disagree.push(format!("GC root {r} belongs to no live transaction or boot entry"));
457 }
458 }
459 for t in ledger.live.values() {
460 disagree.push(format!("transaction {} is still {}", t.txn.id.0, tag(&t.txn.state)));
461 }
462 drop(w);
463 crate::evidence::Evidence {
464 current,
465 boot_default,
466 base: self.base,
467 candidate: self.candidate,
468 boot_entries,
469 collected,
470 work,
471 overrides_empty,
472 override_hash,
473 third_party_override,
474 agreement: Some(disagree),
475 ledger,
476 journal_replays,
477 log: self.log.clone(),
478 pre_status: self.pre_status.clone(),
479 finals,
480 touched: self.touched.clone(),
481 seen_status: self.seen_status.clone(),
482 seen_incidents: self.seen_incidents.clone(),
483 open_incidents: self
484 .core
485 .as_ref()
486 .map(|c| {
487 c.incidents
488 .incidents
489 .values()
490 .filter(|i| i.state != IncidentState::Closed)
491 .map(|i| (i.target.clone(), i.clone()))
492 .collect()
493 })
494 .unwrap_or_default(),
495 first_incident_at: self.first_incident_at.clone(),
496 start: self.start,
497 restarts,
498 notices: self.notices.clone(),
499 responses: self.responses.clone(),
500 entries: Some(entries),
501 full_replay,
502 effects: Some(effects),
503 status_timeline: Some(self.timeline.clone()),
504 foreground: Some(self.foreground_steps.clone()),
505 drivers: Some(drivers),
506 options: Some(options),
507 probe_defs: Some(probe_defs),
508 gc_roots: Some(gc_roots),
509 posture: crate::evidence::PostureSeen {
510 chain_broken: matches!(Journal::open(self.fake.store.clone()), Err(JournalError::Integrity(_))),
511 ..self.posture_seen.clone()
512 },
513 }
514 }
515
516 pub fn statuses(&mut self) -> BTreeMap<UnitId, String> {
518 let (s, defs) = self.observe();
519 defs.keys()
520 .map(|id| (id.clone(), format!("{:?}", s.status(id, &defs)).split('(').next().unwrap_or("").to_string()))
521 .collect()
522 }
523
524 fn observe(&mut self) -> (SystemState, UnitDefs) {
525 match self.core.as_mut() {
526 Some(c) => c.observe(),
527 None => {
528 let g = self.fake.w().current;
530 let gens = &self.fake;
531 use planarian_schema::GenerationBackend;
532 let m = gens
533 .path_of(g)
534 .and_then(|p| gens.evaluate_built(&p))
535 .map(|e| planarian_exec::derive::manifest_of(&e))
536 .unwrap_or_default();
537 let mut a = planarian_state::Assembler::new(m, fixtures::canonical());
538 let l = self.ledger();
539 let ctx = planarian_state::Context {
540 known_good: l.known_good.unwrap_or(g),
541 live_tested: l.live_tested,
542 accepted_baseline: l.accepted_baseline,
543 security_floor: l.security_floor,
544 overrides: l.overrides.clone(),
545 core_hash: Hash::ZERO,
546 };
547 let s = a.observe(&self.fake, &ctx);
548 let d = a.defs();
549 (s, d)
550 }
551 }
552 }
553
554 fn record_status(&mut self) {
556 if let Some((s, defs)) = self.core.as_ref().and_then(|c| c.last_observation().cloned()) {
557 for id in defs.keys() {
558 let st = s.status(id, &defs);
559 let name = format!("{st:?}");
560 let name = name.split('(').next().unwrap_or("").to_string();
561 let line = self.timeline.entry(id.clone()).or_default();
562 if line.last().is_none_or(|(_, n)| *n != name) {
563 line.push((s.observed_at, name.clone()));
564 }
565 self.seen_status.entry(id.clone()).or_default().insert(name);
566 }
567 }
568 }
569
570 fn watchdog(&mut self) {
572 if !self.fake.w().alive.contains(&WATCHDOG_PID) {
573 return;
574 }
575 let Ok(j) = Journal::open(self.fake.store.clone()) else { return };
576 let mut k = Kernel::new(
577 j,
578 ExecHost::new(self.fake.clone(), fixtures::canonical()),
579 OwnerId::Watchdog { pid: WATCHDOG_PID, boot_id: self.boot_id() },
580 );
581 let live: Vec<Transaction> = k.journal.ledger().live.values().map(|l| l.txn.clone()).collect();
582 for t in live {
583 if k.watchdog_should_take_over(&t) {
584 let r = k.recover(t.id, t.lease.epoch);
585 self.notices.push(format!("watchdog: transaction {} found in {} → {r:?}", t.id.0, tag(&t.state)));
586 }
587 }
588 }
589
590 pub fn tick(&mut self) {
591 self.fake.advance(TICK);
592 if let Some(c) = self.core.as_mut() {
593 c.tick();
594 if let Some(why) = c.integrity.take() {
595 self.harvest();
596 self.integrity(why);
597 }
598 }
599 self.record_status();
600 if ((self.now().0 - self.start.0) / TICK.0).is_multiple_of(2) {
601 self.watchdog();
602 }
603 self.harvest();
604 }
605
606 fn boot_id(&self) -> Hash {
607 if self.boots == 0 { Hash::ZERO } else { Hash::of(format!("boot {}", self.boots).as_bytes()) }
608 }
609
610 fn kill_daemon(&mut self) {
611 self.fake.w().alive.remove(&self.daemon_pid);
612 self.core = None;
613 }
614
615 fn integrity(&mut self, why: String) {
617 self.kill_daemon();
618 self.held = true;
619 self.posture_seen.classes.insert("integrity".into());
620 self.posture_seen.finding = Some(why.clone());
621 self.notices.push(format!("INTEGRITY: {why}"));
622 }
623
624 fn start_daemon(&mut self) {
625 self.daemon_pid += 1;
626 let pid = self.daemon_pid;
627 let j = match Journal::open(self.fake.store.clone()) {
628 Ok(j) => j,
629 Err(JournalError::Integrity(why)) => {
630 return self.integrity(format!("journal: integrity: {why}"));
631 }
632 Err(e) => panic!("journal: {e:?}"),
633 };
634 self.fake.w().alive.insert(pid);
635 let mut cfg = config();
636 cfg.fresh_boot = std::mem::take(&mut self.fresh_boot);
637 cfg.posture = self.posture;
638 let boot_id = self.boot_id();
639 let mut core = Core::start(j, self.fake.clone(), OwnerId::Daemon { pid, boot_id }, cfg);
640 core.admin_of = gym_admins();
641 core.finish_recovery();
644 core.remember(self.log.clone());
646 self.core = Some(core);
647 self.harvest();
648 }
649
650 fn request(&mut self, actor: &str, req: Request) -> Result<(), Unsupported> {
653 self.request_peer(peer(actor), req)
654 }
655
656 fn request_peer(&mut self, mut p: Peer, req: Request) -> Result<(), Unsupported> {
657 if let (Some(name), None) = (&p.agent, p.instance) {
658 p.instance = self.fake.w().agent_instances.get(name).copied();
659 }
660 let Some(core) = self.core.as_mut() else { return Err(Unsupported("request while the daemon is down".into())) };
661 core.ex.kernel.crash_at = self.crash.clone();
662 let r = core.handle(p, req);
663 let crashed = matches!(&r, Response::Error { message } if message.contains("Crashed"));
664 if let Some(c) = self.core.as_mut() {
665 c.ex.kernel.crash_at = None;
666 }
667 self.responses.push(r);
668 self.harvest();
669 if crashed || (self.fake.store.torn() && !self.torn_seen) {
671 self.torn_seen = self.fake.store.torn();
672 self.kill_daemon();
673 self.crash = None;
674 }
675 self.track_candidate();
676 Ok(())
677 }
678
679 fn track_candidate(&mut self) {
680 let l = self.ledger();
681 if let Some(c) = l
684 .live
685 .values()
686 .filter(|t| {
687 !matches!(
688 t.txn.transition,
689 Transition::Rollback | Transition::Revert { .. } | Transition::SwitchGeneration { .. }
690 )
691 })
692 .filter_map(|t| t.txn.candidate.as_ref().map(|(g, _)| *g))
693 .next_back()
694 {
695 self.candidate = Some(c);
696 }
697 let w = self.fake.w();
698 if self.candidate.is_none() && w.gens.len() > 1 {
699 drop(w);
700 self.candidate = self.fake.w().gens.keys().copied().max().filter(|g| *g != self.base);
701 }
702 }
703
704 fn until(&mut self, limit: Millis, mut pred: impl FnMut(&mut Sim) -> bool) {
706 let end = self.now().plus(limit);
707 while self.now() < end && !pred(self) {
708 self.tick();
709 }
710 }
711
712 fn journal_reached(&self, state: &str) -> bool {
713 let l = self.ledger();
714 l.live.values().any(|t| t.path.iter().any(|p| p == state))
715 || l.terminal.values().any(|t| t.path.iter().any(|p| p == state))
716 }
717
718 pub fn arm(&mut self, steps: &[&T]) -> (std::collections::BTreeSet<usize>, Option<(usize, CrashPoint)>) {
725 let mut consumed = std::collections::BTreeSet::new();
726 let mut crash = None;
727 let is_txn = |s: &T| {
728 s.get("transition").is_some()
729 || matches!(s.get("raw").and_then(T::as_str), Some("ask" | "cli" | "approve_pending"))
730 };
731 for (i, s) in steps.iter().enumerate() {
732 for (key, point) in [("op_called", CrashPoint::OpCalled as fn(_) -> _), ("op_begun", CrashPoint::OpBegun)] {
733 if let Some(call) = s.get("when").and_then(|w| w.get(key)).and_then(T::as_str) {
734 let call: &'static str = Box::leak(call.to_string().into_boxed_str());
735 if let Some(j) = (0..i).rev().find(|j| is_txn(steps[*j])) {
736 crash = Some((j, point(call)));
737 }
738 }
739 }
740 if s.get("when").is_some_and(|w| w.get("op_called").is_some() || w.get("op_begun").is_some()) {
741 continue;
742 }
743 let Some(state) = s.get("when").and_then(|w| w.get("journal")).and_then(T::as_str) else { continue };
744 let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("");
745 match get("raw") {
746 "kill_process" if get("process") == "planariad" => {
747 let state: &'static str = Box::leak(state.to_string().into_boxed_str());
748 let slow_worker = steps.iter().any(|x| {
751 x.get("raw").and_then(T::as_str) == Some("slow_backend")
752 && x.get("call").and_then(T::as_str) == Some("activate_live")
753 });
754 let point = if state == "OpId" || (state == "Activating" && slow_worker) {
755 CrashPoint::OpCalled("activate_live")
756 } else {
757 CrashPoint::State(state)
758 };
759 if let Some(j) = (0..i).rev().find(|j| is_txn(steps[*j])) {
760 crash = Some((j, point));
761 }
762 }
763 "interrupt_write" if get("target") == "record" => {
765 self.fake.store.tear_write_containing(&format!("\"to\":\"{state}\""));
766 consumed.insert(i);
767 }
768 "edit_owner_source" if s.get("tracked").and_then(T::as_bool) != Some(false) => {
769 self.fake.w().edit_on_stage = true;
770 consumed.insert(i);
771 }
772 "fill_fs" if get("fs") == "device:fs-nix" => {
773 let mut w = self.fake.w();
774 w.nospace_on_build = true;
775 if let Some((free, _)) = w.fs.get_mut("/nix") {
776 *free = 0;
777 }
778 consumed.insert(i);
779 }
780 "edit_override_file" if state == "Published" => {
781 self.fake.w().third_party_on_publish = true;
782 consumed.insert(i);
783 }
784 "fill_fs" if get("fs") == "device:fs-boot" => {
785 self.fake.w().fail_publish = true;
786 consumed.insert(i);
787 }
788 "block_network" if get("dst") == "127.0.0.1" => {
789 self.fake.w().block_on_activate.insert(UnitId::service("sshd"));
790 consumed.insert(i);
791 }
792 _ => {}
793 }
794 }
795 (consumed, crash)
796 }
797
798 pub fn set_crash(&mut self, p: Option<CrashPoint>) {
799 self.crash = p;
800 }
801
802 pub fn step(&mut self, s: &T) -> Result<(), Unsupported> {
803 let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("");
804 for k in ["unit", "device"] {
805 if let Some(u) = s.get(k).and_then(T::as_str) {
806 self.touched.insert(unit(u));
807 }
808 }
809 let actor = s.get("actor").and_then(T::as_str).unwrap_or("user:1001").to_string();
810 if let Some(t) = s.get("transition").and_then(T::as_str) {
811 return self.transition(t, s, &actor);
812 }
813 match get("raw") {
814 "kill_unit" => {
815 let sig = match get("signal") {
816 "SIGHUP" => 1,
817 "SIGINT" => 2,
818 "SIGABRT" => 6,
819 "SIGSEGV" => 11,
820 "SIGTERM" => 15,
821 _ => 9,
822 };
823 self.fake.w().signal(&unit(get("unit")), sig)
824 }
825 "oom" => self.fake.w().oom(&unit(get("unit"))),
826 "footprint" => {
829 use planarian_backend_netwatch::{Change, ChangeKind, Owner};
830 let kind = match get("change") {
831 "interface" => ChangeKind::Interface,
832 "route" => ChangeKind::Route,
833 "rule" => ChangeKind::Rule,
834 "dns" => ChangeKind::Dns,
835 "proxy" => ChangeKind::Proxy,
836 "firewall" => ChangeKind::Firewall,
837 c => return Err(Unsupported(format!("footprint change {c}"))),
838 };
839 let owner = match get("unit") {
840 "unattributed" => Owner::Unattributed,
841 u => Owner::Unit(unit(u)),
842 };
843 let iface = s.get("interface").and_then(T::as_str).map(str::to_string);
844 let object = format!("{} {}", get("change"), iface.as_deref().unwrap_or("-"));
845 let steers = s.get("steers").and_then(T::as_bool).unwrap_or(false);
846 self.fake.w().netwatch.apply(&Change { owner, kind, object, iface, steers, removed: false });
847 }
848 "footprint_stale" => {
850 self.fake.w().netwatch_stale.insert(unit(get("unit")));
851 }
852 "netwatch" => match get("state") {
854 "attach-failed" => self.fake.w().netwatch_failed = Some("attach failed (scripted)".into()),
855 "attached" => self.fake.w().netwatch_failed = None,
856 st => return Err(Unsupported(format!("netwatch state {st}"))),
857 },
858 "break_in_generation" => {
861 let g = match get("generation") {
862 "candidate" => {
863 self.candidate.ok_or_else(|| Unsupported("break_in_generation: no candidate".into()))?
864 }
865 "base" => self.base,
866 g => return Err(Unsupported(format!("break_in_generation {g}"))),
867 };
868 let mut w = self.fake.w();
869 w.break_in(g, &unit(get("unit")));
870 for u in s.get("switch_restarts").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str) {
873 w.activation_restarts.insert(unit(u));
874 }
875 }
876 "restarts_on_switch" => {
879 let mut w = self.fake.w();
880 for u in s.get("units").and_then(T::as_array).into_iter().flatten().filter_map(T::as_str) {
881 w.activation_restarts.insert(unit(u));
882 }
883 }
884 "stop_unit_out_of_band" => self.fake.w().stop_out_of_band(&unit(get("unit"))),
885 "set_knob" | "set_persistent_knob" => self.knob(&unit(get("unit")), get("knob"), s.get("value"))?,
886 "corrupt_state" => {
887 if let Some(sim) = self.fake.w().units.get_mut(&unit(get("unit"))) {
888 sim.fail_on_start = true;
889 }
890 }
891 "unbind_driver" => {
892 self.fake.w().devices.insert(unit(get("device")), None);
893 }
894 "block_network" => match (get("dst"), s.get("port").and_then(T::as_integer)) {
895 ("127.0.0.1", Some(22)) => {
896 self.fake.w().blocked.insert(UnitId::service("sshd"));
897 }
898 ("10.0.2.2", None) => {
900 self.fake.w().external_unreachable.insert("gym-upstream".into());
901 }
902 (d, p) => return Err(Unsupported(format!("block_network {d} {p:?}"))),
903 },
904 "unblock_network" => {
905 let mut w = self.fake.w();
906 w.blocked.clear();
907 w.external_unreachable.clear();
908 }
909 "stop_host_service" if get("name").starts_with("hostapd-") => {
912 let ssid = get("name").trim_start_matches("hostapd-").to_string();
913 let mut w = self.fake.w();
914 w.wifi_in_range.remove(&ssid);
915 if w.runtime.get("wifi.network") == Some(&Value::Str(ssid)) {
916 w.runtime.remove("wifi.network");
917 }
918 }
919 "stop_host_service" if get("name") == "gym-upstream" => {
920 self.fake.w().external_down.insert("gym-upstream".into());
921 }
922 "start_host_service" if get("name") == "gym-upstream" => {
923 self.fake.w().external_down.remove("gym-upstream");
924 }
925 "kill_process" => match get("process") {
926 "planariad" => {
927 if s.get("when").is_some() {
928 self.until(Millis::secs(600), |sim| sim.core.is_none());
930 } else {
931 self.kill_daemon();
932 }
933 }
934 "planarian-recovery" => {
935 self.fake.w().alive.remove(&WATCHDOG_PID);
936 }
937 p => return Err(Unsupported(format!("kill_process {p}"))),
938 },
939 "hold_down" => self.held = true,
940 "break_journal_chain" => {
943 if get("when") == "powered-off" {
944 self.kill_daemon();
945 }
946 let n = Journal::open(self.fake.store.clone())
947 .and_then(|mut j| j.segment())
948 .map(|seg| seg.len())
949 .map_err(|e| Unsupported(format!("break_journal_chain: {e:?}")))?;
950 if n < 3 {
951 return Err(Unsupported("break_journal_chain: fewer than 3 records".into()));
952 }
953 let edited = planarian_txn::journal::tamper(&self.fake.store, n / 2, |p| {
954 if let Some(b) = p.iter_mut().find(|b| b.is_ascii_digit()) {
955 *b = if *b == b'9' { b'1' } else { *b + 1 };
956 }
957 });
958 if !matches!(edited, Ok(true)) {
959 return Err(Unsupported(format!("break_journal_chain: {edited:?}")));
960 }
961 }
962 "delete_journal_record" => {
965 let n = s.get("record").and_then(T::as_integer).unwrap_or(1) as usize;
966 let gone = planarian_txn::journal::delete(&self.fake.store, n)
967 .map_err(|e| Unsupported(format!("delete_journal_record: {e}")))?;
968 if !gone {
969 return Err(Unsupported(format!("delete_journal_record: no record {n}")));
970 }
971 }
972 "ack_alarm" => {
975 if peer(&actor).admin {
976 self.posture_seen.acks.push(actor.clone());
977 } else {
978 self.posture_seen.refused_acks.push(actor.clone());
979 }
980 }
981 "release" | "restart_process" => match get("process") {
982 "planariad" => {
983 self.held = false;
984 if get("raw") == "restart_process" && self.core.is_some() {
986 self.kill_daemon();
987 }
988 if self.core.is_none() {
989 self.start_daemon();
990 }
991 }
992 "planarian-recovery" => {
993 self.fake.w().alive.insert(WATCHDOG_PID);
994 }
995 p => return Err(Unsupported(format!("restart_process {p}"))),
996 },
997 "wait" => match s.get("for") {
998 Some(T::Table(t)) if t.contains_key("journal") => {
999 let state = t["journal"].as_str().unwrap_or("").to_string();
1000 self.until(Millis::secs(1800), |sim| sim.journal_reached(&state));
1001 }
1002 Some(v) => {
1003 let d = dur(v).ok_or_else(|| Unsupported(format!("wait {v}")))?;
1004 self.until(d, |_| false);
1005 }
1006 None => {}
1007 },
1008 "ask" => self.request(&actor, Request::Ask { phrase: get("phrase").into(), choice: None })?,
1009 "request_then_exit" => {
1012 let req: Request = serde_json::from_value(
1013 serde_json::to_value(s.get("request").ok_or_else(|| Unsupported("request".into()))?)
1014 .map_err(|e| Unsupported(e.to_string()))?,
1015 )
1016 .map_err(|e| Unsupported(format!("request_then_exit: {e}")))?;
1017 let actor = get("actor");
1018 let who = actor.split('@').next().unwrap_or("user:1000").to_string();
1019 self.request(&who, req)?;
1020 }
1021 "forge_proposal_fields" => {
1024 let r = s.get("request").and_then(T::as_table).ok_or_else(|| Unsupported("request".into()))?;
1025 let args: BTreeMap<String, Value> = r
1026 .get("args")
1027 .and_then(T::as_table)
1028 .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1029 .unwrap_or_default();
1030 let change = ChangeId(r.get("change").and_then(T::as_str).unwrap_or("").into());
1031 let mut line = serde_json::to_value(Request::Apply { change, args }).expect("serializes");
1032 let forged = serde_json::to_value(s.get("fields").cloned().unwrap_or(T::Table(Default::default())))
1033 .map_err(|e| Unsupported(e.to_string()))?;
1034 if let (Some(o), Some(f)) = (line.as_object_mut(), forged.as_object()) {
1035 o.extend(f.clone());
1036 }
1037 let req: Request =
1038 serde_json::from_value(line).map_err(|e| Unsupported(format!("forge_proposal_fields: {e}")))?;
1039 self.request(&actor, req)?;
1040 }
1041 "agent_run" => {
1042 let name = get("name");
1043 let argv: Vec<String> = s
1044 .get("argv")
1045 .and_then(T::as_array)
1046 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1047 .unwrap_or_default();
1048 let actor = s.get("as").and_then(T::as_str).unwrap_or("user:1001").to_string();
1049 self.request(&actor, Request::AgentRun { name: name.to_string(), argv })?;
1050 }
1051 "cli" => {
1052 let argv: Vec<String> = s
1053 .get("argv")
1054 .and_then(T::as_array)
1055 .map(|a| a.iter().filter_map(|x| x.as_str().map(String::from)).collect())
1056 .unwrap_or_default();
1057 if let ["internal", "wifi-connect", ssid] =
1060 argv.iter().map(String::as_str).collect::<Vec<_>>().as_slice()
1061 {
1062 self.fake.w().runtime.insert("wifi.network".into(), Value::Str(ssid.to_string()));
1063 return Ok(());
1064 }
1065 if argv.first().map(String::as_str) == Some("internal")
1066 && argv.get(1).map(String::as_str) == Some("present-token")
1067 {
1068 for h in self.held_tokens.clone() {
1069 self.request_peer(peer(&actor).at_terminal(), Request::Approve { hash: h })?;
1070 }
1071 return Ok(());
1072 }
1073 let req = self.cli(&argv)?;
1074 self.request(&actor, req)?;
1075 }
1076 "approve_pending" => {
1077 let pending: Vec<Hash> =
1078 self.core.as_ref().map(|c| c.ex.parked.values().map(|p| p.bound_to).collect()).unwrap_or_default();
1079 if s.get("hold_token").and_then(T::as_bool) == Some(true) {
1080 self.held_tokens.extend(pending);
1081 return Ok(());
1082 }
1083 let actor = s.get("actor").and_then(T::as_str).unwrap_or("root").to_string();
1085 let tty = s.get("via").and_then(T::as_str) != Some("no-tty");
1086 for h in pending {
1087 let p = if tty { peer(&actor).at_terminal() } else { peer(&actor) };
1088 self.request_peer(p, Request::Approve { hash: h })?;
1089 }
1090 }
1091 "greeter_login" => {
1092 let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1093 let now = self.now();
1094 self.fake.w().sessions.push(SessionInfo {
1095 id: format!("s{uid}"),
1096 uid,
1097 class: "user".into(),
1098 seat: Some("seat0".into()),
1099 remote: false,
1100 active: true,
1101 idle: false,
1102 created: now,
1103 });
1104 let sim = planarian_fake::UnitSim::active(now);
1105 for name in ["pipewire", "wireplumber"] {
1106 self.fake.w().units.insert(
1107 UnitId {
1108 kind: UnitKind::Service,
1109 name: name.into(),
1110 scope: Scope::User { uid, session: None },
1111 },
1112 sim.clone(),
1113 );
1114 }
1115 self.fake.w().units.insert(
1117 UnitId {
1118 kind: UnitKind::Target,
1119 name: "graphical-session".into(),
1120 scope: Scope::User { uid, session: None },
1121 },
1122 sim,
1123 );
1124 }
1125 "reboot" => {
1128 self.kill_daemon();
1129 self.fake.w().reboot();
1130 self.boots += 1;
1131 self.fresh_boot = true;
1132 self.start_daemon();
1133 }
1134 "fail_boot" => self.fake.w().fail_one_shot = true,
1135 "logout" => {
1136 let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1137 self.fake.w().sessions.retain(|x| x.uid != uid);
1138 }
1139 "session_login" => {
1142 let uid = s.get("uid").and_then(T::as_integer).unwrap_or(1000) as u32;
1143 let id = get("session").to_string();
1144 let seat = s.get("seat").and_then(T::as_str).map(String::from);
1145 let compositor = seat.is_some() && get("compositor") != "fails";
1147 let now = self.now();
1148 let mut w = self.fake.w();
1149 w.sessions.push(SessionInfo {
1150 id: id.clone(),
1151 uid,
1152 class: "user".into(),
1153 remote: seat.is_none(),
1154 seat,
1155 active: true,
1156 idle: false,
1157 created: now,
1158 });
1159 w.compositors.insert(id, compositor);
1160 let sim = planarian_fake::UnitSim::active(now);
1161 for name in ["pipewire", "wireplumber"] {
1162 let u = UnitId {
1163 kind: UnitKind::Service,
1164 name: name.into(),
1165 scope: Scope::User { uid, session: None },
1166 };
1167 w.units.entry(u).or_insert_with(|| sim.clone());
1168 }
1169 }
1170 "session_logout" => {
1171 let id = get("session").to_string();
1172 let mut w = self.fake.w();
1173 w.sessions.retain(|x| x.id != id);
1174 w.compositors.remove(&id);
1175 let uids: BTreeSet<u32> = w.sessions.iter().map(|x| x.uid).collect();
1177 w.units.retain(|u, _| !matches!(u.scope, Scope::User { uid, .. } if !uids.contains(&uid)));
1178 }
1179 "foreground" => {
1180 let id = get("session").to_string();
1181 let on = s.get("on").and_then(T::as_bool).unwrap_or(true);
1182 let now = self.now();
1183 self.foreground_steps.push((now, id.clone(), on));
1184 let mut w = self.fake.w();
1185 if on {
1186 w.foreground.insert(id);
1187 } else {
1188 w.foreground.remove(&id);
1189 }
1190 }
1191 "slow_backend" if get("call") == "activate_live" => {
1192 let d = s.get("delay").and_then(dur).unwrap_or(Millis::secs(120));
1193 let uncancellable = s.get("uncancellable").and_then(T::as_bool).unwrap_or(false);
1194 self.fake.w().slow_activation = Some((d, !uncancellable));
1195 }
1196 "withhold_reply" if get("call") == "activate_live" => {
1197 self.fake.w().slow_activation = Some((Millis::secs(30), false));
1198 }
1199 "backend_result" => match (get("call"), get("result")) {
1200 ("jobs_tagged", "None") => self.fake.w().jobs_tagged_unknown = true,
1201 ("policy_version", "bumped") => {
1204 let c =
1205 self.core.as_mut().ok_or_else(|| Unsupported("policy bump while the daemon is down".into()))?;
1206 c.ex.cfg.tables.model_catalog.push(ModelId("gym:policy-bump".into()));
1207 }
1208 ("evaluate", "planariad-unit-changed") => {
1209 self.fake.w().rules.push(planarian_fake::Rule {
1210 option: "planarian.reference.fontScale".into(),
1211 value: Value::Str("1.25".into()),
1212 effect: planarian_fake::Effect::TouchCore,
1213 });
1214 }
1215 ("dry_activate", r @ ("exit-nonzero" | "timeout" | "truncated")) => {
1216 self.fake.w().dry_activate_fails = Some(format!("dry activation: {r}"));
1217 }
1218 ("verify_candidate", "nar-mismatch") => {
1219 self.fake.w().nar_mismatch = true;
1220 }
1221 ("activate_live", "Failed") => {
1222 let base = self.base;
1223 self.fake.w().fail_activate.insert(base);
1224 }
1225 (c, r) => return Err(Unsupported(format!("backend_result {c} {r}"))),
1226 },
1227 "edit_owner_source" => {
1228 let path = get("path").to_string();
1229 let tracked = s.get("tracked").and_then(T::as_bool).unwrap_or(true);
1230 if tracked {
1231 let mut w = self.fake.w();
1232 w.owner_files.insert(path, "edited".into());
1233 if let Some(svc) = s.get("enables").and_then(T::as_str) {
1236 w.base_options.insert(format!("services.{svc}.enable"), Value::Bool(true));
1237 }
1238 } else {
1239 self.fake.w().untracked.insert(path);
1240 }
1241 }
1242 "suspend" => {
1245 let d = s.get("duration").and_then(dur).ok_or_else(|| Unsupported("suspend duration".into()))?;
1246 self.fake.advance(d);
1247 }
1248 "set_wall_clock" => {
1250 let by = get("by");
1251 let (neg, rest) = match by.strip_prefix('-') {
1252 Some(r) => (true, r.to_string()),
1253 None => (false, by.trim_start_matches('+').to_string()),
1254 };
1255 let d = dur(&T::String(rest)).ok_or_else(|| Unsupported(format!("set_wall_clock {by}")))?;
1256 let ms = d.0 as i64;
1257 self.fake.w().wall_skew += if neg { -ms } else { ms };
1258 }
1259 "advance_clock" => {
1260 let d = s.get("by").and_then(dur).ok_or_else(|| Unsupported("advance_clock".into()))?;
1261 self.fake.advance(d);
1262 if let Some(c) = self.core.as_mut() {
1263 c.tick();
1264 }
1265 }
1266 "interrupt_write" if get("target") == "checkpoint" => {
1269 self.save_segment();
1270 self.compacted = true;
1271 self.fake.store.fail_next_checkpoint();
1272 if let Some(c) = self.core.as_mut() {
1273 let _ = c.ex.kernel.journal.compact();
1274 } else {
1275 let _ = Journal::open(self.fake.store.clone()).map(|mut j| j.compact());
1276 }
1277 }
1278 "corrupt_checkpoint" => {
1281 self.kill_daemon();
1282 let mut b = self
1283 .fake
1284 .store
1285 .read_checkpoint()
1286 .ok()
1287 .flatten()
1288 .ok_or_else(|| Unsupported("corrupt_checkpoint: no checkpoint".into()))?;
1289 let mid = b.len() / 2;
1290 b[mid] ^= 0x20;
1291 self.fake.store.write_checkpoint(&b).map_err(|e| Unsupported(e.to_string()))?;
1292 }
1293 "force_compaction" => {
1294 self.save_segment();
1295 self.compacted = true;
1296 if let Some(c) = self.core.as_mut() {
1297 let _ = c.ex.kernel.journal.compact();
1298 } else {
1299 let _ = Journal::open(self.fake.store.clone()).map(|mut j| j.compact());
1300 }
1301 }
1302 "hotunplug" => {
1303 self.fake.w().devices.remove(&unit(get("device")));
1304 }
1305 "fill_fs" => {
1306 let path = match get("fs") {
1307 "device:fs-nix" => "/nix",
1308 "device:fs-boot" => "/boot",
1309 "device:fs-journal" => "/var/lib/planarian",
1310 _ => "/",
1311 };
1312 if get("until") == "full" && path == "/boot" {
1313 self.fake.w().fail_publish = true;
1314 }
1315 if let Some((free, size)) = self.fake.w().fs.get_mut(path) {
1316 *free = if get("until") == "full" { 0 } else { *size / 100 };
1317 }
1318 }
1319 "set_setting" => match get("key") {
1320 "gc.retain_all" => {
1321 self.fake.w().gc_budget.retain_all = s.get("value").and_then(T::as_bool).unwrap_or(true);
1322 }
1323 "gc.generation_budget_percent" => {
1324 let pct =
1325 s.get("value").and_then(T::as_float).ok_or_else(|| Unsupported("set_setting value".into()))?;
1326 self.fake.w().gc_budget.generations_permille = (pct * 10.0).round() as u32;
1327 }
1328 "userControllable" => {
1329 let units: Vec<String> = s
1330 .get("value")
1331 .and_then(T::as_array)
1332 .into_iter()
1333 .flatten()
1334 .filter_map(T::as_str)
1335 .map(|u| unit(u).name)
1336 .collect();
1337 let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1338 c.ex.cfg.settings.user_controllable_units.extend(units);
1339 }
1340 "userControllableChanges" => {
1341 let add: Vec<ChangeId> = s
1342 .get("value")
1343 .and_then(T::as_array)
1344 .into_iter()
1345 .flatten()
1346 .filter_map(T::as_str)
1347 .map(|c| ChangeId(c.into()))
1348 .collect();
1349 let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1350 c.ex.cfg.settings.user_controllable_changes.extend(add);
1351 }
1352 "max_tier" => {
1353 let tier = match s.get("value").and_then(T::as_str) {
1354 Some("Observe") => Tier::Observe,
1355 Some("Runtime") => Tier::Runtime,
1356 Some("Tested") => Tier::Tested,
1357 v => return Err(Unsupported(format!("set_setting max_tier {v:?}"))),
1358 };
1359 let c = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1360 c.ex.cfg.settings.max_tier = tier;
1361 }
1362 "posture" => {
1363 self.posture = match s.get("value").and_then(T::as_str) {
1364 Some("Relaxed") => planarian_schema::Posture::Relaxed,
1365 Some("Guarded") => planarian_schema::Posture::Guarded,
1366 Some("Sealed") => planarian_schema::Posture::Sealed,
1367 _ => planarian_schema::Posture::Standard,
1368 };
1369 let p = self.posture;
1370 if let Some(c) = self.core.as_mut() {
1371 c.set_posture(p);
1372 }
1373 }
1374 k => return Err(Unsupported(format!("set_setting {k}"))),
1375 },
1376 "out_of_band_rebuild" => {
1377 let edit = get("edit");
1378 let activate = s.get("activate").and_then(T::as_bool).unwrap_or(false);
1379 let mut w = self.fake.w();
1380 if let Some(n) = edit
1381 .strip_prefix("create ")
1382 .and_then(|r| r.split_whitespace().next())
1383 .and_then(|n| n.parse::<usize>().ok())
1384 {
1385 for _ in 0..n {
1386 let o = OverrideSet::default();
1387 let g = w.add_generation(&o);
1388 w.boot_entries.insert(g);
1389 }
1390 } else if edit == "build-only NetworkManager store path" {
1391 let g = w.add_generation(&OverrideSet::default());
1394 let nm = UnitId::service("NetworkManager");
1395 w.gens.get_mut(&g).expect("added").eval.activation.restart.insert(nm);
1396 w.boot_entries.insert(g);
1397 } else if edit.contains("gym-web ExecStart=/bin/false") {
1398 let o = OverrideSet::default();
1399 let g = w.add_generation(&o);
1400 w.gens.get_mut(&g).expect("added").broken.insert(UnitId::service("gym-web"));
1401 if activate {
1402 w.boot_entries.insert(g);
1404 w.boot_default = g;
1405 let sim = w.units.get_mut(&UnitId::service("gym-web")).expect("gym-web");
1406 sim.observed = Observed::Failed(Reason::ExitCode(1));
1407 w.current = g;
1408 }
1409 } else if edit.starts_with("update input gym-modules") {
1410 w.base_options.insert("gym.modules".into(), Value::Str("new".into()));
1413 w.base_inputs.insert("gym-modules".into(), ("new".into(), "/nix/store/bbb-gym-modules".into()));
1414 w.rebuild_uid = if actor == "root" { 0 } else { peer(&actor).uid };
1415 let g = w.add_generation(&OverrideSet::default());
1416 w.rebuild_uid = 0;
1417 w.boot_entries.insert(g);
1418 w.boot_default = g;
1419 w.apply_generation(g);
1420 } else if edit == "enable gym-bad-payload" {
1421 w.base_options.insert("services.gym-bad-payload.enable".into(), Value::Bool(true));
1423 w.rebuild_uid = if actor == "root" { 0 } else { peer(&actor).uid };
1424 let g = w.add_generation(&OverrideSet::default());
1425 w.rebuild_uid = 0;
1426 w.boot_entries.insert(g);
1427 w.boot_default = g;
1428 w.apply_generation(g);
1429 } else {
1430 return Err(Unsupported(format!("out_of_band_rebuild {edit:?}")));
1431 }
1432 }
1433 other => return Err(Unsupported(format!("raw action {other:?}"))),
1434 }
1435 self.harvest();
1436 Ok(())
1437 }
1438
1439 fn knob(&mut self, u: &UnitId, knob: &str, v: Option<&T>) -> Result<(), Unsupported> {
1440 let mut w = self.fake.w();
1441 let now = w.now;
1442 let sim = w.units.get_mut(u).ok_or_else(|| Unsupported(format!("no unit {u}")))?;
1443 match (knob, v) {
1444 ("health", Some(T::Integer(503))) => sim.health = Health::Http503,
1445 ("health_delay", Some(d)) => sim.health = Health::Delay(dur(d).unwrap_or(Millis::secs(15))),
1446 ("probe_delay", Some(d)) => sim.health = Health::Stall(dur(d).unwrap_or(Millis::secs(15))),
1447 ("fail_after", Some(d)) => {
1448 sim.fail_after = dur(d);
1449 sim.started_at = now;
1450 }
1451 ("fail_on_start", Some(T::Boolean(b))) => sim.fail_on_start = *b,
1452 (k, v) => return Err(Unsupported(format!("knob {k}={v:?}"))),
1453 }
1454 Ok(())
1455 }
1456
1457 fn cli(&self, argv: &[String]) -> Result<Request, Unsupported> {
1458 let a: Vec<&str> = argv.iter().map(String::as_str).collect();
1459 Ok(match a.as_slice() {
1460 ["override", u] => Request::SetOverride {
1461 unit: unit(u),
1462 until: Until::Explicit,
1463 desired: planarian_schema::OverrideDesired::LeaveAlone,
1464 },
1465 ["override", u, "--stop" | "--stopped"] => Request::SetOverride {
1466 unit: unit(u),
1467 until: Until::Explicit,
1468 desired: planarian_schema::OverrideDesired::Stopped,
1469 },
1470 ["stop", u] => Request::Stop { unit: unit(u) },
1471 ["restart", u] => Request::Restart { unit: unit(u) },
1472 ["ack"] => Request::Acknowledge { unit: None },
1473 ["ack", u] => Request::Acknowledge { unit: Some(unit(u)) },
1474 ["release", u] => Request::LiftOverride { unit: unit(u) },
1475 ["resolve", _t, how] => {
1476 let l = self.ledger();
1477 let txn = match (l.interlock.keys().next(), *_t) {
1481 (Some(t), _) => *t,
1482 (None, "any") => TxnId(u64::MAX),
1483 (None, _) => return Err(Unsupported("resolve: nothing interlocked".into())),
1484 };
1485 let resolution = match *how {
1486 "--as-committed" => Resolution::AsCommitted,
1487 "--as-reverted" => Resolution::AsReverted,
1488 _ => Resolution::ManualRepaired,
1489 };
1490 Request::Resolve { txn, resolution }
1491 }
1492 ["diagnose", u, "--probe", p] => Request::Diagnose { unit: unit(u), probe: Some(ProbeId(p.to_string())) },
1493 ["undo"] => Request::Undo,
1494 other => return Err(Unsupported(format!("cli {other:?}"))),
1495 })
1496 }
1497
1498 fn transition(&mut self, t: &str, s: &T, actor: &str) -> Result<(), Unsupported> {
1499 let get = |k: &str| s.get(k).and_then(T::as_str).unwrap_or("").to_string();
1500 let req = match t {
1501 "ApplyChange" => {
1502 let args: BTreeMap<String, Value> = s
1503 .get("args")
1504 .and_then(T::as_table)
1505 .map(|a| a.iter().map(|(k, v)| (k.clone(), value(v))).collect())
1506 .unwrap_or_default();
1507 Request::Apply { change: ChangeId(get("change")), args }
1508 }
1509 "RestartUnit" if s.get("expected").is_some() || s.get("observed").is_some() => {
1510 let core = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1511 let (st, defs) = core.observe();
1512 let t = Transition::RestartUnit(unit(&get("unit")));
1513 let mut expected = planarian_schema::spec::predict(&t, &st, &defs);
1514 if let Some(m) = s.get("expected").and_then(|e| e.get("may_change")).and_then(T::as_array) {
1515 expected.may_change = m.iter().filter_map(T::as_str).map(unit).collect();
1516 }
1517 let observed = if s.get("observed").is_some() { Hash::ZERO } else { st.digest() };
1518 let p = Proposal { observed, expected, transition: t, reason: "gym".into(), source: Source::User };
1519 let pe = peer(actor);
1520 let authz = gym_authz(actor);
1521 let policy = core.ex.policy_version();
1522 let r = core.ex.submit(p.clone(), authz, &st, &defs);
1523 let (facts, decision) = core.ex.last.clone().unzip();
1524 let now = self.fake.w().now;
1525 self.log.push(planarian_daemon::LogRecord {
1526 at: now,
1527 proposal: p,
1528 caller: Actor::user(pe.uid),
1529 pre_digest: st.digest(),
1530 policy,
1531 result: r,
1532 facts,
1533 decision,
1534 post_digest: None,
1535 path: vec![],
1536 why: String::new(),
1537 agent: None,
1538 no_snapshot: false,
1539 approver: None,
1540 bound_to: None,
1541 });
1542 return Ok(());
1543 }
1544 "RestartUnit" => Request::Restart { unit: unit(&get("unit")) },
1545 "CollectGarbage" => Request::Gc,
1546 "Rollback" => return self.direct(actor, Transition::Rollback),
1547 "RestoreSnapshot" => {
1549 let id = SnapshotId(get("snapshot"));
1550 return self.direct(actor, Transition::RestoreSnapshot(id));
1551 }
1552 "SwitchGeneration" => {
1553 let g = self.fake.w().gens.keys().copied().max().unwrap_or(GenerationId(1));
1554 self.candidate = Some(g);
1555 return self.direct(actor, Transition::SwitchGeneration(g));
1556 }
1557 "BindDriver" => {
1558 let device = unit(&get("device"));
1559 let driver = DriverRef { name: get("driver"), module: None };
1560 return self.direct(actor, Transition::BindDriver { device, driver });
1561 }
1562 "SetOverride" => Request::SetOverride {
1563 unit: unit(&get("unit")),
1564 until: Until::Explicit,
1565 desired: planarian_schema::OverrideDesired::LeaveAlone,
1566 },
1567 other => return Err(Unsupported(format!("transition {other}"))),
1568 };
1569 self.request(actor, req)
1570 }
1571
1572 fn direct(&mut self, actor: &str, t: Transition) -> Result<(), Unsupported> {
1574 let crash = self.crash.clone();
1575 let core = self.core.as_mut().ok_or_else(|| Unsupported("daemon down".into()))?;
1576 core.ex.kernel.crash_at = crash;
1577 let (st, defs) = core.observe();
1578 let p = Proposal {
1579 observed: st.digest(),
1580 expected: planarian_schema::spec::predict(&t, &st, &defs),
1581 transition: t,
1582 reason: "gym".into(),
1583 source: Source::User,
1584 };
1585 let pe = peer(actor);
1586 let authz = gym_authz(actor);
1587 let policy = core.ex.policy_version();
1588 let r = core.ex.submit(p.clone(), authz, &st, &defs);
1589 let (facts, decision) = core.ex.last.clone().unzip();
1590 if let Some(c) = self.core.as_mut() {
1591 c.ex.kernel.crash_at = None;
1592 }
1593 let crashed = matches!(&r, planarian_exec::Submitted::Error(e) if e.contains("Crashed"));
1594 let now = self.fake.w().now;
1595 self.log.push(planarian_daemon::LogRecord {
1596 at: now,
1597 proposal: p,
1598 caller: Actor::user(pe.uid),
1599 pre_digest: st.digest(),
1600 policy,
1601 result: r,
1602 facts,
1603 decision,
1604 post_digest: None,
1605 path: vec![],
1606 why: String::new(),
1607 agent: None,
1608 no_snapshot: false,
1609 approver: None,
1610 bound_to: None,
1611 });
1612 if crashed {
1613 self.kill_daemon();
1614 self.crash = None;
1615 }
1616 Ok(())
1617 }
1618
1619 pub fn run_window(&mut self, window: Millis) {
1621 let end = self.start.plus(window);
1622 while self.now() < end {
1623 if self.core.is_none() && !self.held {
1624 self.start_daemon();
1625 }
1626 self.tick();
1627 }
1628 }
1629}