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#[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
20pub fn is_available() -> bool {
22 which::which("systemctl").is_ok()
23}
24
25pub 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
49pub 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
70pub 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
92pub 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
108pub 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
121pub 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
132pub 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
143fn 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
159fn 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
176pub 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
187pub 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
195pub 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
203pub 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
220pub 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
233pub 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
277pub 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
312fn 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
393fn 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
451pub fn start_unit_friendly(name: &str, timeout_secs: u64) -> Result<()> {
456 if !is_available() {
457 anyhow::bail!("systemctl not available");
458 }
459
460 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 }
470 }
471
472 reset_failed(name)?;
476
477 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 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 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
511fn 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}