Skip to main content

planarian_schema/
backend.rs

1//! Backend contracts (§8). Generic parameters only; no `dyn` (§6).
2//!
3//! Every mutating call takes the `OpId` that was durably journaled before it
4//! (§12). The OpId carries `(txn, epoch)`: the backend refuses the call with
5//! `Outcome::fenced()` if that epoch is not the journal's current epoch for the
6//! transaction, and tags the work it starts with the OpId so `jobs_tagged` can
7//! find it later. This replaces the `&Lease` parameter in §8.
8
9use crate::ids::{GenerationId, Hash, JobId, Millis, OpId, SnapshotId, StorePath, Timestamp};
10use crate::state::{Observed, Resources};
11use crate::transition::ActivationPlan;
12use crate::transition::{DriverRef, EvaluatedGeneration, Value};
13use crate::txn::Outcome;
14use crate::unit::{ProbeDef, ProbeResult, UnitId};
15use serde::{Deserialize, Serialize};
16use std::collections::{BTreeMap, BTreeSet};
17
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub enum SourceKind {
20    Git,
21    Directory,
22}
23
24/// The owner's configuration as Nix will see it.
25#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
26pub struct PinnedSource {
27    pub root: String,
28    pub kind: SourceKind,
29}
30
31/// Flat option assignments for `planarian/overrides.nix` (wrapped in `lib.mkOverride 90`).
32#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
33pub struct OverrideSet {
34    pub assignments: BTreeMap<String, Value>,
35}
36
37/// An immutable staged copy (§9 step 1).
38#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
39pub struct StagedSource {
40    pub dir: String,
41    pub base_source: Hash,
42    pub staged_source: Hash,
43    pub base_override: Hash,
44    pub candidate_override: Hash,
45    /// Untracked files that were excluded (reported, §9 step 1).
46    pub excluded: Vec<String>,
47}
48
49#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
50pub struct DeviceInfo {
51    pub id: UnitId,
52    pub driver: Option<DriverRef>,
53}
54
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56pub struct Built {
57    pub generation: GenerationId,
58    pub path: StorePath,
59    /// The GC root `build` created for its own output (§9 step 5).
60    pub root: String,
61    /// What switching to it would start, stop, restart and reload (dry-activate).
62    pub activation: ActivationPlan,
63    /// Its NAR hash, as the store reported it at build time (C6).
64    #[serde(default)]
65    pub nar: Option<Hash>,
66}
67
68/// How a unit's main process last ended, as the supervisor reports it.
69#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
70pub enum Ending {
71    /// It exited with this status.
72    Exited(i32),
73    /// A signal ended it (`systemctl stop` sends SIGTERM).
74    Signal(i32),
75    /// The kernel's OOM killer ended it (`Result=oom-kill`).
76    OomKill,
77}
78
79impl Ending {
80    /// §11 residue: a termination signal, which a person (or a stop job) sends,
81    /// as opposed to a crash signal or an exit status. Owner decision Q5.
82    pub fn is_termination(&self) -> bool {
83        matches!(self, Ending::Signal(1 | 2 | 9 | 15))
84    }
85}
86
87pub trait UnitBackend {
88    fn observe(&self, id: &UnitId) -> Result<Observed, Outcome>;
89    /// How the unit's main process last ended; None if the backend cannot say.
90    fn ended_by(&self, _id: &UnitId) -> Option<Ending> {
91        None
92    }
93    /// How many times systemd itself restarted the unit (`NRestarts`); None
94    /// if the backend cannot say.
95    fn auto_restarts(&self, _id: &UnitId) -> Option<u32> {
96        None
97    }
98    /// The boot report's unit check (§10): active units whose definition does
99    /// not come from the running generation (a file outside the store that is
100    /// not a generator's).
101    fn foreign_units(&self) -> Vec<String> {
102        vec![]
103    }
104    /// §12d "Which units": which of the network tools `tools` are protecting
105    /// traffic now, from netwatch's kernel-attributed footprints, with every
106    /// unknown counted as protecting (fail closed). `Err` means netwatch could
107    /// not be read at all; a caller counts every tool as protecting then. The
108    /// default is that error: a backend without netwatch never waives a hold.
109    fn protecting(&self, _tools: &[UnitId]) -> Result<BTreeSet<UnitId>, Outcome> {
110        Err(Outcome::Unknown("no netwatch".into()))
111    }
112    fn list(&self) -> Result<Vec<UnitId>, Outcome>;
113    /// Start an agent (§11, §16): the transient unit `planarian-agent-<name>`,
114    /// under a dynamic uid of its own. Returns the started unit's instance
115    /// (its InvocationID), read back once it has started (I24).
116    fn start_agent(&self, _name: &str, _argv: &[String], _limits: &AgentLimits) -> Result<InstanceId, Outcome> {
117        Err(Outcome::Failed("this backend cannot start agents".into()))
118    }
119    /// Whether an agent's unit under this name is loaded and running (I24:
120    /// `agent run` refuses a name in use).
121    fn agent_running(&self, _name: &str) -> bool {
122        false
123    }
124    fn restart(&self, id: &UnitId, op: &OpId) -> Outcome;
125    fn stop(&self, id: &UnitId, op: &OpId) -> Outcome;
126    /// §12d *Network security fails closed*: hold the traffic a failed
127    /// network-security unit protected (`planarian-hold@<unit>`). A failure
128    /// posture response, not a transition: no `Prepared`, no lease (it only
129    /// ever restricts traffic; docs/bypasses.md). Idempotent. Unlike the
130    /// other mutations here it takes no `OpId`; the daemon journals
131    /// `HoldApplied` before calling it.
132    ///
133    /// Every hold call here runs on the daemon's tick (or a request on its
134    /// core) and waits at most `wait`: past it the call returns `Unknown`
135    /// (or, for `hold_table_present`, an error: cannot say), and what it
136    /// started goes on in the host. A hold not confirmed within `wait`
137    /// counts as held; the socket, approvals and repairs are never stalled
138    /// for a stuck `systemctl` (reviewer, #61).
139    fn hold(&self, _unit: &UnitId, _wait: Millis) -> Outcome {
140        Outcome::Failed("this backend cannot hold traffic".into())
141    }
142    /// Lift a unit's hold, once it is Running and its Readiness probes pass
143    /// (journaled `HoldLifted` first). Idempotent. At most `wait`.
144    fn lift_hold(&self, _unit: &UnitId, _wait: Millis) -> Outcome {
145        Outcome::Failed("this backend cannot hold traffic".into())
146    }
147    /// The units whose traffic is held now, as the host says (§12d: the
148    /// hold survives a restart of planariad). An error means "cannot say",
149    /// never "none".
150    fn held(&self) -> Result<BTreeSet<UnitId>, Outcome> {
151        Ok(BTreeSet::new())
152    }
153    /// `planarian hold release` (an attested Override, decided before this):
154    /// mark the unit's hold released until `until` (`None`: the next boot),
155    /// then lift it, waiting at most `wait`. While the mark stands, a new
156    /// failure does not hold.
157    fn release_hold(&self, _unit: &UnitId, _until: Option<Timestamp>, _wait: Millis) -> Outcome {
158        Outcome::Failed("this backend cannot hold traffic".into())
159    }
160    /// The release ended (expired, or the override lifted): remove the mark.
161    fn end_release(&self, _unit: &UnitId) -> Outcome {
162        Outcome::Succeeded
163    }
164    /// Whether the hold's nftables table is in place. Anything else that
165    /// flushes the ruleset (`nft flush ruleset`) removes it and would fail
166    /// open; the daemon checks this while a hold is active. An error means
167    /// "cannot say" (also past `wait`).
168    fn hold_table_present(&self, _wait: Millis) -> Result<bool, Outcome> {
169        Ok(true)
170    }
171    /// Build a unit's hold again (the table was removed under it): restart
172    /// its hold unit, since starting an active one does nothing. At most
173    /// `wait`.
174    fn reapply_hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
175        self.hold(unit, wait)
176    }
177}
178
179pub trait GenerationBackend {
180    // Read-only; allowed before Prepared.
181    fn current(&self) -> Result<GenerationId, Outcome>;
182    fn boot_entries(&self) -> Result<Vec<GenerationId>, Outcome>;
183    fn boot_default(&self) -> Result<GenerationId, Outcome>;
184    fn last_booted(&self) -> Result<GenerationId, Outcome>;
185    fn stage(&self, owner: &PinnedSource, overrides: &OverrideSet) -> Result<StagedSource, Outcome>;
186    /// Recompute the NAR hash of the owner's Nix-visible source now (stale check, §9 step 6).
187    fn source_hash(&self, owner: &PinnedSource) -> Result<Hash, Outcome>;
188    /// The owner's files Nix cannot see now (untracked in a git flake), to
189    /// report with the stale check: one added during the build was not
190    /// there at staging (§9 step 1).
191    fn untracked(&self, _owner: &PinnedSource) -> Vec<String> {
192        vec![]
193    }
194    fn evaluate(&self, src: &StagedSource) -> Result<EvaluatedGeneration, Outcome>;
195    /// Store-only; roots its own output.
196    fn build(&self, eval: &EvaluatedGeneration) -> Result<Built, Outcome>;
197    /// The path is a whole system, its contents still hash to what the store
198    /// recorded, and, when `nar` is given (the transaction built it), that is
199    /// the NAR hash recorded at build (§9 step 3, C6).
200    fn verify_candidate(&self, path: &StorePath, nar: Option<&Hash>) -> Result<bool, Outcome>;
201    /// What switching from the running generation to an existing one would
202    /// start, stop, restart and reload (its dry activation), for
203    /// SwitchGeneration / Revert / Rollback (§11, R04). An activation that
204    /// failed, timed out or was cut short is an error, never a smaller plan.
205    fn dry_activation(&self, _path: &StorePath) -> Result<ActivationPlan, Outcome> {
206        Err(Outcome::Unknown("this backend has no dry activation".into()))
207    }
208    fn pending_jobs(&self) -> Result<Vec<JobId>, Outcome>;
209    /// `None`: the backend cannot say whether work tagged with `op` exists.
210    fn jobs_tagged(&self, op: &OpId) -> Result<Option<Vec<JobId>>, Outcome>;
211    /// The override file's current content hash (promotion compares against it, §9 step 4).
212    fn override_hash(&self) -> Result<Hash, Outcome>;
213
214    // Mutating; require a durable Prepared and a current epoch.
215    /// `switch-to-configuration test`.
216    fn activate_live(&self, g: GenerationId, op: &OpId) -> Outcome;
217    /// Boot entry only; no re-activation.
218    fn publish_boot(&self, g: GenerationId, op: &OpId) -> Outcome;
219    /// The boot-only path (§12 step 7, §12f): `g` boots once, at the next
220    /// boot; `keep` stays the default. All or nothing.
221    fn publish_boot_once(&self, _g: GenerationId, _keep: GenerationId, _op: &OpId) -> Outcome {
222        Outcome::Failed("this boot loader cannot boot a generation once".into())
223    }
224    /// Who created generation `g` and when (its profile link's owner and
225    /// time): the provenance of a rebuild made outside Planarian (§15).
226    fn created_by(&self, _g: GenerationId) -> Option<(u32, Timestamp)> {
227        None
228    }
229    /// The generation a one-shot entry will boot next, if one is pending.
230    fn one_shot_pending(&self) -> Result<Option<GenerationId>, Outcome> {
231        Err(Outcome::Unknown("this boot loader has no one-shot entries".into()))
232    }
233    fn protect(&self, g: GenerationId, op: &OpId) -> Outcome;
234    fn cancel(&self, job: &JobId, op: &OpId) -> Outcome;
235    /// Atomically replace the live override file with the content whose hash is
236    /// `to`, but only if its current hash is `from`. Idempotent: if it already
237    /// hashes to `to`, succeed without writing.
238    fn swap_override(&self, from: &Hash, to: &Hash, op: &OpId) -> Outcome;
239
240    // Bookkeeping beyond §8 that the protocol needs.
241    /// The system store path of a known generation.
242    fn path_of(&self, g: GenerationId) -> Result<StorePath, Outcome>;
243    /// Evaluate an already-built generation from its store path (its exported
244    /// manifest), for SwitchGeneration / Revert / Rollback.
245    fn evaluate_built(&self, path: &StorePath) -> Result<EvaluatedGeneration, Outcome>;
246    /// The live override set and its content hash.
247    fn read_overrides(&self) -> Result<(OverrideSet, Hash), Outcome>;
248    /// Keep override content addressable by hash, so `swap_override` can write it.
249    fn remember_override(&self, set: &OverrideSet) -> Result<Hash, Outcome>;
250    /// The override content recorded for a generation when it was committed.
251    fn override_for(&self, g: GenerationId) -> Result<Option<Hash>, Outcome>;
252    /// The override content `remember_override` kept under this hash.
253    fn override_content(&self, _h: &Hash) -> Result<OverrideSet, Outcome> {
254        Err(Outcome::Unknown("this backend keeps no override content".into()))
255    }
256    fn record_override(&self, g: GenerationId, hash: &Hash) -> Result<(), Outcome>;
257    /// Remove a GC root symlink this backend created.
258    fn release_root(&self, root: &str) -> bool;
259    /// Remove build roots no transaction can still use: older than the
260    /// longest a candidate may wait (§12f), a crashed build's included.
261    /// At startup and before every collection (§9 step 5, C4). Returns them.
262    fn age_out_build_roots(&self) -> Vec<String> {
263        vec![]
264    }
265    /// Collect generations under the §12b bounds (`gc::plan`), never touching
266    /// `protect` (I3), then collect store garbage. Mutating.
267    fn collect_garbage(&self, protect: &[GenerationId], emergency: bool, op: &OpId) -> Outcome;
268    /// What `collect_garbage` would delete now. Read-only.
269    fn gc_plan(&self, protect: &[GenerationId], emergency: bool) -> Result<crate::gc::GcPlan, Outcome>;
270    /// The last collection's result.
271    fn space_report(&self) -> Option<crate::gc::SpaceReport>;
272}
273
274pub trait SnapshotBackend {
275    fn supported(&self) -> bool;
276    fn snapshot(&self, label: &str, op: &OpId) -> Result<SnapshotId, Outcome>;
277    fn restore(&self, id: &SnapshotId, op: &OpId) -> Outcome;
278    fn usage_bytes(&self) -> u64;
279}
280
281pub trait InventoryBackend {
282    fn devices(&self) -> Result<Vec<DeviceInfo>, Outcome>;
283    fn resources(&self) -> Result<Resources, Outcome>;
284    fn bound_driver(&self, device: &UnitId) -> Result<Option<DriverRef>, Outcome>;
285    fn bind(&self, device: &UnitId, driver: &DriverRef, op: &OpId) -> Outcome;
286    fn unbind(&self, device: &UnitId, op: &OpId) -> Outcome;
287}
288
289pub trait ProbeBackend {
290    /// Run probes concurrently, each in its sandbox, each killed at its timeout
291    /// (§10). One result per job, in order. `FreeSpace` probes are answered
292    /// here too; `SystemdActive` and `SessionTarget` are evaluated by `state`.
293    fn run_batch(&self, jobs: &[(UnitId, ProbeDef)]) -> Vec<ProbeResult>;
294}
295
296#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
297pub struct SessionInfo {
298    pub id: String,
299    pub uid: u32,
300    /// logind class: `user`, `greeter`, `lock-screen`, `background`, `manager`, …
301    pub class: String,
302    pub seat: Option<String>,
303    pub remote: bool,
304    pub created: Timestamp,
305    /// logind `State=active`: the session in the foreground of its seat.
306    #[serde(default)]
307    pub active: bool,
308    /// logind `IdleHint`: nobody has used it for a while.
309    #[serde(default)]
310    pub idle: bool,
311}
312
313impl SessionInfo {
314    /// §10: only `user`-class sessions with a seat, or remote user sessions, count.
315    pub fn establishes(&self) -> bool {
316        self.class == "user" && (self.seat.is_some() || self.remote)
317    }
318}
319
320#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
321pub enum PowerSource {
322    Ac,
323    Battery,
324}
325
326pub trait SessionBackend {
327    fn sessions(&self) -> Result<Vec<SessionInfo>, Outcome>;
328    fn power(&self) -> Option<PowerSource>;
329    /// Whether a compositor runs in this session's own cgroup (§9); None if
330    /// the backend cannot see.
331    fn compositor_running(&self, _s: &SessionInfo) -> Option<bool> {
332        None
333    }
334    /// Sessions in which foreground work holds (§10: a GameMode client, a
335    /// gamescope session, …): `Condition::Foreground`.
336    fn foreground(&self) -> Vec<String> {
337        vec![]
338    }
339}
340
341/// The core-owned runtime-settable options (§12a).
342pub trait RuntimeOptionBackend {
343    fn get(&self, path: &str) -> Option<crate::transition::Value>;
344    /// `Outcome::Failed("no live mechanism")` when the option can only be
345    /// changed through its write-back.
346    fn set(&self, path: &str, value: &crate::transition::Value, op: &OpId) -> Outcome;
347    /// Other values the option could take right now (for `wifi.network`:
348    /// known networks in range, besides the current one).
349    fn alternatives(&self, path: &str) -> Vec<crate::transition::Value> {
350        let _ = path;
351        vec![]
352    }
353}
354
355pub trait ProcessBackend {
356    /// SIGTERM, then SIGKILL, the process group via pidfd; true once confirmed gone.
357    fn kill_group(&self, pid: u32) -> bool;
358    fn alive(&self, pid: u32) -> bool;
359}
360
361pub trait Clock {
362    /// Wall-clock time: for what people see and records compared across boots.
363    fn now(&self) -> Timestamp;
364    /// Milliseconds on `CLOCK_BOOTTIME`: for everything that does not survive
365    /// a reboot (approval tokens, leases, cooldowns, windows; §12h). Moving
366    /// the wall clock never moves it.
367    fn boot_now(&self) -> Timestamp;
368    fn sleep(&self, d: Millis);
369}
370
371/// Every backend the system needs, as one generic parameter (§6: generics, no
372/// `dyn`). The daemon and the watchdog instantiate it with the real backends;
373/// the gym, with fakes.
374pub trait Backends {
375    type Units: UnitBackend;
376    /// Cloned onto a worker thread for the slow part of planning a change.
377    type Gens: GenerationBackend + Clone + Send + 'static;
378    type Inv: InventoryBackend;
379    type Probes: ProbeBackend;
380    type Sessions: SessionBackend;
381    type Runtime: RuntimeOptionBackend;
382    type Procs: ProcessBackend;
383    type Clock: Clock;
384
385    fn units(&self) -> &Self::Units;
386    fn generations(&self) -> &Self::Gens;
387    fn inventory(&self) -> &Self::Inv;
388    fn probes(&self) -> &Self::Probes;
389    fn sessions(&self) -> &Self::Sessions;
390    fn runtime(&self) -> &Self::Runtime;
391    fn processes(&self) -> &Self::Procs;
392    fn clock(&self) -> &Self::Clock;
393}
394
395/// An agent's name: 1-32 of `a-z`, `0-9` and `-`, not starting with `-`.
396pub fn valid_agent_name(name: &str) -> bool {
397    (1..=32).contains(&name.len())
398        && !name.starts_with('-')
399        && name.bytes().all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')
400}
401
402/// One start of an agent's unit: its systemd InvocationID (I24). The name
403/// alone never identifies an agent.
404#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
405pub struct InstanceId(pub [u8; 16]);
406
407impl InstanceId {
408    /// From systemd's form (32 hex digits, as `InvocationID` and the
409    /// cgroup's `trusted.invocation_id` carry it) or the 16 raw bytes.
410    pub fn parse(b: &[u8]) -> Option<InstanceId> {
411        let b = b.strip_suffix(b"\n").unwrap_or(b);
412        if b.len() == 16 {
413            return Some(InstanceId(b.try_into().ok()?));
414        }
415        let s = std::str::from_utf8(b).ok()?.trim();
416        if s.len() != 32 {
417            return None;
418        }
419        let mut out = [0u8; 16];
420        for (i, o) in out.iter_mut().enumerate() {
421            *o = u8::from_str_radix(s.get(2 * i..2 * i + 2)?, 16).ok()?;
422        }
423        (out != [0; 16]).then_some(InstanceId(out))
424    }
425}
426
427/// The unit an agent runs in (§11, §16).
428pub fn agent_unit(name: &str) -> String {
429    format!("planarian-agent-{name}")
430}
431
432/// The slice every launched agent runs in (§12d, R05): the posture stops it
433/// as a whole.
434pub const AGENTS_SLICE: &str = "planarian-agents.slice";
435
436/// Limits on launched agents (`services.planarian.agents.limits`, §12d).
437#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
438#[serde(rename_all = "camelCase")]
439pub struct AgentLimits {
440    /// Per launch: systemd `TasksMax=` and `MemoryMax=`.
441    pub tasks_max: u32,
442    pub memory_max: String,
443    /// Agents one uid may have running at once.
444    pub per_uid: u32,
445}
446
447impl Default for AgentLimits {
448    fn default() -> AgentLimits {
449        AgentLimits { tasks_max: 512, memory_max: "4G".into(), per_uid: 4 }
450    }
451}