Skip to main content

planarian/
main.rs

1//! `planarian` and `ask` (§16). One binary; invoked as `ask` (by name or as
2//! `planarian ask`) it interprets a phrase.
3
4use planarian_ipc::{Request, Response, SOCKET, StatusReport};
5use planarian_schema::{
6    ChangeId, Hash, IncidentState, OverrideDesired, Resolution, TxnId, UnitId, UnitStatus, Until, Value,
7};
8use std::collections::BTreeMap;
9use std::io::{BufRead, BufReader, IsTerminal, Write};
10use std::os::unix::net::UnixStream;
11
12const USAGE: &str = "usage:
13  planarian status [--json]
14  planarian log [-n N]
15  planarian incidents
16  planarian ask \"<phrase>\"          (also: ask \"<phrase>\")
17  planarian apply <change> [key=value ...]
18  planarian stop <unit>               stop it and leave it stopped until the next boot
19  planarian restart <unit>
20  planarian override <unit> [--leave-alone|--stopped|--absent|--detached] [--until next-boot|explicit|<N>m]
21  planarian release <unit>
22  planarian hold release <unit> [--until next-boot|<N>m]   lift a network hold (administrator)
23  planarian gc
24  planarian undo
25  planarian diagnose <unit> [probe]
26  planarian approve [--json]          list what waits for approval, with what each would change
27  planarian approve <hash>            root, at a terminal; shows what it changes, then approves
28  planarian cancel <txn>
29  planarian resolve <txn> --as-committed|--as-reverted|--manual-repaired
30  planarian mark-good [--accept-unconfirmed]
31  planarian mark-security-floor       the running generation becomes the security floor
32
33Units are written like `sshd`, `service:sshd`, `device:snd-hda0` or `service@1000:pipewire`.";
34
35fn unit(s: &str) -> Result<UnitId, String> {
36    if s.contains(':') {
37        s.parse().map_err(|_| format!("not a unit: {s}"))
38    } else {
39        Ok(UnitId::service(s.trim_end_matches(".service")))
40    }
41}
42
43fn value(s: &str) -> Value {
44    match s {
45        "true" => Value::Bool(true),
46        "false" => Value::Bool(false),
47        _ => s.parse::<i64>().map(Value::Int).unwrap_or_else(|_| Value::Str(s.into())),
48    }
49}
50
51fn now_ms() -> u64 {
52    std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0)
53}
54
55fn parse(args: &[String]) -> Result<(Request, bool), String> {
56    let json = args.iter().any(|a| a == "--json");
57    let args: Vec<&str> = args.iter().map(String::as_str).filter(|a| *a != "--json").collect();
58    let req = match args.as_slice() {
59        ["status"] | [] => Request::Status,
60        ["log"] => Request::Log { limit: 40 },
61        ["log", "-n", n] => Request::Log { limit: n.parse().map_err(|_| "-n needs a number")? },
62        ["incidents"] => Request::Incidents,
63        ["ask", rest @ ..] if !rest.is_empty() => Request::Ask { phrase: rest.join(" "), choice: None },
64        ["apply", change, kv @ ..] => {
65            let mut a = BTreeMap::new();
66            for p in kv {
67                let (k, v) = p.split_once('=').ok_or_else(|| format!("expected key=value, got {p}"))?;
68                a.insert(k.to_string(), value(v));
69            }
70            Request::Apply { change: ChangeId(change.to_string()), args: a }
71        }
72        ["stop", u] => Request::Stop { unit: unit(u)? },
73        ["restart", u] => Request::Restart { unit: unit(u)? },
74        ["override", u, flags @ ..] => {
75            let mut until = Until::Explicit;
76            let mut desired = OverrideDesired::LeaveAlone;
77            let mut it = flags.iter();
78            while let Some(f) = it.next() {
79                match *f {
80                    "--leave-alone" => desired = OverrideDesired::LeaveAlone,
81                    "--stopped" | "--stop" => desired = OverrideDesired::Stopped,
82                    "--absent" => desired = OverrideDesired::Absent,
83                    "--detached" => desired = OverrideDesired::Detected,
84                    "--until" => {
85                        until = match it.next().copied() {
86                            Some("next-boot") => Until::NextBoot,
87                            Some("explicit") => Until::Explicit,
88                            Some(m) => {
89                                let mins: u64 = m
90                                    .trim_end_matches('m')
91                                    .parse()
92                                    .map_err(|_| "--until takes next-boot, explicit or <N>m")?;
93                                Until::At(planarian_schema::Timestamp(now_ms() + mins * 60_000))
94                            }
95                            None => return Err("--until takes next-boot, explicit or <N>m".into()),
96                        }
97                    }
98                    other => return Err(format!("override: unknown flag {other}")),
99                }
100            }
101            Request::SetOverride { unit: unit(u)?, until, desired }
102        }
103        ["release", u] => Request::LiftOverride { unit: unit(u)? },
104        ["hold", "release", u, rest @ ..] => {
105            // §12d: a release is never forever; without --until it lasts
106            // until the next boot.
107            let until = match rest {
108                [] => Until::NextBoot,
109                ["--until", "next-boot"] => Until::NextBoot,
110                ["--until", m] if m.ends_with('m') => {
111                    let n: u64 = m.trim_end_matches('m').parse().map_err(|_| "--until takes next-boot or <N>m")?;
112                    Until::At(planarian_schema::Timestamp(now_ms() + n * 60_000))
113                }
114                _ => return Err("hold release <unit> [--until next-boot|<N>m]".into()),
115            };
116            Request::HoldRelease { unit: unit(u)?, until }
117        }
118        ["gc"] => Request::Gc,
119        ["undo"] => Request::Undo,
120        ["diagnose", u] => Request::Diagnose { unit: unit(u)?, probe: None },
121        ["diagnose", u, p] => {
122            Request::Diagnose { unit: unit(u)?, probe: Some(planarian_schema::ProbeId(p.to_string())) }
123        }
124        // Listing is read-only: what waits for approval, from the status.
125        ["approve"] => Request::Status,
126        ["approve", h] => {
127            if !std::io::stdin().is_terminal() {
128                // The daemon decides (from the process, not from us); this
129                // only says why it will refuse.
130                eprintln!("note: approval needs an interactive terminal; this one has none");
131            }
132            Request::Approve { hash: Hash::from_hex(h).ok_or("approve takes the 64-character hash shown by status")? }
133        }
134        ["cancel", t] => Request::Cancel { txn: TxnId(t.parse().map_err(|_| "cancel takes a transaction number")?) },
135        ["resolve", t, how] => Request::Resolve {
136            txn: TxnId(t.parse().map_err(|_| "resolve takes a transaction number")?),
137            resolution: match *how {
138                "--as-committed" => Resolution::AsCommitted,
139                "--as-reverted" => Resolution::AsReverted,
140                "--manual-repaired" => Resolution::ManualRepaired,
141                _ => return Err("resolve needs --as-committed, --as-reverted or --manual-repaired".into()),
142            },
143        },
144        ["agent", "run", name, "--", argv @ ..] if !argv.is_empty() => {
145            Request::AgentRun { name: name.to_string(), argv: argv.iter().map(|s| s.to_string()).collect() }
146        }
147        ["report", "boot"] => Request::ReportBoot,
148        ["posture"] => Request::Posture,
149        ["ack"] => Request::Acknowledge { unit: None },
150        ["ack", u] => Request::Acknowledge { unit: Some(unit(u)?) },
151        ["mark-security-floor"] => Request::MarkSecurityFloor,
152        ["mark-good"] => Request::MarkGood { accept_unconfirmed: false },
153        ["mark-good", "--accept-unconfirmed"] => Request::MarkGood { accept_unconfirmed: true },
154        _ => return Err(USAGE.into()),
155    };
156    Ok((req, json))
157}
158
159fn send(req: &Request) -> Result<Response, String> {
160    let path = std::env::var("PLANARIAN_SOCKET").unwrap_or_else(|_| SOCKET.into());
161    let mut s = UnixStream::connect(&path).map_err(|e| format!("cannot reach planariad at {path}: {e}"))?;
162    // Long work runs off the core thread; the slowest answer is a diagnosis
163    // probe with a long timeout. Ten minutes without one means planariad is
164    // stuck: say so rather than hang.
165    s.set_read_timeout(Some(std::time::Duration::from_secs(600))).map_err(|e| e.to_string())?;
166    s.write_all(req.encode().as_bytes()).map_err(|e| e.to_string())?;
167    let mut line = String::new();
168    BufReader::new(&s).read_line(&mut line).map_err(|e| e.to_string())?;
169    serde_json::from_str(&line).map_err(|e| format!("bad response: {e}"))
170}
171
172fn symbol(s: &UnitStatus, expected: bool) -> &'static str {
173    match s {
174        UnitStatus::Running => "ok",
175        _ if !expected => "  ",
176        UnitStatus::Overridden(_) => "--",
177        UnitStatus::Unknown | UnitStatus::Transitioning => "??",
178        UnitStatus::Bound => "ok",
179        _ => "!!",
180    }
181}
182
183fn print_status(r: &StatusReport) {
184    println!(
185        "generation {}   known-good {}   boot default {}",
186        r.generation.0,
187        r.known_good.map(|g| g.0.to_string()).unwrap_or("-".into()),
188        r.boot_default.map(|g| g.0.to_string()).unwrap_or("-".into())
189    );
190    if let Some(n) = &r.baseline_note {
191        println!("note: {n}");
192    }
193    for (t, why) in &r.interlock {
194        println!(
195            "\nRECOVERY FAILED (transaction {}): {why}\n  autonomous repair is stopped; after fixing it: planarian resolve {} --manual-repaired",
196            t.0, t.0
197        );
198    }
199    println!();
200    for u in &r.units {
201        let st = if u.expected || matches!(u.status, UnitStatus::Overridden(_)) {
202            format!("{:?}", u.status)
203        } else {
204            "not expected now".into()
205        };
206        println!("  {} {:<36} {}", symbol(&u.status, u.expected), u.unit.to_string(), st);
207    }
208    if !r.incidents.is_empty() {
209        println!("\nincidents:");
210        for i in &r.incidents {
211            let state = match &i.state {
212                IncidentState::Cooldown { .. } => "cooling down".to_string(),
213                IncidentState::Escalated { .. } => "needs a person".to_string(),
214                s => format!("{s:?}"),
215            };
216            println!("  #{} {} — {:?}, {} attempt(s), {state}", i.id.0, i.target, i.divergence.1, i.attempts.len());
217        }
218    }
219    if !r.pending.is_empty() {
220        println!("\nwaiting for approval (as root: planarian approve <hash>):");
221        for p in &r.pending {
222            println!("  transaction {}: {}\n    {}", p.txn.0, p.what, p.approve);
223            print_diff(&p.diff, "    ");
224        }
225    }
226    for (t, s, what) in &r.in_flight {
227        match planarian_ipc::waiting_words(s) {
228            Some(w) => println!("\n{w}: transaction {}: {what}", t.0),
229            None => println!("\nin progress: transaction {} ({s:?}): {what}", t.0),
230        }
231    }
232    if let Some(sp) = &r.space {
233        let gb = |n: u64| n as f64 / 1e9;
234        println!(
235            "\nspace: {} generation(s), {:.1} GB of a {:.1} GB budget (last collection removed {})",
236            sp.generations,
237            gb(sp.bytes),
238            gb(sp.max_bytes),
239            sp.collected
240        );
241        if let Some(short) = sp.shortfall {
242            println!(
243                "  over budget by {:.1} GB: the generations Planarian must keep are larger than the budget, so it is a target, not a guarantee",
244                gb(short)
245            );
246        }
247    }
248    if let Some(rec) = &r.reconciliation
249        && !rec.is_empty()
250    {
251        println!("\nafter the last restart:");
252        for a in rec {
253            println!("  {a}");
254        }
255    }
256    if !r.inferred.is_empty() {
257        println!("\ntaken as intended (stopped outside Planarian; `planarian ack` once seen):");
258        for (u, why) in &r.inferred {
259            println!("  {u}: {why}");
260        }
261    }
262    if !r.notices.is_empty() {
263        println!("\nrecent:");
264        for n in r.notices.iter().rev().take(8).rev() {
265            println!("  {n}");
266        }
267    }
268}
269
270/// Print a response; returns the exit code.
271fn print(resp: &Response, json: bool) -> i32 {
272    if json {
273        println!("{}", serde_json::to_string_pretty(resp).unwrap_or_default());
274        return 0;
275    }
276    match resp {
277        Response::Status(r) => print_status(r),
278        Response::Console(r) => print_status(&r.status),
279        Response::Log { lines } => {
280            for l in lines {
281                println!("{:>6}  {}", l.seq, l.text);
282            }
283        }
284        Response::Incidents { incidents } if incidents.is_empty() => println!("no incidents"),
285        Response::Incidents { incidents } => {
286            for i in incidents {
287                println!(
288                    "#{} {} {:?} {:?} attempts={} budget={}",
289                    i.id.0,
290                    i.target,
291                    i.divergence.1,
292                    i.state,
293                    i.attempts.len(),
294                    i.budget_left
295                );
296            }
297        }
298        Response::Done { txn, message, .. } => println!("transaction {}: {message}", txn.0),
299        Response::NeedsApproval { txn, approve, why, .. } => {
300            println!(
301                "transaction {} needs approval because {why}.\nTo approve, as root at a terminal:\n  planarian approve {approve}",
302                txn.0
303            )
304        }
305        Response::Rejected { reason, message } => {
306            eprintln!("refused ({reason}): {message}");
307            return 1;
308        }
309        Response::Confirm { choices } => {
310            println!("Did you mean:");
311            for c in choices {
312                println!("  {}. {}", c.index + 1, c.understood);
313            }
314        }
315        Response::Unmatched { message } => {
316            println!("{message}");
317            return 1;
318        }
319        Response::Ok { message } => println!("{message}"),
320        // Only the gym's forced compaction asks for this (gym hooks).
321        Response::Compacted { entries } => println!("compacted ({} entries)", entries.len()),
322        Response::Error { message } => {
323            eprintln!("error: {message}");
324            return 1;
325        }
326    }
327    0
328}
329
330/// §16 *Approvals show the change*: each pending change with the diff the
331/// core computed for it.
332fn print_pending(pending: &[planarian_ipc::Pending], json: bool) {
333    if json {
334        println!("{}", serde_json::to_string_pretty(pending).unwrap_or_default());
335        return;
336    }
337    if pending.is_empty() {
338        println!("nothing waits for approval");
339        return;
340    }
341    for p in pending {
342        println!(
343            "transaction {}: {}\n  approve (as root, at a terminal): planarian approve {}",
344            p.txn.0, p.what, p.approve
345        );
346        print_diff(&p.diff, "  ");
347    }
348}
349
350fn print_diff(d: &planarian_ipc::ApprovalDiff, indent: &str) {
351    if d.is_empty() {
352        return;
353    }
354    println!("{indent}it would change:");
355    for l in d.lines() {
356        println!("{indent}  {l}");
357    }
358}
359
360fn run(args: Vec<String>) -> i32 {
361    let (req, json) = match parse(&args) {
362        Ok(r) => r,
363        Err(e) => {
364            eprintln!("{e}");
365            return 2;
366        }
367    };
368    let words: Vec<&str> = args.iter().map(String::as_str).filter(|a| *a != "--json").collect();
369    if words == ["approve"] {
370        return match send(&Request::Status) {
371            Ok(Response::Status(r)) => {
372                print_pending(&r.pending, json);
373                0
374            }
375            Ok(other) => print(&other, json),
376            Err(e) => {
377                eprintln!("{e}");
378                69
379            }
380        };
381    }
382    // `approve <hash>`: what it changes, above the result.
383    let shown = match &req {
384        Request::Approve { hash } => match send(&Request::Status) {
385            Ok(Response::Status(r)) => r.pending.into_iter().find(|p| p.approve == *hash),
386            _ => None,
387        },
388        _ => None,
389    };
390    if let (Some(p), false) = (&shown, json) {
391        println!("transaction {}: {}", p.txn.0, p.what);
392        print_diff(&p.diff, "");
393    }
394    let resp = match send(&req) {
395        Ok(r) => r,
396        Err(e) => {
397            eprintln!("{e}");
398            return 69;
399        }
400    };
401    // `ask` with two candidates: let the user pick at a terminal.
402    if let (Response::Confirm { choices }, Request::Ask { phrase, .. }) = (&resp, &req) {
403        print(&resp, false);
404        if !std::io::stdin().is_terminal() {
405            return 1;
406        }
407        print!("Choose 1–{} (anything else cancels): ", choices.len());
408        let _ = std::io::stdout().flush();
409        let mut line = String::new();
410        let _ = std::io::stdin().read_line(&mut line);
411        let Ok(n) = line.trim().parse::<usize>() else { return 1 };
412        if n == 0 || n > choices.len() {
413            return 1;
414        }
415        return match send(&Request::Ask { phrase: phrase.clone(), choice: Some(n - 1) }) {
416            Ok(r) => print(&r, json),
417            Err(e) => {
418                eprintln!("{e}");
419                69
420            }
421        };
422    }
423    if let (Some(p), true) = (&shown, json) {
424        let v = serde_json::json!({ "pending": p, "response": resp });
425        println!("{}", serde_json::to_string_pretty(&v).unwrap_or_default());
426        return if matches!(resp, Response::Error { .. } | Response::Rejected { .. }) { 1 } else { 0 };
427    }
428    print(&resp, json)
429}
430
431fn main() {
432    let mut args: Vec<String> = std::env::args().collect();
433    let argv0 = args.remove(0);
434    let invoked_as_ask = std::path::Path::new(&argv0).file_name().is_some_and(|n| n == "ask");
435    if args.first().is_some_and(|a| a == "--help" || a == "-h") {
436        println!("{USAGE}");
437        return;
438    }
439    if invoked_as_ask {
440        args.insert(0, "ask".into());
441    }
442    std::process::exit(run(args));
443}
444
445#[cfg(test)]
446mod tests {
447    use super::*;
448
449    fn p(s: &str) -> Request {
450        parse(&s.split_whitespace().map(String::from).collect::<Vec<_>>()).unwrap().0
451    }
452
453    #[test]
454    fn parses_commands() {
455        assert_eq!(p("status"), Request::Status);
456        assert_eq!(p("stop sshd"), Request::Stop { unit: UnitId::service("sshd") });
457        assert_eq!(
458            p("release service@1000:pipewire"),
459            Request::LiftOverride { unit: "service@1000:pipewire".parse().unwrap() }
460        );
461        assert_eq!(p("ask reconnect wifi"), Request::Ask { phrase: "reconnect wifi".into(), choice: None });
462        match p("apply font-scale scale=1.25") {
463            Request::Apply { change, args } => {
464                assert_eq!(change.0, "font-scale");
465                assert_eq!(args["scale"], Value::Str("1.25".into()));
466            }
467            r => panic!("{r:?}"),
468        }
469        assert!(matches!(
470            p("resolve 4 --manual-repaired"),
471            Request::Resolve { resolution: Resolution::ManualRepaired, .. }
472        ));
473        assert!(matches!(p("override backup --until 20m"), Request::SetOverride { until: Until::At(_), .. }));
474        assert!(parse(&["bogus".into()]).is_err());
475        // Listing what waits for approval reads the status; approving does not.
476        assert_eq!(p("approve"), Request::Status);
477        assert!(matches!(p(&format!("approve {}", "ab".repeat(32))), Request::Approve { .. }));
478    }
479}