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}