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 let s = s.split_whitespace().next().unwrap_or(s);
25 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 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 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
212pub 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
220pub 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
241pub 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 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 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 if crate::quadlet_install::is_installed(name) {
293 return Ok(ContainerState::Stopped);
294 }
295 return Ok(ContainerState::Missing);
296 }
297 };
298 }
299 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
322pub 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}