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