1use std::process::Command;
7
8use anyhow::{Context, Result};
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(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
64pub(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
99fn 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
180pub(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}