1use crate::error::{Error, Result};
7use crate::tracing_compat::{debug, info, warn};
8use serde::{Deserialize, Serialize};
9use std::process::Stdio;
10use std::time::Duration;
11use tokio::process::Command;
12use tokio::time::timeout;
13use which::which;
14
15pub const DEFAULT_PREREQ_TIMEOUT: Duration = Duration::from_secs(30);
17
18#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct DockerVersion {
21 pub version: String,
23 pub major: u32,
25 pub minor: u32,
27 pub patch: u32,
29}
30
31impl DockerVersion {
32 pub fn parse(version_str: &str) -> Result<Self> {
37 let clean_version = version_str.trim().trim_start_matches('v');
38 let parts: Vec<&str> = clean_version.split('.').collect();
39
40 if parts.len() < 3 {
41 return Err(Error::parse_error(format!(
42 "Invalid version format: {version_str}"
43 )));
44 }
45
46 let major = parts[0]
47 .parse()
48 .map_err(|_| Error::parse_error(format!("Invalid major version: {}", parts[0])))?;
49
50 let minor = parts[1]
51 .parse()
52 .map_err(|_| Error::parse_error(format!("Invalid minor version: {}", parts[1])))?;
53
54 let patch = parts[2]
55 .parse()
56 .map_err(|_| Error::parse_error(format!("Invalid patch version: {}", parts[2])))?;
57
58 Ok(Self {
59 version: clean_version.to_string(),
60 major,
61 minor,
62 patch,
63 })
64 }
65
66 #[must_use]
68 pub fn meets_minimum(&self, minimum: &DockerVersion) -> bool {
69 if self.major > minimum.major {
70 return true;
71 }
72 if self.major == minimum.major {
73 if self.minor > minimum.minor {
74 return true;
75 }
76 if self.minor == minimum.minor && self.patch >= minimum.patch {
77 return true;
78 }
79 }
80 false
81 }
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DockerInfo {
87 pub version: DockerVersion,
89 pub binary_path: String,
91 pub daemon_running: bool,
93 pub server_version: Option<DockerVersion>,
95 pub os: String,
97 pub architecture: String,
99}
100
101pub struct DockerPrerequisites {
103 pub minimum_version: DockerVersion,
105 pub timeout: Option<Duration>,
107}
108
109impl Default for DockerPrerequisites {
110 fn default() -> Self {
111 Self {
112 minimum_version: DockerVersion {
113 version: "20.10.0".to_string(),
114 major: 20,
115 minor: 10,
116 patch: 0,
117 },
118 timeout: None,
119 }
120 }
121}
122
123impl DockerPrerequisites {
124 #[must_use]
126 pub fn new(minimum_version: DockerVersion) -> Self {
127 Self {
128 minimum_version,
129 timeout: None,
130 }
131 }
132
133 #[must_use]
138 pub fn with_timeout(mut self, timeout: Duration) -> Self {
139 self.timeout = Some(timeout);
140 self
141 }
142
143 #[must_use]
145 pub fn with_timeout_secs(mut self, seconds: u64) -> Self {
146 self.timeout = Some(Duration::from_secs(seconds));
147 self
148 }
149
150 pub async fn check(&self) -> Result<DockerInfo> {
156 if let Some(timeout_duration) = self.timeout {
157 match timeout(timeout_duration, self.check_internal()).await {
158 Ok(result) => result,
159 Err(_) => Err(Error::timeout(timeout_duration.as_secs())),
160 }
161 } else {
162 self.check_internal().await
163 }
164 }
165
166 async fn check_internal(&self) -> Result<DockerInfo> {
168 info!("Checking Docker prerequisites...");
169
170 let binary_path = Self::find_docker_binary()?;
172 debug!("Found Docker binary at: {}", binary_path);
173
174 let version = self.get_docker_version(&binary_path).await?;
176 info!("Found Docker version: {}", version.version);
177
178 if !version.meets_minimum(&self.minimum_version) {
180 return Err(Error::UnsupportedVersion {
181 found: version.version.clone(),
182 minimum: self.minimum_version.version.clone(),
183 });
184 }
185
186 let (daemon_running, server_version) = self.check_daemon(&binary_path).await;
188
189 #[cfg_attr(not(feature = "tracing"), allow(clippy::if_same_then_else))]
190 if daemon_running {
191 info!("Docker daemon is running");
192 } else {
193 warn!("Docker daemon is not running");
194 }
195
196 let (os, architecture) = Self::get_system_info();
198
199 Ok(DockerInfo {
200 version,
201 binary_path,
202 daemon_running,
203 server_version,
204 os,
205 architecture,
206 })
207 }
208
209 fn find_docker_binary() -> Result<String> {
214 let path = which("docker").map_err(|_| Error::DockerNotFound)?;
215 Ok(path.to_string_lossy().to_string())
216 }
217
218 async fn get_docker_version(&self, binary_path: &str) -> Result<DockerVersion> {
220 let output = Command::new(binary_path)
221 .args(["--version"])
222 .stdout(Stdio::piped())
223 .stderr(Stdio::piped())
224 .output()
225 .await
226 .map_err(|e| Error::custom(format!("Failed to run 'docker --version': {e}")))?;
227
228 if !output.status.success() {
229 return Err(Error::command_failed(
230 "docker --version",
231 output.status.code().unwrap_or(-1),
232 String::from_utf8_lossy(&output.stdout).to_string(),
233 String::from_utf8_lossy(&output.stderr).to_string(),
234 ));
235 }
236
237 let version_output = String::from_utf8_lossy(&output.stdout);
238 debug!("Docker version output: {}", version_output);
239
240 let version_str = version_output
242 .split_whitespace()
243 .nth(2)
244 .and_then(|v| v.split(',').next())
245 .ok_or_else(|| {
246 Error::parse_error(format!("Could not parse version from: {version_output}"))
247 })?;
248
249 DockerVersion::parse(version_str)
250 }
251
252 async fn check_daemon(&self, binary_path: &str) -> (bool, Option<DockerVersion>) {
254 let output = Command::new(binary_path)
255 .args(["version", "--format", "{{.Server.Version}}"])
256 .stdout(Stdio::piped())
257 .stderr(Stdio::piped())
258 .output()
259 .await;
260
261 match output {
262 Ok(output) if output.status.success() => {
263 let server_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
264 if server_version_str.is_empty() {
265 (false, None)
266 } else {
267 match DockerVersion::parse(&server_version_str) {
268 Ok(version) => (true, Some(version)),
269 Err(_) => (true, None),
270 }
271 }
272 }
273 _ => (false, None),
274 }
275 }
276
277 fn get_system_info() -> (String, String) {
279 let os = std::env::consts::OS.to_string();
280 let arch = std::env::consts::ARCH.to_string();
281 (os, arch)
282 }
283}
284
285pub async fn ensure_docker() -> Result<DockerInfo> {
291 let checker = DockerPrerequisites::default();
292 checker.check().await
293}
294
295pub async fn ensure_docker_with_timeout(timeout: Duration) -> Result<DockerInfo> {
301 let checker = DockerPrerequisites::default().with_timeout(timeout);
302 checker.check().await
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308
309 #[test]
310 fn test_docker_version_parse() {
311 let version = DockerVersion::parse("24.0.7").unwrap();
312 assert_eq!(version.major, 24);
313 assert_eq!(version.minor, 0);
314 assert_eq!(version.patch, 7);
315 assert_eq!(version.version, "24.0.7");
316 }
317
318 #[test]
319 fn test_docker_version_parse_with_v_prefix() {
320 let version = DockerVersion::parse("v20.10.21").unwrap();
321 assert_eq!(version.major, 20);
322 assert_eq!(version.minor, 10);
323 assert_eq!(version.patch, 21);
324 assert_eq!(version.version, "20.10.21");
325 }
326
327 #[test]
328 fn test_docker_version_parse_invalid() {
329 assert!(DockerVersion::parse("invalid").is_err());
330 assert!(DockerVersion::parse("1.2").is_err());
331 assert!(DockerVersion::parse("a.b.c").is_err());
332 }
333
334 #[test]
335 fn test_version_meets_minimum() {
336 let current = DockerVersion::parse("24.0.7").unwrap();
337 let minimum = DockerVersion::parse("20.10.0").unwrap();
338 let too_high = DockerVersion::parse("25.0.0").unwrap();
339
340 assert!(current.meets_minimum(&minimum));
341 assert!(!current.meets_minimum(&too_high));
342
343 let exact = DockerVersion::parse("20.10.0").unwrap();
345 assert!(exact.meets_minimum(&minimum));
346
347 let newer_minor = DockerVersion::parse("20.11.0").unwrap();
349 let older_minor = DockerVersion::parse("20.9.0").unwrap();
350 assert!(newer_minor.meets_minimum(&minimum));
351 assert!(!older_minor.meets_minimum(&minimum));
352
353 let newer_patch = DockerVersion::parse("20.10.1").unwrap();
355 let older_patch = DockerVersion::parse("20.10.0").unwrap();
356 assert!(newer_patch.meets_minimum(&minimum));
357 assert!(older_patch.meets_minimum(&minimum)); }
359
360 #[test]
361 fn test_prerequisites_default() {
362 let prereqs = DockerPrerequisites::default();
363 assert_eq!(prereqs.minimum_version.version, "20.10.0");
364 }
365
366 #[test]
367 fn test_prerequisites_custom_minimum() {
368 let custom_version = DockerVersion::parse("25.0.0").unwrap();
369 let prereqs = DockerPrerequisites::new(custom_version.clone());
370 assert_eq!(prereqs.minimum_version, custom_version);
371 }
372
373 #[test]
374 fn test_prerequisites_timeout() {
375 let prereqs = DockerPrerequisites::default();
376 assert!(prereqs.timeout.is_none());
377
378 let prereqs_with_timeout =
379 DockerPrerequisites::default().with_timeout(Duration::from_secs(10));
380 assert_eq!(prereqs_with_timeout.timeout, Some(Duration::from_secs(10)));
381
382 let prereqs_with_secs = DockerPrerequisites::default().with_timeout_secs(30);
383 assert_eq!(prereqs_with_secs.timeout, Some(Duration::from_secs(30)));
384 }
385
386 #[tokio::test]
387 async fn test_ensure_docker_integration() {
388 let result = ensure_docker().await;
391
392 match result {
393 Ok(info) => {
394 assert!(!info.binary_path.is_empty());
395 assert!(!info.version.version.is_empty());
396 assert!(info.version.major >= 20);
397 println!(
398 "Docker found: {} at {}",
399 info.version.version, info.binary_path
400 );
401
402 if info.daemon_running {
403 println!("Docker daemon is running");
404 if let Some(server_version) = info.server_version {
405 println!("Server version: {}", server_version.version);
406 }
407 } else {
408 println!("Docker daemon is not running");
409 }
410 }
411 Err(Error::DockerNotFound) => {
412 println!("Docker not found - skipping integration test");
413 }
414 Err(e) => {
415 println!("Prerequisites check failed: {e}");
416 }
417 }
418 }
419}