Skip to main content

planarian_backend_linux/
systemd.rs

1//! `UnitBackend` over systemd (§8 systemd mapping).
2//!
3//! `active` → Active; `failed` → Failed(exit status); `inactive`/`dead` →
4//! Bound; `activating`/`deactivating`/`reloading` → Transitioning; not
5//! found → Absent; a systemctl error → Err (state reports Unknown).
6//! User-scope units go through the user's manager (`--machine=<uid>@.host --user`).
7
8use crate::admitted;
9use crate::cmd::run;
10use planarian_schema::{
11    Ending, Millis, Observed, OpId, Outcome, Reason, Scope, Timestamp, UnitBackend, UnitId, UnitKind,
12};
13use std::collections::{BTreeMap, BTreeSet};
14use std::path::{Path, PathBuf};
15use std::time::Duration;
16
17pub const QUERY_TIMEOUT: Duration = Duration::from_secs(10);
18pub const JOB_TIMEOUT: Duration = Duration::from_secs(120);
19
20#[derive(Clone, Debug)]
21pub struct Systemd {
22    txn_dir: PathBuf,
23}
24
25pub fn unit_name(id: &UnitId) -> String {
26    let suffix = match id.kind {
27        UnitKind::Service => "service",
28        UnitKind::Target => "target",
29        UnitKind::Device => "device",
30    };
31    if id.name.contains('.') { id.name.clone() } else { format!("{}.{suffix}", id.name) }
32}
33
34/// The `systemctl` prefix for a unit's manager.
35pub fn systemctl(scope: &Scope) -> Vec<String> {
36    match scope {
37        Scope::System => vec!["systemctl".into()],
38        Scope::User { uid, .. } => vec!["systemctl".into(), "--user".into(), format!("--machine={uid}@.host")],
39    }
40}
41
42/// Parse `systemctl show` key=value output.
43pub fn parse_show(text: &str) -> BTreeMap<String, String> {
44    text.lines().filter_map(|l| l.split_once('=')).map(|(k, v)| (k.to_string(), v.to_string())).collect()
45}
46
47/// `Result`, `ExecMainCode` (1 exited, 2 killed, 3 dumped core) and
48/// `ExecMainStatus` → how the main process last ended.
49pub fn ending_from(props: &BTreeMap<String, String>) -> Option<Ending> {
50    let get = |k: &str| props.get(k).map(String::as_str).unwrap_or("");
51    let status: i32 = get("ExecMainStatus").parse().ok()?;
52    if get("Result") == "oom-kill" {
53        return Some(Ending::OomKill);
54    }
55    match get("ExecMainCode") {
56        "1" => Some(Ending::Exited(status)),
57        "2" | "3" => Some(Ending::Signal(status)),
58        _ => None,
59    }
60}
61
62/// A unit file that belongs to the generation: in the store (directly or
63/// through /etc's links into it), written by a systemd generator, transient,
64/// or none at all (scopes, slices, devices, mounts systemd makes up).
65pub fn declared(fragment: &str) -> bool {
66    // Transient units (`systemd-run`, Planarian's own operations) are made at
67    // run time by root over D-Bus and do not persist: not a planted file.
68    if fragment.is_empty()
69        || fragment.starts_with("/nix/store/")
70        || fragment.starts_with("/run/systemd/generator")
71        || fragment.starts_with("/run/systemd/transient/")
72    {
73        return true;
74    }
75    std::fs::canonicalize(fragment).is_ok_and(|p| p.starts_with("/nix/store/"))
76}
77
78pub fn observed_from(props: &BTreeMap<String, String>, now: Timestamp) -> Observed {
79    let get = |k: &str| props.get(k).map(String::as_str).unwrap_or("");
80    if get("LoadState") == "not-found" {
81        return Observed::Absent;
82    }
83    match get("ActiveState") {
84        "active" => Observed::Active,
85        "failed" => Observed::Failed(Reason::ExitCode(get("ExecMainStatus").parse().unwrap_or(1))),
86        "inactive" | "dead" => Observed::Bound,
87        "activating" | "deactivating" | "reloading" | "refreshing" | "maintenance" => {
88            Observed::Transitioning { from: Box::new(Observed::Bound), since: now }
89        }
90        other => Observed::Unknown { since: now, reason: format!("ActiveState={other:?}") },
91    }
92}
93
94impl Systemd {
95    pub fn new(txn_dir: PathBuf) -> Systemd {
96        Systemd { txn_dir }
97    }
98
99    fn ctl(&self, scope: &Scope, args: &[&str], timeout: Duration) -> crate::cmd::Output {
100        let mut argv = systemctl(scope);
101        argv.extend(args.iter().map(|s| s.to_string()));
102        let refs: Vec<&str> = argv.iter().map(String::as_str).collect();
103        run(&refs, timeout)
104    }
105
106    fn job(&self, verb: &str, id: &UnitId, op: &OpId) -> Outcome {
107        if !admitted(&self.txn_dir, op) {
108            return Outcome::fenced();
109        }
110        let o = self.ctl(&id.scope, &[verb, &unit_name(id)], JOB_TIMEOUT);
111        if o.timed_out {
112            Outcome::Unknown(format!("systemctl {verb} {} did not finish", unit_name(id)))
113        } else if o.ok() {
114            Outcome::Succeeded
115        } else {
116            Outcome::Failed(o.tail())
117        }
118    }
119}
120
121fn now() -> Timestamp {
122    Timestamp(
123        std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_millis() as u64).unwrap_or(0),
124    )
125}
126
127impl UnitBackend for Systemd {
128    /// §12d: netwatch's footprints in `/run/planarian/footprint`, read with its
129    /// own fail-closed rules, at the boot clock's now.
130    fn protecting(&self, tools: &[UnitId]) -> Result<BTreeSet<UnitId>, Outcome> {
131        let now = planarian_backend_netwatch::host::boottime_secs()
132            .ok_or_else(|| Outcome::Unknown("no /proc/uptime".into()))?;
133        Ok(planarian_backend_netwatch::read(std::path::Path::new(planarian_backend_netwatch::DIR))
134            .protecting(tools, now))
135    }
136    fn observe(&self, id: &UnitId) -> Result<Observed, Outcome> {
137        let name = unit_name(id);
138        let o = self.ctl(
139            &id.scope,
140            &["show", "--property=LoadState,ActiveState,SubState,ExecMainStatus", &name],
141            QUERY_TIMEOUT,
142        );
143        if !o.ok() {
144            // A user manager that isn't running means the unit isn't either.
145            if matches!(id.scope, Scope::User { .. }) {
146                return Ok(Observed::Absent);
147            }
148            return Err(Outcome::Failed(o.tail()));
149        }
150        Ok(observed_from(&parse_show(&o.stdout), now()))
151    }
152
153    fn foreign_units(&self) -> Vec<String> {
154        let o = self.ctl(
155            &Scope::System,
156            &["list-units", "--state=active", "--no-legend", "--plain", "--all"],
157            QUERY_TIMEOUT,
158        );
159        if !o.ok() {
160            return vec![];
161        }
162        let mut out = vec![];
163        for name in o.stdout.lines().filter_map(|l| l.split_whitespace().next()) {
164            let s = self.ctl(&Scope::System, &["show", "--property=FragmentPath", "--value", name], QUERY_TIMEOUT);
165            let frag = s.stdout.trim();
166            if s.ok() && !declared(frag) {
167                out.push(format!("{name} ({frag})"));
168            }
169        }
170        out
171    }
172
173    fn ended_by(&self, id: &UnitId) -> Option<Ending> {
174        let o = self.ctl(
175            &id.scope,
176            &["show", "--property=Result,ExecMainCode,ExecMainStatus", &unit_name(id)],
177            QUERY_TIMEOUT,
178        );
179        o.ok().then(|| ending_from(&parse_show(&o.stdout))).flatten()
180    }
181
182    fn auto_restarts(&self, id: &UnitId) -> Option<u32> {
183        let o = self.ctl(&id.scope, &["show", "--property=NRestarts", "--value", &unit_name(id)], QUERY_TIMEOUT);
184        o.ok().then(|| o.stdout.trim().parse().ok()).flatten()
185    }
186
187    fn list(&self) -> Result<Vec<UnitId>, Outcome> {
188        let o = self.ctl(
189            &Scope::System,
190            &["list-units", "--type=service", "--all", "--plain", "--no-legend"],
191            QUERY_TIMEOUT,
192        );
193        if !o.ok() {
194            return Err(Outcome::Failed(o.tail()));
195        }
196        Ok(o.stdout
197            .lines()
198            .filter_map(|l| l.split_whitespace().next())
199            .filter_map(|n| n.strip_suffix(".service"))
200            .map(UnitId::service)
201            .collect())
202    }
203
204    fn start_agent(
205        &self,
206        name: &str,
207        argv: &[String],
208        limits: &planarian_schema::backend::AgentLimits,
209    ) -> Result<planarian_schema::backend::InstanceId, Outcome> {
210        if !planarian_schema::backend::valid_agent_name(name) {
211            return Err(Outcome::Failed(format!("bad agent name {name:?}")));
212        }
213        // I20: no agent starts while a failure class is raised.
214        if posture_failed(Path::new(FAILED_DIR)) {
215            return Err(Outcome::Failed("a failure posture is engaged: agents do not start".into()));
216        }
217        let unit = format!("--unit={}", planarian_schema::backend::agent_unit(name));
218        let slice = format!("--slice={}", planarian_schema::backend::AGENTS_SLICE);
219        let tasks = format!("--property=TasksMax={}", limits.tasks_max);
220        let memory = format!("--property=MemoryMax={}", limits.memory_max);
221        // A uid of its own (DynamicUser), so it is never the person who
222        // started it (§11); no privilege it could gain by exec. In the
223        // agents' slice, which the posture stops as a whole, after and
224        // requiring planarian-ready.target, and conditioned on no failure
225        // class being raised (§12d, I20, R05).
226        let mut a: Vec<&str> = vec![
227            "systemd-run",
228            &unit,
229            &slice,
230            "--collect",
231            "--quiet",
232            "--property=DynamicUser=yes",
233            "--property=NoNewPrivileges=yes",
234            "--property=Requires=planarian-ready.target",
235            "--property=After=planarian-ready.target",
236            "--property=ConditionPathExistsGlob=!/run/planarian/failed-*",
237            &tasks,
238            &memory,
239            "--",
240        ];
241        a.extend(argv.iter().map(String::as_str));
242        let o = run(&a, JOB_TIMEOUT);
243        if !o.ok() {
244            return Err(Outcome::Failed(o.tail()));
245        }
246        // The instance that started, read back from systemd (I24).
247        let unit = format!("{}.service", planarian_schema::backend::agent_unit(name));
248        let id = run(&["systemctl", "show", "-p", "InvocationID", "--value", &unit], QUERY_TIMEOUT);
249        planarian_schema::backend::InstanceId::parse(id.stdout.trim().as_bytes())
250            .ok_or_else(|| Outcome::Unknown(format!("{unit} started, but it has no InvocationID")))
251    }
252
253    fn agent_running(&self, name: &str) -> bool {
254        let unit = format!("{}.service", planarian_schema::backend::agent_unit(name));
255        let o = run(&["systemctl", "show", "-p", "ActiveState", "--value", &unit], QUERY_TIMEOUT);
256        matches!(o.stdout.trim(), "active" | "activating" | "reloading" | "deactivating")
257    }
258
259    fn restart(&self, id: &UnitId, op: &OpId) -> Outcome {
260        self.job("restart", id, op)
261    }
262
263    fn stop(&self, id: &UnitId, op: &OpId) -> Outcome {
264        self.job("stop", id, op)
265    }
266    // §12d: the hold, a failure posture response (crate::hold).
267    fn hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
268        crate::hold::apply(unit, ms(wait))
269    }
270    fn lift_hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
271        crate::hold::lift(unit, ms(wait))
272    }
273    fn held(&self) -> Result<std::collections::BTreeSet<UnitId>, Outcome> {
274        crate::hold::held_in(Path::new(crate::hold::HOLD_DIR))
275    }
276    fn release_hold(&self, unit: &UnitId, until: Option<Timestamp>, wait: Millis) -> Outcome {
277        crate::hold::release_in(Path::new(crate::hold::RELEASED_DIR), unit, until, ms(wait))
278    }
279    fn end_release(&self, unit: &UnitId) -> Outcome {
280        crate::hold::end_release_in(Path::new(crate::hold::RELEASED_DIR), unit)
281    }
282    fn hold_table_present(&self, wait: Millis) -> Result<bool, Outcome> {
283        crate::hold::table_present(ms(wait))
284    }
285    fn reapply_hold(&self, unit: &UnitId, wait: Millis) -> Outcome {
286        crate::hold::reapply(unit, ms(wait))
287    }
288}
289
290fn ms(m: Millis) -> std::time::Duration {
291    std::time::Duration::from_millis(m.0)
292}
293
294/// Where the posture marks a raised failure class (`failed-<class>`).
295const FAILED_DIR: &str = "/run/planarian";
296
297/// A failure class is raised: `failed-*` exists in `dir`.
298fn posture_failed(dir: &Path) -> bool {
299    std::fs::read_dir(dir)
300        .into_iter()
301        .flatten()
302        .flatten()
303        .any(|e| e.file_name().to_str().is_some_and(|n| n.starts_with("failed-")))
304}
305
306#[cfg(test)]
307mod tests {
308    use super::*;
309
310    /// R05, I20: the launcher refuses while a failure class is raised.
311    #[test]
312    fn no_agent_starts_while_a_failure_class_is_raised() {
313        let d = std::env::temp_dir().join(format!("planarian-posture-{}", std::process::id()));
314        std::fs::create_dir_all(&d).unwrap();
315        assert!(!posture_failed(&d));
316        std::fs::write(d.join("alarm"), "").unwrap();
317        assert!(!posture_failed(&d), "only failed-<class> counts");
318        std::fs::write(d.join("failed-crashed"), "").unwrap();
319        assert!(posture_failed(&d));
320        let _ = std::fs::remove_dir_all(&d);
321    }
322
323    #[test]
324    fn mapping() {
325        let o = |s: &str| observed_from(&parse_show(s), Timestamp(0));
326        assert_eq!(o("LoadState=loaded\nActiveState=active\nSubState=running\nExecMainStatus=0\n"), Observed::Active);
327        assert!(
328            declared("") && declared("/nix/store/abc-unit/x.service") && declared("/run/systemd/generator/a.mount")
329        );
330        assert!(!declared("/etc/systemd/system/planted-by-hand-0000.service"));
331        assert!(declared("/run/systemd/transient/run-u12.service"));
332        let e = |t: &str| ending_from(&parse_show(t));
333        assert_eq!(e("Result=signal\nExecMainCode=2\nExecMainStatus=9\n"), Some(Ending::Signal(9)));
334        assert_eq!(e("Result=success\nExecMainCode=2\nExecMainStatus=15\n"), Some(Ending::Signal(15)));
335        assert_eq!(e("Result=core-dump\nExecMainCode=3\nExecMainStatus=11\n"), Some(Ending::Signal(11)));
336        assert_eq!(e("Result=oom-kill\nExecMainCode=2\nExecMainStatus=9\n"), Some(Ending::OomKill));
337        assert_eq!(e("Result=exit-code\nExecMainCode=1\nExecMainStatus=3\n"), Some(Ending::Exited(3)));
338        assert_eq!(
339            o("LoadState=loaded\nActiveState=failed\nExecMainStatus=137\n"),
340            Observed::Failed(Reason::ExitCode(137))
341        );
342        assert_eq!(o("LoadState=loaded\nActiveState=inactive\n"), Observed::Bound);
343        assert_eq!(o("LoadState=not-found\nActiveState=inactive\n"), Observed::Absent);
344        assert!(matches!(o("LoadState=loaded\nActiveState=activating\n"), Observed::Transitioning { .. }));
345        assert_eq!(unit_name(&UnitId::service("sshd")), "sshd.service");
346        assert_eq!(unit_name(&UnitId::target("graphical-session")), "graphical-session.target");
347        assert_eq!(
348            systemctl(&Scope::User { uid: 1000, session: None }),
349            ["systemctl", "--user", "--machine=1000@.host"]
350        );
351    }
352}