Skip to main content

podbox/systemd/
status.rs

1//! Unit status query and diagnostics: `systemctl show` parsing, journal
2//! tails, actionable hint extraction, and the diagnostic card.
3//!
4//! Extracted verbatim from `systemd.rs`.
5
6use std::process::Command;
7
8use anyhow::{Context, Result};
9
10/// Parsed status of a systemd unit.
11#[derive(Debug, Default)]
12pub struct UnitStatus {
13    pub load_state: String,
14    pub active_state: String,
15    pub sub_state: String,
16    pub load_error: String,
17    pub need_daemon_reload: bool,
18}
19
20/// Query systemd unit properties via `systemctl --user show`.
21pub(crate) fn query_unit_status(name: &str) -> Result<UnitStatus> {
22    let mut cmd = Command::new("systemctl");
23    cmd.args([
24        "--user",
25        "show",
26        &format!("{name}.service"),
27        "--property=LoadState,ActiveState,SubState,LoadError,NeedDaemonReload",
28    ]);
29    let output = cmd
30        .stdout(std::process::Stdio::piped())
31        .stderr(std::process::Stdio::piped())
32        .spawn()
33        .context("failed to spawn systemctl show")?
34        .wait_with_output()
35        .context("systemctl show failed")?;
36
37    if !output.status.success() && output.stdout.is_empty() {
38        let stderr = String::from_utf8_lossy(&output.stderr);
39        anyhow::bail!("unit '{}' not found by systemd: {}", name, stderr.trim());
40    }
41
42    Ok(parse_unit_show(&String::from_utf8_lossy(&output.stdout)))
43}
44
45fn parse_unit_show(raw: &str) -> UnitStatus {
46    let mut status = UnitStatus::default();
47    for line in raw.lines() {
48        let (key, value) = match line.split_once('=') {
49            Some(kv) => kv,
50            None => continue,
51        };
52        match key {
53            "LoadState" => status.load_state = value.to_string(),
54            "ActiveState" => status.active_state = value.to_string(),
55            "SubState" => status.sub_state = value.to_string(),
56            "LoadError" => status.load_error = value.to_string(),
57            "NeedDaemonReload" => status.need_daemon_reload = value == "yes",
58            _ => {}
59        }
60    }
61    status
62}
63
64/// Tail journal logs for a container's service units.
65pub(crate) fn journal_tail(name: &str, n: u32) -> Result<String> {
66    if which::which("journalctl").is_err() {
67        anyhow::bail!("journalctl not available");
68    }
69    let mut cmd = Command::new("journalctl");
70    cmd.args([
71        "--user",
72        "-u",
73        &format!("{name}.service"),
74        "-n",
75        &n.to_string(),
76        "--no-pager",
77        "--output=short",
78    ]);
79    let output = cmd
80        .stdout(std::process::Stdio::piped())
81        .stderr(std::process::Stdio::piped())
82        .spawn()
83        .context("failed to spawn journalctl")?
84        .wait_with_output()
85        .context("journalctl failed")?;
86
87    if !output.status.success() {
88        let stderr = String::from_utf8_lossy(&output.stderr);
89        anyhow::bail!("journalctl failed: {}", stderr.trim());
90    }
91
92    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
93    if stdout.trim().is_empty() {
94        anyhow::bail!("no journal entries found");
95    }
96    Ok(stdout)
97}
98
99/// Build an actionable hint string from unit status and journal output.
100fn diagnose(status: &UnitStatus, journal: Option<&str>) -> (String, String) {
101    let load_error = &status.load_error;
102
103    let error_msg = if !load_error.is_empty() {
104        load_error.clone()
105    } else {
106        format!(
107            "ActiveState={}, SubState={}",
108            status.active_state, status.sub_state
109        )
110    };
111
112    let hint = if load_error.contains("Invalid environment")
113        || load_error.contains("bad setting")
114        || load_error.contains("Bad message")
115    {
116        "Check your config environment variables. \
117         Environment keys must not contain newlines or '=' characters, \
118         and values must be valid UTF-8."
119            .to_string()
120    } else if load_error.contains("port") || load_error.contains("address") {
121        "A port specified in [network]ports may already be in use on the host. \
122         Ensure the port is available and not bound by another service."
123            .to_string()
124    } else if load_error.contains("permission") || load_error.contains("Permission") {
125        "systemd reported a permission error. \
126         Check that your home and mount directories are accessible."
127            .to_string()
128    } else if load_error.contains("mount")
129        || load_error.contains("volume")
130        || load_error.contains("Volume")
131    {
132        "A mount directory specified in your config may not exist. \
133         Verify your XDG and custom mount paths are correct."
134            .to_string()
135    } else if let Some(journal) = journal {
136        extract_hint_from_journal(journal)
137    } else {
138        "Run `podbox build --rebuild` to regenerate Quadlet files, \
139         then `podbox enable` to reinstall them."
140            .to_string()
141    };
142
143    (error_msg, hint)
144}
145
146fn extract_hint_from_journal(journal: &str) -> String {
147    for line in journal.lines() {
148        let lower = line.to_lowercase();
149        if lower.contains("oci runtime") || lower.contains("container create failed") {
150            return "An OCI runtime error occurred. \
151                     Check that your container image has all required dependencies \
152                     and that your mount paths are correct."
153                .to_string();
154        }
155        if lower.contains("permission denied") {
156            return "A permission error occurred. \
157                     Check that your home and mount directories have the correct permissions."
158                .to_string();
159        }
160        if lower.contains("port already in use")
161            || lower.contains("address already in use")
162            || lower.contains("listen failed")
163            || lower.contains("couldn't listen")
164        {
165            return "A mapped port is already in use on the host. \
166                     Change the host port in your config's [network]ports section."
167                .to_string();
168        }
169        if lower.contains("no such file") || lower.contains("not found") {
170            return "A file or directory referenced in the config was not found. \
171                     Verify all mount paths and the container image name."
172                .to_string();
173        }
174    }
175    "Run `podbox build --rebuild` to regenerate Quadlet files, \
176     then `podbox enable` to reinstall them."
177        .to_string()
178}
179
180/// Format a diagnostic card as a string.
181pub(crate) fn diagnostic_card(name: &str, status: &UnitStatus, journal: Option<&str>) -> String {
182    let (error_msg, hint) = diagnose(status, journal);
183
184    let error_line = format!("   LoadError: {error_msg}");
185
186    let unit_line = format!("  Unit:         {name}.service");
187    let load_line = format!("  LoadState:    {}", status.load_state);
188    let active_line = format!("  ActiveState:  {}", status.active_state);
189    let sub_line = format!("  SubState:     {}", status.sub_state);
190    let error_label = if error_msg.is_empty() {
191        String::new()
192    } else {
193        format!("\n  {error_line}")
194    };
195    let reload_line = if status.need_daemon_reload {
196        "\n  Note: systemd indicated NeedDaemonReload=yes. \
197         A daemon-reload was triggered.\n"
198            .to_string()
199    } else {
200        String::new()
201    };
202
203    let journal_section = match journal {
204        Some(j) if !j.trim().is_empty() => {
205            let lines: Vec<&str> = j.lines().collect();
206            let tail = if lines.len() > 10 {
207                &lines[lines.len() - 10..]
208            } else {
209                &lines
210            };
211            let body = tail
212                .iter()
213                .map(|l| format!("    {l}"))
214                .collect::<Vec<_>>()
215                .join("\n");
216            format!("\n  Journal (last {} lines):\n{}", tail.len(), body)
217        }
218        _ => String::new(),
219    };
220
221    format!(
222        "\nError: Container '{name}' failed to start.\n\
223         \n\
224         Diagnostics:\n\
225         {unit_line}\n\
226         {load_line}\n\
227         {active_line}\n\
228         {sub_line}{error_label}{reload_line}\
229         \n\
230         Hint: {hint}\
231         {journal_section}\n\
232         \n\
233         Run `podbox build --rebuild` and `podbox enable` to regenerate and \
234         reinstall Quadlet files, then try again.\n"
235    )
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241
242    fn sample_show_output() -> &'static str {
243        "LoadState=loaded\nActiveState=active\nSubState=running\nLoadError=\nNeedDaemonReload=no\n"
244    }
245
246    fn sample_show_bad_env() -> &'static str {
247        "LoadState=bad-setting\nActiveState=failed\nSubState=failed\nLoadError=Invalid environment assignment on line 23.\nNeedDaemonReload=no\n"
248    }
249
250    #[test]
251    fn parse_loaded_unit() {
252        let s = parse_unit_show(sample_show_output());
253        assert_eq!(s.load_state, "loaded");
254        assert_eq!(s.active_state, "active");
255        assert_eq!(s.sub_state, "running");
256        assert!(s.load_error.is_empty());
257        assert!(!s.need_daemon_reload);
258    }
259
260    #[test]
261    fn parse_bad_setting() {
262        let s = parse_unit_show(sample_show_bad_env());
263        assert_eq!(s.load_state, "bad-setting");
264        assert_eq!(s.active_state, "failed");
265        assert!(!s.load_error.is_empty());
266        assert!(s.load_error.contains("Invalid environment"));
267    }
268
269    #[test]
270    fn parse_with_daemon_reload() {
271        let raw = "LoadState=loaded\nActiveState=inactive\nSubState=dead\nLoadError=\nNeedDaemonReload=yes\n";
272        let s = parse_unit_show(raw);
273        assert!(s.need_daemon_reload);
274    }
275
276    #[test]
277    fn parse_empty_output() {
278        let s = parse_unit_show("");
279        assert!(s.load_state.is_empty());
280        assert!(!s.need_daemon_reload);
281    }
282
283    #[test]
284    fn diagnose_bad_environment() {
285        let s = parse_unit_show(sample_show_bad_env());
286        let (err, _hint) = diagnose(&s, None);
287        assert!(err.contains("Invalid environment"));
288    }
289
290    #[test]
291    fn diagnose_healthy_unit() {
292        let s = parse_unit_show(sample_show_output());
293        let (err, _hint) = diagnose(&s, None);
294        assert!(err.contains("ActiveState=active"));
295    }
296
297    #[test]
298    fn diagnostic_card_renders() {
299        let s = parse_unit_show(sample_show_bad_env());
300        let card = diagnostic_card("dev", &s, Some("test journal line\nanother line\n"));
301        assert!(card.contains("dev"));
302        assert!(card.contains("bad-setting"));
303        assert!(card.contains("Invalid environment"));
304        assert!(card.contains("Hint:"));
305    }
306
307    #[test]
308    fn diagnostic_card_with_journal() {
309        let s = UnitStatus::default();
310        let journal = "Jun 15 10:00:00 systemd[1]: podbox-dev.service: Failed with result exit-code.\nJun 15 10:00:00 systemd[1]: podbox-dev.service: Main process exited, code=exited, status=1/FAILURE\n";
311        let card = diagnostic_card("test", &s, Some(journal));
312        assert!(card.contains("Journal"));
313        assert!(card.contains("test"));
314    }
315}