Skip to main content

podbox/
systemd.rs

1use std::process::Command;
2use std::time::{Duration, Instant};
3
4use anyhow::{Context, Result};
5
6use crate::podman::{ContainerState, query_state};
7
8const POLL_INTERVAL_MS: u64 = 300;
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/// Whether systemctl is available on this system.
21pub fn is_available() -> bool {
22    which::which("systemctl").is_ok()
23}
24
25/// Ensure linger is enabled for the current user.
26pub fn enable_linger() -> Result<()> {
27    let whoami = std::env::var("USER").unwrap_or_default();
28    if whoami.is_empty() || which::which("loginctl").is_err() {
29        return Ok(());
30    }
31    let mut cmd = Command::new("loginctl");
32    cmd.args(["enable-linger", &whoami]);
33    let output = cmd
34        .stdout(std::process::Stdio::piped())
35        .stderr(std::process::Stdio::piped())
36        .spawn()
37        .context("failed to spawn loginctl")?
38        .wait_with_output()
39        .context("loginctl command failed")?;
40    if !output.status.success() {
41        let stderr = String::from_utf8_lossy(&output.stderr);
42        eprintln!("Warning: enable-linger failed: {}", stderr);
43    } else {
44        println!("Linger enabled for user.");
45    }
46    Ok(())
47}
48
49/// Run `systemctl --user daemon-reload`.
50pub fn daemon_reload() -> Result<()> {
51    if !is_available() {
52        return Ok(());
53    }
54    let mut cmd = Command::new("systemctl");
55    cmd.args(["--user", "daemon-reload"]);
56    let output = cmd
57        .stdout(std::process::Stdio::piped())
58        .stderr(std::process::Stdio::piped())
59        .spawn()
60        .context("failed to spawn systemctl daemon-reload")?
61        .wait_with_output()
62        .context("systemctl daemon-reload failed")?;
63    if !output.status.success() {
64        let stderr = String::from_utf8_lossy(&output.stderr);
65        anyhow::bail!("daemon-reload failed: {}", stderr.trim());
66    }
67    Ok(())
68}
69
70/// Run `systemctl --user reset-failed` for a container's units.
71pub fn reset_failed(name: &str) -> Result<()> {
72    if !is_available() {
73        return Ok(());
74    }
75    let unit_names = [
76        format!("{}.service", name),
77        format!("{}.socket", name),
78        format!("{}-host.service", name),
79        format!("{}-proxy.service", name),
80    ];
81    for unit in &unit_names {
82        let mut cmd = Command::new("systemctl");
83        cmd.args(["--user", "reset-failed", unit])
84            .stdout(std::process::Stdio::null())
85            .stderr(std::process::Stdio::null());
86        let _ = cmd.status();
87    }
88    Ok(())
89}
90
91/// Start and enable a socket unit.
92pub fn enable_now_socket(name: &str) -> Result<()> {
93    if !is_available() {
94        return Ok(());
95    }
96    let mut cmd = Command::new("systemctl");
97    cmd.args(["--user", "enable", "--now", &format!("{}.socket", name)]);
98    let _ = cmd.status();
99    Ok(())
100}
101
102/// Stop socket and host service units.
103pub fn stop_socket_and_host(name: &str) -> Result<()> {
104    if !is_available() {
105        return Ok(());
106    }
107    for unit in [format!("{}.socket", name), format!("{}-host.service", name)] {
108        let mut cmd = Command::new("systemctl");
109        cmd.args(["--user", "stop", &unit]);
110        let _ = cmd.status();
111    }
112    Ok(())
113}
114
115/// Stop the Wayland compositor proxy service if it exists.
116pub fn stop_compositor(name: &str) -> Result<()> {
117    if !is_available() {
118        return Ok(());
119    }
120    let mut cmd = Command::new("systemctl");
121    cmd.args(["--user", "stop", &format!("{}-compositor.service", name)]);
122    let _ = cmd.status();
123    Ok(())
124}
125
126/// Start a service unit via `systemctl --user start`.
127pub fn start_unit(name: &str) -> Result<()> {
128    let mut cmd = Command::new("systemctl");
129    cmd.args(["--user", "start", &format!("{}.service", name)]);
130    let status = cmd.status().context("failed to spawn systemctl start")?;
131    if !status.success() {
132        anyhow::bail!("systemctl start failed for '{}.service'", name);
133    }
134    Ok(())
135}
136
137/// Stop a service unit via `systemctl --user stop`.
138pub fn stop_unit(name: &str) -> Result<()> {
139    let mut cmd = Command::new("systemctl");
140    cmd.args(["--user", "stop", &format!("{}.service", name)]);
141    cmd.status()?;
142    Ok(())
143}
144
145/// Restart a service unit via `systemctl --user restart`.
146pub fn restart_unit(name: &str) -> Result<()> {
147    let mut cmd = Command::new("systemctl");
148    cmd.args(["--user", "restart", &format!("{}.service", name)]);
149    cmd.status()?;
150    Ok(())
151}
152
153/// Check whether a unit is enabled in systemd.
154pub fn is_unit_enabled(name: &str) -> bool {
155    if !is_available() {
156        return false;
157    }
158    Command::new("systemctl")
159        .args([
160            "--user",
161            "--quiet",
162            "is-enabled",
163            &format!("{}.service", name),
164        ])
165        .status()
166        .map(|s| s.success())
167        .unwrap_or(false)
168}
169
170/// Check whether a unit is in the failed state.
171pub fn is_unit_failed(name: &str) -> bool {
172    if !is_available() {
173        return false;
174    }
175    Command::new("systemctl")
176        .args(["--user", "is-failed", &format!("{}.service", name)])
177        .output()
178        .ok()
179        .map(|o| String::from_utf8_lossy(&o.stdout).trim() == "failed")
180        .unwrap_or(false)
181}
182
183/// Query systemd unit properties via `systemctl --user show`.
184pub fn query_unit_status(name: &str) -> Result<UnitStatus> {
185    let mut cmd = Command::new("systemctl");
186    cmd.args([
187        "--user",
188        "show",
189        &format!("{}.service", name),
190        "--property=LoadState,ActiveState,SubState,LoadError,NeedDaemonReload",
191    ]);
192    let output = cmd
193        .stdout(std::process::Stdio::piped())
194        .stderr(std::process::Stdio::piped())
195        .spawn()
196        .context("failed to spawn systemctl show")?
197        .wait_with_output()
198        .context("systemctl show failed")?;
199
200    if !output.status.success() && output.stdout.is_empty() {
201        let stderr = String::from_utf8_lossy(&output.stderr);
202        anyhow::bail!("unit '{}' not found by systemd: {}", name, stderr.trim());
203    }
204
205    Ok(parse_unit_show(&String::from_utf8_lossy(&output.stdout)))
206}
207
208fn parse_unit_show(raw: &str) -> UnitStatus {
209    let mut status = UnitStatus::default();
210    for line in raw.lines() {
211        let (key, value) = match line.split_once('=') {
212            Some(kv) => kv,
213            None => continue,
214        };
215        match key {
216            "LoadState" => status.load_state = value.to_string(),
217            "ActiveState" => status.active_state = value.to_string(),
218            "SubState" => status.sub_state = value.to_string(),
219            "LoadError" => status.load_error = value.to_string(),
220            "NeedDaemonReload" => status.need_daemon_reload = value == "yes",
221            _ => {}
222        }
223    }
224    status
225}
226
227/// Tail journal logs for a container's service units.
228pub fn journal_tail(name: &str, n: u32) -> Result<String> {
229    if which::which("journalctl").is_err() {
230        anyhow::bail!("journalctl not available");
231    }
232    let mut cmd = Command::new("journalctl");
233    cmd.args([
234        "--user",
235        "-u",
236        &format!("{}.service", name),
237        "-n",
238        &n.to_string(),
239        "--no-pager",
240        "--output=short",
241    ]);
242    let output = cmd
243        .stdout(std::process::Stdio::piped())
244        .stderr(std::process::Stdio::piped())
245        .spawn()
246        .context("failed to spawn journalctl")?
247        .wait_with_output()
248        .context("journalctl failed")?;
249
250    if !output.status.success() {
251        let stderr = String::from_utf8_lossy(&output.stderr);
252        anyhow::bail!("journalctl failed: {}", stderr.trim());
253    }
254
255    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
256    if stdout.trim().is_empty() {
257        anyhow::bail!("no journal entries found");
258    }
259    Ok(stdout)
260}
261
262/// Build an actionable hint string from unit status and journal output.
263fn diagnose(status: &UnitStatus, journal: Option<&str>) -> (String, String) {
264    let load_error = &status.load_error;
265
266    let error_msg = if !load_error.is_empty() {
267        load_error.clone()
268    } else {
269        format!(
270            "ActiveState={}, SubState={}",
271            status.active_state, status.sub_state
272        )
273    };
274
275    let hint = if load_error.contains("Invalid environment")
276        || load_error.contains("bad setting")
277        || load_error.contains("Bad message")
278    {
279        "Check your config environment variables. \
280         Environment keys must not contain newlines or '=' characters, \
281         and values must be valid UTF-8."
282            .to_string()
283    } else if load_error.contains("port") || load_error.contains("address") {
284        "A port specified in [network]ports may already be in use on the host. \
285         Ensure the port is available and not bound by another service."
286            .to_string()
287    } else if load_error.contains("permission") || load_error.contains("Permission") {
288        "systemd reported a permission error. \
289         Check that your home and mount directories are accessible."
290            .to_string()
291    } else if load_error.contains("mount")
292        || load_error.contains("volume")
293        || load_error.contains("Volume")
294    {
295        "A mount directory specified in your config may not exist. \
296         Verify your XDG and custom mount paths are correct."
297            .to_string()
298    } else if let Some(journal) = journal {
299        extract_hint_from_journal(journal)
300    } else {
301        "Run `podbox build --rebuild` to regenerate Quadlet files, \
302         then `podbox enable` to reinstall them."
303            .to_string()
304    };
305
306    (error_msg, hint)
307}
308
309fn extract_hint_from_journal(journal: &str) -> String {
310    for line in journal.lines() {
311        let lower = line.to_lowercase();
312        if lower.contains("oci runtime") || lower.contains("container create failed") {
313            return "An OCI runtime error occurred. \
314                     Check that your container image has all required dependencies \
315                     and that your mount paths are correct."
316                .to_string();
317        }
318        if lower.contains("permission denied") {
319            return "A permission error occurred. \
320                     Check that your home and mount directories have the correct permissions."
321                .to_string();
322        }
323        if lower.contains("port already in use")
324            || lower.contains("address already in use")
325            || lower.contains("listen failed")
326            || lower.contains("couldn't listen")
327        {
328            return "A mapped port is already in use on the host. \
329                     Change the host port in your config's [network]ports section."
330                .to_string();
331        }
332        if lower.contains("no such file") || lower.contains("not found") {
333            return "A file or directory referenced in the config was not found. \
334                     Verify all mount paths and the container image name."
335                .to_string();
336        }
337    }
338    "Run `podbox build --rebuild` to regenerate Quadlet files, \
339     then `podbox enable` to reinstall them."
340        .to_string()
341}
342
343/// Format a diagnostic card as a string.
344fn diagnostic_card(name: &str, status: &UnitStatus, journal: Option<&str>) -> String {
345    let (error_msg, hint) = diagnose(status, journal);
346
347    let error_line = format!("   LoadError: {}", error_msg);
348
349    let unit_line = format!("  Unit:         {}.service", name);
350    let load_line = format!("  LoadState:    {}", status.load_state);
351    let active_line = format!("  ActiveState:  {}", status.active_state);
352    let sub_line = format!("  SubState:     {}", status.sub_state);
353    let error_label = if error_msg.is_empty() {
354        String::new()
355    } else {
356        format!("\n  {}", error_line)
357    };
358    let reload_line = if status.need_daemon_reload {
359        "\n  Note: systemd indicated NeedDaemonReload=yes. \
360         A daemon-reload was triggered.\n"
361            .to_string()
362    } else {
363        String::new()
364    };
365
366    let journal_section = match journal {
367        Some(j) if !j.trim().is_empty() => {
368            let lines: Vec<&str> = j.lines().collect();
369            let tail = if lines.len() > 10 {
370                &lines[lines.len() - 10..]
371            } else {
372                &lines
373            };
374            let body = tail
375                .iter()
376                .map(|l| format!("    {}", l))
377                .collect::<Vec<_>>()
378                .join("\n");
379            format!("\n  Journal (last {} lines):\n{}", tail.len(), body)
380        }
381        _ => String::new(),
382    };
383
384    format!(
385        "\nError: Container '{name}' failed to start.\n\
386         \n\
387         Diagnostics:\n\
388         {unit_line}\n\
389         {load_line}\n\
390         {active_line}\n\
391         {sub_line}{error_label}{reload_line}\
392         \n\
393         Hint: {hint}\
394         {journal_section}\n\
395         \n\
396         Run `podbox build --rebuild` and `podbox enable` to regenerate and \
397         reinstall Quadlet files, then try again.\n"
398    )
399}
400
401/// Start a container with friendly diagnostics on failure.
402///
403/// Checks for `NeedDaemonReload` and auto-fixes it. If the start fails,
404/// queries systemd and journalctl to build a diagnostic card for the user.
405pub fn start_unit_friendly(name: &str, timeout_secs: u64) -> Result<()> {
406    if !is_available() {
407        anyhow::bail!("systemctl not available");
408    }
409
410    // Check if daemon-reload is needed first
411    match query_unit_status(name) {
412        Ok(status) if status.need_daemon_reload => {
413            tracing::info!("systemd needs reload — running daemon-reload...");
414            daemon_reload()?;
415        }
416        Ok(_) => {}
417        Err(_) => {
418            // Unit might not exist yet — that's fine, we're about to try starting.
419        }
420    }
421
422    // Clear any previous failure so a unit that landed in `failed` (e.g. from
423    // an idle stop or a transient error) can be started again without the
424    // user having to run `systemctl --user reset-failed` manually.
425    reset_failed(name)?;
426
427    // Try to start
428    let start_result = (|| -> Result<()> {
429        start_unit(name)?;
430        wait_for_running(name, timeout_secs)
431    })();
432
433    match start_result {
434        Ok(()) => Ok(()),
435        Err(_) => {
436            // Gather diagnostics
437            let status = query_unit_status(name).unwrap_or_default();
438            let journal = journal_tail(name, 10).ok();
439            let card = diagnostic_card(name, &status, journal.as_deref());
440            eprintln!("{}", card);
441            anyhow::bail!("container '{}' failed to start", name);
442        }
443    }
444}
445
446/// Poll until the container reaches Running state or timeout.
447fn wait_for_running(name: &str, timeout_secs: u64) -> Result<()> {
448    let deadline = Instant::now() + Duration::from_secs(timeout_secs);
449    loop {
450        match query_state(name)? {
451            ContainerState::Running => return Ok(()),
452            _ if Instant::now() >= deadline => {
453                let state = query_state(name)?;
454                anyhow::bail!(
455                    "container '{}' did not become ready within {}s (final state: {:?})",
456                    name,
457                    timeout_secs,
458                    state,
459                );
460            }
461            _ => {
462                std::thread::sleep(Duration::from_millis(POLL_INTERVAL_MS));
463            }
464        }
465    }
466}
467
468#[cfg(test)]
469mod tests {
470    use super::*;
471
472    fn sample_show_output() -> &'static str {
473        "LoadState=loaded\nActiveState=active\nSubState=running\nLoadError=\nNeedDaemonReload=no\n"
474    }
475
476    fn sample_show_bad_env() -> &'static str {
477        "LoadState=bad-setting\nActiveState=failed\nSubState=failed\nLoadError=Invalid environment assignment on line 23.\nNeedDaemonReload=no\n"
478    }
479
480    #[test]
481    fn parse_loaded_unit() {
482        let s = parse_unit_show(sample_show_output());
483        assert_eq!(s.load_state, "loaded");
484        assert_eq!(s.active_state, "active");
485        assert_eq!(s.sub_state, "running");
486        assert!(s.load_error.is_empty());
487        assert!(!s.need_daemon_reload);
488    }
489
490    #[test]
491    fn parse_bad_setting() {
492        let s = parse_unit_show(sample_show_bad_env());
493        assert_eq!(s.load_state, "bad-setting");
494        assert_eq!(s.active_state, "failed");
495        assert!(!s.load_error.is_empty());
496        assert!(s.load_error.contains("Invalid environment"));
497    }
498
499    #[test]
500    fn parse_with_daemon_reload() {
501        let raw = "LoadState=loaded\nActiveState=inactive\nSubState=dead\nLoadError=\nNeedDaemonReload=yes\n";
502        let s = parse_unit_show(raw);
503        assert!(s.need_daemon_reload);
504    }
505
506    #[test]
507    fn parse_empty_output() {
508        let s = parse_unit_show("");
509        assert!(s.load_state.is_empty());
510        assert!(!s.need_daemon_reload);
511    }
512
513    #[test]
514    fn diagnose_bad_environment() {
515        let s = parse_unit_show(sample_show_bad_env());
516        let (err, _hint) = diagnose(&s, None);
517        assert!(err.contains("Invalid environment"));
518    }
519
520    #[test]
521    fn diagnose_healthy_unit() {
522        let s = parse_unit_show(sample_show_output());
523        let (err, _hint) = diagnose(&s, None);
524        assert!(err.contains("ActiveState=active"));
525    }
526
527    #[test]
528    fn diagnostic_card_renders() {
529        let s = parse_unit_show(sample_show_bad_env());
530        let card = diagnostic_card("dev", &s, Some("test journal line\nanother line\n"));
531        assert!(card.contains("dev"));
532        assert!(card.contains("bad-setting"));
533        assert!(card.contains("Invalid environment"));
534        assert!(card.contains("Hint:"));
535    }
536
537    #[test]
538    fn diagnostic_card_with_journal() {
539        let s = UnitStatus::default();
540        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";
541        let card = diagnostic_card("test", &s, Some(journal));
542        assert!(card.contains("Journal"));
543        assert!(card.contains("test"));
544    }
545}