1use 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
34pub 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
42pub 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
47pub 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
62pub fn declared(fragment: &str) -> bool {
66 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 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 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 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 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 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 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
294const FAILED_DIR: &str = "/run/planarian";
296
297fn 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 #[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}