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!("{}.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
91pub 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
102pub 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
115pub 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
126pub 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
137pub 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
145pub 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
153pub 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
170pub 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
183pub 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
227pub 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
262fn 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
343fn 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
401pub fn start_unit_friendly(name: &str, timeout_secs: u64) -> Result<()> {
406 if !is_available() {
407 anyhow::bail!("systemctl not available");
408 }
409
410 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 }
420 }
421
422 reset_failed(name)?;
426
427 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 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
446fn 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}