Skip to main content

planarian_ipc/
lib.rs

1//! The daemon's Unix-socket protocol (`/run/planarian/planariad.sock`).
2//!
3//! One newline-terminated JSON `Request` per connection, answered by one
4//! JSON `Response`. The caller's identity is never part of the request: the
5//! daemon takes it from `SO_PEERCRED` (§11).
6
7use 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 "<phrase>"`; `choice` picks one of a previous `Confirm`'s options.
29    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    /// §12d `planarian hold release <unit> [--until]`: lift a network
47    /// hold, as an attested override on its hold unit. Administrator
48    /// authority; a non-administrator's release asks an administrator.
49    HoldRelease {
50        unit: UnitId,
51        until: Until,
52    },
53    /// A `StopUnit` from the CLI; rewritten to `StopAndSuppress { NextBoot }` (§14).
54    Stop {
55        unit: UnitId,
56    },
57    Restart {
58        unit: UnitId,
59    },
60    Gc,
61    Undo,
62    /// Approve a pending change. Phase A: root at an interactive terminal,
63    /// the terminal established by the daemon from the peer's pidfd (§11,
64    /// B-3); the request carries nothing about it.
65    Approve {
66        hash: Hash,
67    },
68    Cancel {
69        txn: TxnId,
70    },
71    Resolve {
72        txn: TxnId,
73        resolution: Resolution,
74    },
75    /// `planarian mark-security-floor`: the running generation becomes the
76    /// security floor (§12e). Administrator only.
77    MarkSecurityFloor,
78    MarkGood {
79        accept_unconfirmed: bool,
80    },
81    Diagnose {
82        unit: UnitId,
83        probe: Option<ProbeId>,
84    },
85    /// Everything the read-only console shows (§16a), in one answer.
86    Console,
87    /// `planarian report boot` (§16): the last boot report.
88    ReportBoot,
89    /// `planarian posture` (§16, §12d): the posture and timeout in force,
90    /// and whether a weaker declaration waits for the next boot.
91    Posture,
92    /// The gym's VM harness: submit `transition` as a proposal from the
93    /// caller, optionally with a stale digest or a narrowed `may_change`.
94    /// Answered only by a daemon built with `gym-hooks`.
95    GymPropose {
96        transition: Transition,
97        stale_digest: bool,
98        may_change: Option<Vec<UnitId>>,
99    },
100    /// The gym's VM harness: compact the journal now. `gym-hooks` only.
101    GymCompact,
102    /// `planarian agent run <name> -- <argv>` (§11, §16): start an agent as
103    /// its own principal, in the transient unit `planarian-agent-<name>`
104    /// with a dynamic uid. It is granted nothing.
105    AgentRun {
106        name: String,
107        argv: Vec<String>,
108    },
109    /// `planarian ack [<unit>]`: acknowledge inferred overrides (all, or one
110    /// unit's), so they stop being listed (§11, B-11). They still hold.
111    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    /// §16 *Approvals show the change*: what a generation transition would
132    /// apply, computed by the core from the evaluated generations (never
133    /// from the proposal). Empty for a transition that is not one.
134    #[serde(default)]
135    pub diff: ApprovalDiff,
136}
137
138/// §16 (owner decision, Nov 2): the configuration a generation transition
139/// that asks would apply, as a person sees it in `planarian approve`, the
140/// console and the notice, and as an agent reads it (`approve --json`).
141#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
142pub struct ApprovalDiff {
143    /// Every option added, removed or changed; Security- and
144    /// External-sensitive ones first, then by path.
145    pub options: Vec<OptionChange>,
146    /// The units it would remove or stop, with their sensitivity; Security-
147    /// and External-sensitive ones first.
148    pub removes: Vec<RemovedUnit>,
149    /// Said when the core could not read the evaluated generations: the
150    /// options shown are the requested settings only, not the whole change.
151    #[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    /// The sensitivity by name ("Security", "External", ...), as shown.
160    pub kinds: Vec<String>,
161}
162
163#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
164pub struct OptionChange {
165    pub path: String,
166    /// `None`: the option is added (`old`) or removed (`new`).
167    pub old: Option<Value>,
168    pub new: Option<Value>,
169    pub sensitivity: planarian_schema::Sensitivities,
170    /// The sensitivity by name ("Security", "External", ...), as shown.
171    pub kinds: Vec<String>,
172}
173
174/// A sensitivity by name, most significant first; "Routine" when it is
175/// nothing else.
176pub 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    /// The full diff, one line each, for `planarian approve` and the console.
197    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    /// One line for the notice: how much it changes, the sensitive options
206    /// spelled out, and the units it removes or stops.
207    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    /// "no baseline yet" until initial enrollment (§16).
267    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    /// The last garbage collection's result (§12b).
276    #[serde(default)]
277    pub space: Option<planarian_schema::gc::SpaceReport>,
278    /// Inferred overrides no user has acknowledged yet (§11, B-11): stops
279    /// Planarian took as someone's intent. Listed until `planarian ack`.
280    #[serde(default)]
281    pub inferred: Vec<(UnitId, String)>,
282}
283
284/// One node of the console's Graph view: what is desired, what is observed,
285/// and how healthy it is, kept apart (three indicators, not one colour).
286#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
287pub struct GraphNode {
288    pub unit: UnitId,
289    /// "Running", "Stopped", … and whether its condition holds now.
290    pub desired: String,
291    pub expected: bool,
292    /// The supervisor's view: "Active", "Failed: …", "Unknown: …".
293    pub observed: String,
294    pub health: UnitStatus,
295    pub suppressed: bool,
296    /// Its readiness evidence is stale or unknown.
297    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/// One transaction in the console's Timeline view.
308#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
309pub struct TxnSummary {
310    pub txn: TxnId,
311    pub what: String,
312    /// Journal states, in order.
313    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    /// Newest first.
326    pub timeline: Vec<TxnSummary>,
327    pub previous: Option<GenerationId>,
328    pub last_booted: Option<GenerationId>,
329    /// Generations an in-flight transaction is building or activating.
330    pub candidates: Vec<GenerationId>,
331    /// The most recent generation change was reverted.
332    pub last_change_reverted: bool,
333    /// The daemon's clock when it answered.
334    #[serde(default)]
335    pub now: Timestamp,
336}
337
338impl ConsoleReport {
339    /// §16a: something needs a person: an open incident, a pending approval,
340    /// the recovery interlock, a reconciliation record, or a reverted change.
341    /// The console starts visible, and the tray turns amber or red.
342    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
352/// A transaction that is parked by design, in words (§12f, I12): never shown
353/// as work in progress or as a failure.
354pub 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
362/// A unit status in words.
363pub 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    /// A transaction ran to a terminal state.
417    Done {
418        txn: TxnId,
419        state: TxnState,
420        message: String,
421    },
422    /// AskFirst: waiting for `planarian approve <approve>` (as root).
423    NeedsApproval {
424        txn: TxnId,
425        approve: Hash,
426        what: String,
427        why: String,
428    },
429    Rejected {
430        reason: String,
431        message: String,
432    },
433    /// The phrase matched more than one thing; ask again with `choice`.
434    Confirm {
435        choices: Vec<Choice>,
436    },
437    Unmatched {
438        message: String,
439    },
440    Ok {
441        message: String,
442    },
443    /// `GymCompact` (gym hooks): compacted, and the entries the compaction
444    /// discarded (the segment it checkpointed), taken in the same call, so
445    /// nothing journaled meanwhile is missing from them. As JSON, since the
446    /// journal's `Entry` lives in txn, which ipc does not depend on.
447    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}