Skip to content

Commit fc492cf

Browse files
feat(systemd): land Systemd trait, LiveSystemd, MockSystemd, parsers (#12)
Plan §6.8 — third M1 Week 2 adapter probe path. Inventory only at M1; enable / disable / mask / restart arrive with M2 per Plan §7.3. Public surface: - pub trait Systemd { fn inventory(&self) -> Result<ServiceInventory, SystemdError>; } - pub struct LiveSystemd with new() / with_binary() / binary(). - pub struct MockSystemd (test-mocks feature) with new() / with_inventory(). - pub fn parse_list_unit_files(&str) -> (BTreeSet<String>, BTreeSet<String>, BTreeSet<String>) returning (enabled, disabled, masked). - pub fn parse_list_units(&str) -> BTreeSet<String> returning the loaded+active set. - pub fn compose_inventory(unit_files, units) -> ServiceInventory. - pub enum SystemdError — NotInPath, Io, InvocationFailed, NotUtf8. Parser semantics: - list-unit-files: enabled and enabled-runtime → enabled set; disabled → disabled; masked and masked-runtime → masked. static, alias, indirect, generated, transient skipped (not user-configurable enable values). - list-units: filter LOAD == "loaded" && ACTIVE == "active" so the active set excludes not-found, error, masked, and inactive units. LiveSystemd invokes: - systemctl list-unit-files --no-pager --no-legend - systemctl list-units --no-pager --no-legend --all --plain Tests (10 passing): - inventory: list_unit_files_distinguishes_enabled_disabled_masked, list_unit_files_skips_static_alias_indirect, list_units_filters_to_loaded_active, empty_outputs_yield_empty_inventory, compose_combines_both_streams, truncated_lines_are_skipped. - live: systemctl_not_in_path_error_class, live_systemd_inventory_succeeds_when_systemctl_present (silent-skips if systemctl missing — CI runners have it). - mock: empty_mock_yields_empty_inventory, with_inventory_round_trips. Verification: - cargo test -p pearlite-systemd (10 passed; 0 failed) - cargo clippy --workspace --all-targets -- -D warnings (zero warnings) - cargo fmt --all --check - scripts/ci/check-spdx.sh - pearlite-audit check . (1 check, 0 violations) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent e1333fa commit fc492cf

6 files changed

Lines changed: 458 additions & 1 deletion

File tree

crates/pearlite-systemd/Cargo.toml

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,9 @@ repository.workspace = true
1111
[lints]
1212
workspace = true
1313

14+
[features]
15+
test-mocks = []
16+
1417
[dependencies]
18+
pearlite-schema = { path = "../pearlite-schema", version = "0.1" }
19+
thiserror = { workspace = true }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! Errors emitted by `pearlite-systemd`.
5+
6+
use thiserror::Error;
7+
8+
/// Errors emitted while invoking `systemctl` or parsing its output.
9+
#[derive(Debug, Error)]
10+
pub enum SystemdError {
11+
/// The configured `systemctl` binary could not be spawned. Usually
12+
/// a Class 1 preflight failure: every supported host has systemd.
13+
#[error("systemctl binary not found: {hint}")]
14+
NotInPath {
15+
/// Hint string with a runnable command.
16+
hint: &'static str,
17+
},
18+
/// `std::io` error during spawn or stdout capture.
19+
#[error("I/O error invoking systemctl: {0}")]
20+
Io(#[from] std::io::Error),
21+
/// `systemctl` exited non-zero.
22+
#[error("systemctl exited with code {code}:\n{stderr}")]
23+
InvocationFailed {
24+
/// Process exit code.
25+
code: i32,
26+
/// Captured stderr verbatim.
27+
stderr: String,
28+
},
29+
/// `systemctl` stdout was not valid UTF-8.
30+
#[error("systemctl stdout was not valid UTF-8: {0}")]
31+
NotUtf8(#[from] std::string::FromUtf8Error),
32+
}
Lines changed: 184 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,184 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! `systemctl list-unit-files` + `systemctl list-units` parsers and
5+
//! composition into a [`ServiceInventory`].
6+
7+
use pearlite_schema::ServiceInventory;
8+
use std::collections::BTreeSet;
9+
10+
/// Parse the stdout of
11+
/// `systemctl list-unit-files --no-pager --no-legend` into the three
12+
/// disjoint state buckets `(enabled, disabled, masked)`.
13+
///
14+
/// `enabled-runtime` and `masked-runtime` are folded into the
15+
/// non-runtime variants — the difference matters at apply time but not
16+
/// for drift detection.
17+
///
18+
/// `static`, `alias`, `indirect`, `generated`, and `transient` are
19+
/// silently skipped: those states are not user-configurable enable
20+
/// values and never appear in declared service config.
21+
#[must_use]
22+
pub fn parse_list_unit_files(
23+
stdout: &str,
24+
) -> (BTreeSet<String>, BTreeSet<String>, BTreeSet<String>) {
25+
let mut enabled = BTreeSet::new();
26+
let mut disabled = BTreeSet::new();
27+
let mut masked = BTreeSet::new();
28+
for line in stdout.lines() {
29+
let mut iter = line.split_whitespace();
30+
let Some(unit) = iter.next() else { continue };
31+
let Some(state) = iter.next() else { continue };
32+
match state {
33+
"enabled" | "enabled-runtime" => {
34+
enabled.insert(unit.to_owned());
35+
}
36+
"disabled" => {
37+
disabled.insert(unit.to_owned());
38+
}
39+
"masked" | "masked-runtime" => {
40+
masked.insert(unit.to_owned());
41+
}
42+
_ => {} // static / alias / indirect / generated / transient
43+
}
44+
}
45+
(enabled, disabled, masked)
46+
}
47+
48+
/// Parse the stdout of
49+
/// `systemctl list-units --no-pager --no-legend --all --plain` into the
50+
/// set of currently active, loaded units.
51+
///
52+
/// Filter: `LOAD == loaded && ACTIVE == active`. Units that are
53+
/// not-found, error, masked, or inactive are skipped.
54+
#[must_use]
55+
pub fn parse_list_units(stdout: &str) -> BTreeSet<String> {
56+
let mut active = BTreeSet::new();
57+
for line in stdout.lines() {
58+
let mut iter = line.split_whitespace();
59+
let Some(unit) = iter.next() else { continue };
60+
let Some(load) = iter.next() else { continue };
61+
let Some(active_state) = iter.next() else {
62+
continue;
63+
};
64+
if load == "loaded" && active_state == "active" {
65+
active.insert(unit.to_owned());
66+
}
67+
}
68+
active
69+
}
70+
71+
/// Combine `list-unit-files` and `list-units` outputs into one
72+
/// [`ServiceInventory`].
73+
#[must_use]
74+
pub fn compose_inventory(unit_files: &str, units: &str) -> ServiceInventory {
75+
let (enabled, disabled, masked) = parse_list_unit_files(unit_files);
76+
let active = parse_list_units(units);
77+
ServiceInventory {
78+
enabled,
79+
disabled,
80+
masked,
81+
active,
82+
}
83+
}
84+
85+
#[cfg(test)]
86+
#[allow(
87+
clippy::expect_used,
88+
clippy::unwrap_used,
89+
clippy::panic,
90+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
91+
)]
92+
mod tests {
93+
use super::*;
94+
95+
const UNIT_FILES: &str = "\
96+
nginx.service enabled enabled
97+
sshd.service enabled enabled
98+
NetworkManager.service enabled-runtime enabled
99+
bluetooth.service disabled enabled
100+
systemd-resolved.service masked enabled
101+
shutdown.target static -
102+
console-getty.service indirect -
103+
systemd-tmpfiles-setup.service alias -
104+
";
105+
106+
const UNITS: &str = "\
107+
nginx.service loaded active running A high-performance web server
108+
sshd.service loaded active running OpenBSD Secure Shell server
109+
bluetooth.service loaded inactive dead Bluetooth service
110+
systemd-resolved.service masked inactive dead systemd-resolved.service
111+
not-found.service not-found inactive dead not-found.service
112+
";
113+
114+
#[test]
115+
fn list_unit_files_distinguishes_enabled_disabled_masked() {
116+
let (enabled, disabled, masked) = parse_list_unit_files(UNIT_FILES);
117+
118+
assert_eq!(enabled.len(), 3);
119+
assert!(enabled.contains("nginx.service"));
120+
assert!(enabled.contains("sshd.service"));
121+
assert!(enabled.contains("NetworkManager.service")); // enabled-runtime
122+
123+
assert_eq!(disabled.len(), 1);
124+
assert!(disabled.contains("bluetooth.service"));
125+
126+
assert_eq!(masked.len(), 1);
127+
assert!(masked.contains("systemd-resolved.service"));
128+
}
129+
130+
#[test]
131+
fn list_unit_files_skips_static_alias_indirect() {
132+
let (enabled, disabled, masked) = parse_list_unit_files(UNIT_FILES);
133+
let all: BTreeSet<&str> = enabled
134+
.iter()
135+
.chain(disabled.iter())
136+
.chain(masked.iter())
137+
.map(String::as_str)
138+
.collect();
139+
assert!(!all.contains("shutdown.target"));
140+
assert!(!all.contains("console-getty.service"));
141+
assert!(!all.contains("systemd-tmpfiles-setup.service"));
142+
}
143+
144+
#[test]
145+
fn list_units_filters_to_loaded_active() {
146+
let active = parse_list_units(UNITS);
147+
assert_eq!(active.len(), 2);
148+
assert!(active.contains("nginx.service"));
149+
assert!(active.contains("sshd.service"));
150+
// Inactive: not active.
151+
assert!(!active.contains("bluetooth.service"));
152+
// Masked: not loaded.
153+
assert!(!active.contains("systemd-resolved.service"));
154+
// Not-found: not loaded.
155+
assert!(!active.contains("not-found.service"));
156+
}
157+
158+
#[test]
159+
fn empty_outputs_yield_empty_inventory() {
160+
let inv = compose_inventory("", "");
161+
assert!(inv.enabled.is_empty());
162+
assert!(inv.disabled.is_empty());
163+
assert!(inv.masked.is_empty());
164+
assert!(inv.active.is_empty());
165+
}
166+
167+
#[test]
168+
fn compose_combines_both_streams() {
169+
let inv = compose_inventory(UNIT_FILES, UNITS);
170+
assert_eq!(inv.enabled.len(), 3);
171+
assert_eq!(inv.disabled.len(), 1);
172+
assert_eq!(inv.masked.len(), 1);
173+
assert_eq!(inv.active.len(), 2);
174+
}
175+
176+
#[test]
177+
fn truncated_lines_are_skipped() {
178+
// Defensive: a corrupt or partial line must not panic.
179+
let truncated = "nginx.service\nsshd.service enabled\n";
180+
let (enabled, _, _) = parse_list_unit_files(truncated);
181+
assert_eq!(enabled.len(), 1);
182+
assert!(enabled.contains("sshd.service"));
183+
}
184+
}

crates/pearlite-systemd/src/lib.rs

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,25 @@
11
// SPDX-License-Identifier: GPL-3.0-or-later
22
// Copyright (C) 2026 Mohamed Hammad
33

4-
//! systemctl adapter: enable, disable, mask, restart for system and user scopes.
4+
//! systemctl adapter: enable, disable, mask, restart for system and
5+
//! user scopes.
6+
//!
7+
//! At M1 only the read side ([`Systemd::inventory`]) is implemented.
8+
//! Apply-side methods (enable, disable, mask, restart) and user-scope
9+
//! support arrive in M2 per Plan §7.3.
10+
11+
mod errors;
12+
mod inventory;
13+
mod live;
14+
#[cfg(any(test, feature = "test-mocks"))]
15+
mod mock;
16+
17+
pub use errors::SystemdError;
18+
pub use inventory::{compose_inventory, parse_list_unit_files, parse_list_units};
19+
pub use live::{LiveSystemd, Systemd};
20+
21+
#[cfg(feature = "test-mocks")]
22+
pub use mock::MockSystemd;
23+
24+
#[doc(no_inline)]
25+
pub use pearlite_schema::ServiceInventory;
Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
// SPDX-License-Identifier: GPL-3.0-or-later
2+
// Copyright (C) 2026 Mohamed Hammad
3+
4+
//! [`Systemd`] trait + production [`LiveSystemd`] implementation.
5+
6+
use crate::errors::SystemdError;
7+
use crate::inventory::compose_inventory;
8+
use pearlite_schema::ServiceInventory;
9+
use std::path::{Path, PathBuf};
10+
use std::process::Command;
11+
12+
/// Trait the rest of the workspace consumes to talk to systemd.
13+
///
14+
/// At M1 only [`inventory`](Self::inventory) is implemented. Enable /
15+
/// disable / mask / restart arrive with M2's apply-engine wiring.
16+
pub trait Systemd: Send + Sync {
17+
/// Snapshot the unit-file states + currently-active units into a
18+
/// [`ServiceInventory`].
19+
///
20+
/// # Errors
21+
/// Implementations propagate adapter-specific failures via
22+
/// [`SystemdError`].
23+
fn inventory(&self) -> Result<ServiceInventory, SystemdError>;
24+
}
25+
26+
/// Production [`Systemd`] backed by the `systemctl` binary.
27+
#[derive(Clone, Debug)]
28+
pub struct LiveSystemd {
29+
binary: PathBuf,
30+
}
31+
32+
impl LiveSystemd {
33+
/// Construct a [`LiveSystemd`] that resolves `systemctl` from `PATH`.
34+
#[must_use]
35+
pub fn new() -> Self {
36+
Self {
37+
binary: PathBuf::from("systemctl"),
38+
}
39+
}
40+
41+
/// Construct a [`LiveSystemd`] with a caller-supplied binary path.
42+
pub fn with_binary(binary: impl Into<PathBuf>) -> Self {
43+
Self {
44+
binary: binary.into(),
45+
}
46+
}
47+
48+
/// Path of the `systemctl` binary this adapter invokes.
49+
#[must_use]
50+
pub fn binary(&self) -> &Path {
51+
&self.binary
52+
}
53+
54+
fn run(&self, args: &[&str]) -> Result<String, SystemdError> {
55+
let output = match Command::new(&self.binary).args(args).output() {
56+
Ok(o) => o,
57+
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
58+
return Err(SystemdError::NotInPath {
59+
hint: "every supported Pearlite host runs systemd; this is unusual",
60+
});
61+
}
62+
Err(e) => return Err(SystemdError::Io(e)),
63+
};
64+
65+
if !output.status.success() {
66+
let code = output.status.code().unwrap_or(-1);
67+
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
68+
return Err(SystemdError::InvocationFailed { code, stderr });
69+
}
70+
71+
Ok(String::from_utf8(output.stdout)?)
72+
}
73+
}
74+
75+
impl Default for LiveSystemd {
76+
fn default() -> Self {
77+
Self::new()
78+
}
79+
}
80+
81+
impl Systemd for LiveSystemd {
82+
fn inventory(&self) -> Result<ServiceInventory, SystemdError> {
83+
let unit_files = self.run(&["list-unit-files", "--no-pager", "--no-legend"])?;
84+
let units = self.run(&[
85+
"list-units",
86+
"--no-pager",
87+
"--no-legend",
88+
"--all",
89+
"--plain",
90+
])?;
91+
Ok(compose_inventory(&unit_files, &units))
92+
}
93+
}
94+
95+
#[cfg(test)]
96+
#[allow(
97+
clippy::expect_used,
98+
clippy::unwrap_used,
99+
clippy::panic,
100+
reason = "tests may use expect()/unwrap()/panic!() per Plan §4.2 + CLAUDE.md"
101+
)]
102+
mod tests {
103+
use super::*;
104+
105+
#[test]
106+
fn systemctl_not_in_path_error_class() {
107+
let live = LiveSystemd::with_binary("/nonexistent/path/systemctl-binary-12345");
108+
let err = live.inventory().expect_err("must fail");
109+
assert!(matches!(err, SystemdError::NotInPath { .. }), "got {err:?}");
110+
}
111+
112+
/// Plan §6.8 acceptance: real systemctl invocation produces a
113+
/// well-formed inventory. CI runners are systemd-based; the test
114+
/// silent-skips on hosts without systemd.
115+
#[test]
116+
fn live_systemd_inventory_succeeds_when_systemctl_present() {
117+
let live = LiveSystemd::new();
118+
let probe = Command::new(live.binary()).arg("--version").output();
119+
if !matches!(&probe, Ok(o) if o.status.success()) {
120+
return;
121+
}
122+
let inv = live.inventory().expect("inventory");
123+
// Sanity: every unit name ends with a known suffix.
124+
for name in inv
125+
.enabled
126+
.iter()
127+
.chain(inv.disabled.iter())
128+
.chain(inv.masked.iter())
129+
{
130+
assert!(
131+
name.contains('.'),
132+
"unit name without a type suffix: {name}"
133+
);
134+
}
135+
}
136+
}

0 commit comments

Comments
 (0)