1use planarian_schema::{
8 ChangeId, DerivedFacts, EdgeKind, GenerationId, Hash, Incident, IncidentState, OverrideDesired, ProbeId,
9 Resolution, Timestamp, Transition, TxnId, TxnState, UnitId, UnitStatus, Until, Value,
10};
11use serde::{Deserialize, Serialize};
12use std::collections::BTreeMap;
13
14pub const SOCKET: &str = "/run/planarian/planariad.sock";
15
16fn leave_alone() -> OverrideDesired {
17 OverrideDesired::LeaveAlone
18}
19
20#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
21#[serde(tag = "op", rename_all = "kebab-case")]
22pub enum Request {
23 Status,
24 Log {
25 limit: usize,
26 },
27 Incidents,
28 Ask {
30 phrase: String,
31 choice: Option<usize>,
32 },
33 Apply {
34 change: ChangeId,
35 args: BTreeMap<String, Value>,
36 },
37 SetOverride {
38 unit: UnitId,
39 until: Until,
40 #[serde(default = "leave_alone")]
41 desired: OverrideDesired,
42 },
43 LiftOverride {
44 unit: UnitId,
45 },
46 HoldRelease {
50 unit: UnitId,
51 until: Until,
52 },
53 Stop {
55 unit: UnitId,
56 },
57 Restart {
58 unit: UnitId,
59 },
60 Gc,
61 Undo,
62 Approve {
66 hash: Hash,
67 },
68 Cancel {
69 txn: TxnId,
70 },
71 Resolve {
72 txn: TxnId,
73 resolution: Resolution,
74 },
75 MarkSecurityFloor,
78 MarkGood {
79 accept_unconfirmed: bool,
80 },
81 Diagnose {
82 unit: UnitId,
83 probe: Option<ProbeId>,
84 },
85 Console,
87 ReportBoot,
89 Posture,
92 GymPropose {
96 transition: Transition,
97 stale_digest: bool,
98 may_change: Option<Vec<UnitId>>,
99 },
100 GymCompact,
102 AgentRun {
106 name: String,
107 argv: Vec<String>,
108 },
109 Acknowledge {
112 unit: Option<UnitId>,
113 },
114}
115
116#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
117pub struct UnitRow {
118 pub unit: UnitId,
119 pub status: UnitStatus,
120 pub expected: bool,
121 pub detail: String,
122}
123
124#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
125pub struct Pending {
126 pub txn: TxnId,
127 pub approve: Hash,
128 pub what: String,
129 pub facts: DerivedFacts,
130 pub issued: Timestamp,
131 #[serde(default)]
135 pub diff: ApprovalDiff,
136}
137
138#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct ApprovalDiff {
143 pub options: Vec<OptionChange>,
146 pub removes: Vec<RemovedUnit>,
149 #[serde(default)]
152 pub note: Option<String>,
153}
154
155#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
156pub struct RemovedUnit {
157 pub unit: UnitId,
158 pub sensitivity: planarian_schema::Sensitivities,
159 pub kinds: Vec<String>,
161}
162
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct OptionChange {
165 pub path: String,
166 pub old: Option<Value>,
168 pub new: Option<Value>,
169 pub sensitivity: planarian_schema::Sensitivities,
170 pub kinds: Vec<String>,
172}
173
174pub fn sensitivity_kinds(s: &planarian_schema::Sensitivities) -> Vec<String> {
177 use planarian_schema::Sensitivity;
178 let k: Vec<String> = [
179 (Sensitivity::Security, "Security"),
180 (Sensitivity::External, "External"),
181 (Sensitivity::UserData, "UserData"),
182 (Sensitivity::Core, "Core"),
183 ]
184 .into_iter()
185 .filter(|(x, _)| s.contains(*x))
186 .map(|(_, n)| n.to_string())
187 .collect();
188 if k.is_empty() { vec!["Routine".into()] } else { k }
189}
190
191impl ApprovalDiff {
192 pub fn is_empty(&self) -> bool {
193 self.options.is_empty() && self.removes.is_empty() && self.note.is_none()
194 }
195
196 pub fn lines(&self) -> Vec<String> {
198 let d = self;
199 let mut out: Vec<String> = d.note.iter().cloned().collect();
200 out.extend(d.options.iter().map(|o| format!("{} [{}]", change(o), o.kinds.join(", "))));
201 out.extend(d.removes.iter().map(|r| format!("removes or stops {} [{}]", r.unit, r.kinds.join(", "))));
202 out
203 }
204
205 pub fn summary(&self) -> Option<String> {
208 let d = self;
209 if d.is_empty() {
210 return None;
211 }
212 let mut parts: Vec<String> = d.note.iter().cloned().collect();
213 if !d.options.is_empty() {
214 let sensitive: Vec<String> = d.options.iter().filter(|o| first(&o.sensitivity)).map(change).collect();
215 let n = d.options.len();
216 parts.push(if sensitive.is_empty() {
217 format!("changes {n} option{}", if n == 1 { "" } else { "s" })
218 } else {
219 format!(
220 "changes {n} option{} ({} Security/External: {})",
221 if n == 1 { "" } else { "s" },
222 sensitive.len(),
223 sensitive.join("; ")
224 )
225 });
226 }
227 if !d.removes.is_empty() {
228 let units: Vec<String> = d.removes.iter().map(|r| r.unit.to_string()).collect();
229 parts.push(format!("removes or stops {}", units.join(", ")));
230 }
231 Some(parts.join("; "))
232 }
233}
234
235fn first(s: &planarian_schema::Sensitivities) -> bool {
236 use planarian_schema::Sensitivity;
237 s.contains(Sensitivity::Security) || s.contains(Sensitivity::External)
238}
239
240fn value(v: &Value) -> String {
241 match v {
242 Value::Bool(b) => b.to_string(),
243 Value::Int(i) => i.to_string(),
244 Value::Str(s) => s.clone(),
245 Value::List(l) => format!("[{}]", l.iter().map(value).collect::<Vec<_>>().join(", ")),
246 }
247}
248
249fn change(o: &OptionChange) -> String {
250 let v = |x: &Option<Value>| x.as_ref().map(value);
251 match (v(&o.old), v(&o.new)) {
252 (None, Some(n)) => format!("{} added: {n}", o.path),
253 (Some(old), None) => format!("{} removed (was {old})", o.path),
254 (Some(old), Some(n)) => format!("{}: {old} → {n}", o.path),
255 (None, None) => o.path.clone(),
256 }
257}
258
259#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
260pub struct StatusReport {
261 pub generation: GenerationId,
262 pub known_good: Option<GenerationId>,
263 pub live_tested: Option<GenerationId>,
264 pub accepted_baseline: Option<GenerationId>,
265 pub boot_default: Option<GenerationId>,
266 pub baseline_note: Option<String>,
268 pub units: Vec<UnitRow>,
269 pub incidents: Vec<Incident>,
270 pub pending: Vec<Pending>,
271 pub in_flight: Vec<(TxnId, TxnState, String)>,
272 pub interlock: Vec<(TxnId, String)>,
273 pub reconciliation: Option<Vec<String>>,
274 pub notices: Vec<String>,
275 #[serde(default)]
277 pub space: Option<planarian_schema::gc::SpaceReport>,
278 #[serde(default)]
281 pub inferred: Vec<(UnitId, String)>,
282}
283
284#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
287pub struct GraphNode {
288 pub unit: UnitId,
289 pub desired: String,
291 pub expected: bool,
292 pub observed: String,
294 pub health: UnitStatus,
295 pub suppressed: bool,
296 pub stale: bool,
298}
299
300#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
301pub struct GraphEdge {
302 pub from: UnitId,
303 pub to: UnitId,
304 pub kind: EdgeKind,
305}
306
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct TxnSummary {
310 pub txn: TxnId,
311 pub what: String,
312 pub path: Vec<String>,
314 pub state: TxnState,
315 pub live: bool,
316 pub by_watchdog: bool,
317 pub why: String,
318}
319
320#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
321pub struct ConsoleReport {
322 pub status: StatusReport,
323 pub nodes: Vec<GraphNode>,
324 pub edges: Vec<GraphEdge>,
325 pub timeline: Vec<TxnSummary>,
327 pub previous: Option<GenerationId>,
328 pub last_booted: Option<GenerationId>,
329 pub candidates: Vec<GenerationId>,
331 pub last_change_reverted: bool,
333 #[serde(default)]
335 pub now: Timestamp,
336}
337
338impl ConsoleReport {
339 pub fn needs_attention(&self) -> bool {
343 let s = &self.status;
344 s.incidents.iter().any(|i| i.state != IncidentState::Closed)
345 || !s.pending.is_empty()
346 || !s.interlock.is_empty()
347 || s.reconciliation.as_ref().is_some_and(|a| !a.is_empty())
348 || self.last_change_reverted
349 }
350}
351
352pub fn waiting_words(s: &TxnState) -> Option<&'static str> {
355 match s {
356 TxnState::AwaitingBoot => Some("waiting for a restart"),
357 TxnState::Built => Some("waiting for a quiet moment"),
358 _ => None,
359 }
360}
361
362pub fn status_words(s: &UnitStatus) -> String {
364 use UnitStatus as S;
365 use planarian_schema::Reason;
366 match s {
367 S::Running => "running".into(),
368 S::Degraded(probes) => {
369 let names: Vec<&str> = probes.iter().map(|p| p.0.as_str()).collect();
370 format!("running, but failing its health check ({})", names.join(", "))
371 }
372 S::Stopped => "stopped".into(),
373 S::Overridden(d) => match d {
374 planarian_schema::OverrideDesired::LeaveAlone => "left alone on request".into(),
375 planarian_schema::OverrideDesired::Stopped => "stopped on request".into(),
376 planarian_schema::OverrideDesired::Absent => "disabled on request".into(),
377 planarian_schema::OverrideDesired::Detected => "detached on request".into(),
378 },
379 S::Unknown => "unknown (no fresh evidence)".into(),
380 S::Failed(Reason::ExitCode(c)) => format!("failed (exit code {c})"),
381 S::Failed(Reason::DriverError(e)) => format!("failed (driver: {e})"),
382 S::Failed(Reason::Backend(e)) => format!("failed ({e})"),
383 S::Absent => "absent".into(),
384 S::Detected => "detected, no driver bound".into(),
385 S::Bound => "bound to its driver".into(),
386 S::Transitioning => "starting or stopping".into(),
387 S::NotExpected => "not expected now".into(),
388 }
389}
390
391#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
392pub struct LogLine {
393 pub seq: u64,
394 pub at: Timestamp,
395 pub text: String,
396}
397
398#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
399pub struct Choice {
400 pub index: usize,
401 pub understood: String,
402 pub confidence: u16,
403}
404
405#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
406#[serde(tag = "kind", rename_all = "kebab-case")]
407pub enum Response {
408 Status(Box<StatusReport>),
409 Console(Box<ConsoleReport>),
410 Log {
411 lines: Vec<LogLine>,
412 },
413 Incidents {
414 incidents: Vec<Incident>,
415 },
416 Done {
418 txn: TxnId,
419 state: TxnState,
420 message: String,
421 },
422 NeedsApproval {
424 txn: TxnId,
425 approve: Hash,
426 what: String,
427 why: String,
428 },
429 Rejected {
430 reason: String,
431 message: String,
432 },
433 Confirm {
435 choices: Vec<Choice>,
436 },
437 Unmatched {
438 message: String,
439 },
440 Ok {
441 message: String,
442 },
443 Compacted {
448 entries: Vec<serde_json::Value>,
449 },
450 Error {
451 message: String,
452 },
453}
454
455impl Request {
456 pub fn encode(&self) -> String {
457 let mut s = serde_json::to_string(self).expect("request serializes");
458 s.push('\n');
459 s
460 }
461}
462
463impl Response {
464 pub fn encode(&self) -> String {
465 let mut s = serde_json::to_string(self).expect("response serializes");
466 s.push('\n');
467 s
468 }
469}
470
471#[cfg(test)]
472mod tests {
473 use super::*;
474
475 #[test]
476 fn roundtrip() {
477 let r = Request::SetOverride {
478 unit: "service:gym-web".parse().unwrap(),
479 until: Until::Explicit,
480 desired: planarian_schema::OverrideDesired::LeaveAlone,
481 };
482 let s = r.encode();
483 assert!(s.contains("\"op\":\"set-override\""), "{s}");
484 assert_eq!(serde_json::from_str::<Request>(&s).unwrap(), r);
485 let a = Request::Ask { phrase: "restart wifi".into(), choice: None };
486 assert_eq!(serde_json::from_str::<Request>(&a.encode()).unwrap(), a);
487 }
488}