Skip to main content

podbox/
podman.rs

1use std::process::Command;
2use std::sync::Mutex;
3
4use crate::error::PodboxError;
5
6#[derive(Debug, Clone, PartialEq, Eq)]
7pub struct PodmanVersion {
8    pub major: u32,
9    pub minor: u32,
10    pub patch: u32,
11}
12
13impl PodmanVersion {
14    pub fn at_least(&self, major: u32, minor: u32) -> bool {
15        (self.major, self.minor) >= (major, minor)
16    }
17}
18
19static PODMAN_VERSION: Mutex<Option<PodmanVersion>> = Mutex::new(None);
20
21fn parse_version_string(s: &str) -> PodmanVersion {
22    let s = s.trim();
23    // Strip anything after a space (e.g. "5.3.0 (ok)" -> "5.3.0")
24    let s = s.split_whitespace().next().unwrap_or(s);
25    // Strip Debian-style epoch prefix (e.g. "100:5.3.0" -> "5.3.0")
26    let s = s.rsplit_once(':').map(|(_, after)| after).unwrap_or(s);
27    let mut parts = s.splitn(3, '.');
28    PodmanVersion {
29        major: parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
30        minor: parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
31        patch: parts.next().and_then(|p| p.parse().ok()).unwrap_or(0),
32    }
33}
34
35pub fn podman_version() -> anyhow::Result<PodmanVersion> {
36    let mut cache = PODMAN_VERSION.lock().unwrap();
37    if let Some(ref ver) = *cache {
38        return Ok(ver.clone());
39    }
40
41    // Prefer structured output from `podman version -f` (more reliable
42    // across distro packaging, e.g. Debian epoch suffixes).
43    let structured = Command::new("podman")
44        .args(["version", "-f", "{{.Client.Version}}"])
45        .output()
46        .ok()
47        .filter(|o| o.status.success());
48    let version_str = match structured {
49        Some(ref output) => String::from_utf8_lossy(&output.stdout).trim().to_string(),
50        None => {
51            // Fallback: parse `podman --version` (e.g. "podman version 5.3.0")
52            let output = Command::new("podman").args(["--version"]).output()?;
53            let stdout = String::from_utf8_lossy(&output.stdout);
54            stdout.split_whitespace().last().unwrap_or("").to_string()
55        }
56    };
57    let ver = parse_version_string(&version_str);
58    *cache = Some(ver.clone());
59    Ok(ver)
60}
61
62#[cfg(test)]
63#[allow(dead_code)]
64pub(crate) fn set_test_version(ver: PodmanVersion) {
65    let mut cache = PODMAN_VERSION.lock().unwrap();
66    *cache = Some(ver);
67}
68
69#[cfg(test)]
70mod tests {
71    use super::*;
72
73    #[test]
74    fn parses_standard_version() {
75        let v = parse_version_string("5.3.0");
76        assert_eq!(
77            v,
78            PodmanVersion {
79                major: 5,
80                minor: 3,
81                patch: 0
82            }
83        );
84    }
85
86    #[test]
87    fn parses_version_with_epoch() {
88        let v = parse_version_string("100:5.3.0");
89        assert_eq!(
90            v,
91            PodmanVersion {
92                major: 5,
93                minor: 3,
94                patch: 0
95            }
96        );
97    }
98
99    #[test]
100    fn parses_version_with_parenthetical() {
101        let v = parse_version_string("5.3.0 (ok)");
102        assert_eq!(
103            v,
104            PodmanVersion {
105                major: 5,
106                minor: 3,
107                patch: 0
108            }
109        );
110    }
111
112    #[test]
113    fn parses_version_with_leading_trailing_whitespace() {
114        let v = parse_version_string("  5.3.0  ");
115        assert_eq!(
116            v,
117            PodmanVersion {
118                major: 5,
119                minor: 3,
120                patch: 0
121            }
122        );
123    }
124
125    #[test]
126    fn handles_single_component() {
127        let v = parse_version_string("5");
128        assert_eq!(
129            v,
130            PodmanVersion {
131                major: 5,
132                minor: 0,
133                patch: 0
134            }
135        );
136    }
137
138    #[test]
139    fn handles_two_components() {
140        let v = parse_version_string("5.3");
141        assert_eq!(
142            v,
143            PodmanVersion {
144                major: 5,
145                minor: 3,
146                patch: 0
147            }
148        );
149    }
150
151    #[test]
152    fn handles_empty_string() {
153        let v = parse_version_string("");
154        assert_eq!(
155            v,
156            PodmanVersion {
157                major: 0,
158                minor: 0,
159                patch: 0
160            }
161        );
162    }
163
164    #[test]
165    fn handles_non_numeric() {
166        let v = parse_version_string("abc");
167        assert_eq!(
168            v,
169            PodmanVersion {
170                major: 0,
171                minor: 0,
172                patch: 0
173            }
174        );
175    }
176
177    #[test]
178    fn handles_partial_numeric() {
179        let v = parse_version_string("5.abc.0");
180        assert_eq!(
181            v,
182            PodmanVersion {
183                major: 5,
184                minor: 0,
185                patch: 0
186            }
187        );
188    }
189
190    #[test]
191    fn version_at_least_works() {
192        let v = PodmanVersion {
193            major: 5,
194            minor: 6,
195            patch: 0,
196        };
197        assert!(v.at_least(5, 6));
198        assert!(v.at_least(4, 0));
199        assert!(v.at_least(5, 5));
200        assert!(!v.at_least(5, 7));
201        assert!(!v.at_least(6, 0));
202    }
203}
204
205#[derive(Debug, PartialEq, Eq, Clone, Copy)]
206pub enum ContainerState {
207    Running,
208    Stopped,
209    Missing,
210}
211
212/// Check whether a local image tag exists.
213pub fn image_exists(tag: &str) -> anyhow::Result<bool> {
214    let output = std::process::Command::new("podman")
215        .args(["image", "exists", tag])
216        .output()?;
217    Ok(output.status.success())
218}
219
220/// Fetch OCI labels for a local image.
221pub fn image_labels(tag: &str) -> anyhow::Result<std::collections::HashMap<String, String>> {
222    let output = std::process::Command::new("podman")
223        .args([
224            "inspect",
225            "--type",
226            "image",
227            "--format",
228            "{{json .Labels}}",
229            tag,
230        ])
231        .output()?;
232    if !output.status.success() {
233        return Ok(std::collections::HashMap::new());
234    }
235    let stdout = String::from_utf8_lossy(&output.stdout);
236    let map: std::collections::HashMap<String, String> =
237        serde_json::from_str(stdout.trim()).unwrap_or_default();
238    Ok(map)
239}
240
241/// Query the state of a container.
242///
243/// Checks `podman inspect` first.  If the container is unknown to podman,
244/// falls back to `systemctl --user is-active` (quadlet-managed containers)
245/// and returns `Stopped` when the unit exists but is inactive.
246pub fn query_state(name: &str) -> anyhow::Result<ContainerState> {
247    let output = Command::new("podman")
248        .args([
249            "inspect",
250            "--type",
251            "container",
252            "--format",
253            "{{.State.Status}}",
254            name,
255        ])
256        .output()?;
257
258    if !output.status.success() {
259        let stderr = String::from_utf8_lossy(&output.stderr);
260        if stderr.contains("no such container") || stderr.contains("no such object") {
261            // Fallback: check systemd for Quadlet-managed containers
262            if let Ok(sys) = Command::new("systemctl")
263                .args(["--user", "is-active", &format!("{name}.service")])
264                .output()
265            {
266                let state = String::from_utf8_lossy(&sys.stdout).trim().to_string();
267                match state.as_str() {
268                    "active" => {
269                        // Systemd says active, but verify the podman container
270                        // actually exists. Quadlet's OnStop=remove can delete
271                        // the container while the unit is restarting. If the
272                        // container is truly gone, return Stopped so that
273                        // ensure_running() tries to start it.
274                        if let Ok(out) = Command::new("podman")
275                            .args(["ps", "-q", "--filter", &format!("name=^{name}$")])
276                            .output()
277                        {
278                            if out.status.success()
279                                && !String::from_utf8_lossy(&out.stdout).trim().is_empty()
280                            {
281                                return Ok(ContainerState::Running);
282                            }
283                        }
284                        return Ok(ContainerState::Stopped);
285                    }
286                    "inactive" | "failed" => return Ok(ContainerState::Stopped),
287                    _ => {
288                        // "unknown" — unit not loaded.  Check whether quadlet
289                        // files exist; if so the container was built but is
290                        // stopped (OnStop=remove may have removed the podman
291                        // object while leaving the quadlet infrastructure).
292                        if crate::quadlet_install::is_installed(name) {
293                            return Ok(ContainerState::Stopped);
294                        }
295                        return Ok(ContainerState::Missing);
296                    }
297                };
298            }
299            // systemctl not available — check quadlet files as last resort
300            if crate::quadlet_install::is_installed(name) {
301                return Ok(ContainerState::Stopped);
302            }
303            return Ok(ContainerState::Missing);
304        }
305        return Err(PodboxError::PodmanInspectFailed {
306            name: name.into(),
307            stderr: stderr.to_string(),
308        }
309        .into());
310    }
311
312    let stdout = String::from_utf8_lossy(&output.stdout)
313        .trim()
314        .to_lowercase();
315    match stdout.as_str() {
316        "running" => Ok(ContainerState::Running),
317        "stopped" | "exited" => Ok(ContainerState::Stopped),
318        _ => Ok(ContainerState::Stopped),
319    }
320}
321
322/// Get the digest of a built image.
323pub fn image_digest(tag: &str) -> anyhow::Result<String> {
324    let output = Command::new("podman")
325        .args(["inspect", "--format", "{{.Digest}}", tag])
326        .output()?;
327
328    if !output.status.success() {
329        let stderr = String::from_utf8_lossy(&output.stderr);
330        return Err(PodboxError::PodmanInspectFailed {
331            name: tag.into(),
332            stderr: stderr.to_string(),
333        }
334        .into());
335    }
336
337    Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
338}