1use 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 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 ["approve"] => Request::Status,
126 ["approve", h] => {
127 if !std::io::stdin().is_terminal() {
128 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 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
270fn 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 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
330fn 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 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 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 assert_eq!(p("approve"), Request::Status);
477 assert!(matches!(p(&format!("approve {}", "ab".repeat(32))), Request::Approve { .. }));
478 }
479}