1use crate::error::{Error, Result};
7use serde::{Deserialize, Serialize};
8use std::process::Stdio;
9use std::time::Duration;
10use tokio::process::Command;
11use tokio::time::timeout;
12use tracing::{debug, info, warn};
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 if daemon_running {
190 info!("Docker daemon is running");
191 } else {
192 warn!("Docker daemon is not running");
193 }
194
195 let (os, architecture) = Self::get_system_info();
197
198 Ok(DockerInfo {
199 version,
200 binary_path,
201 daemon_running,
202 server_version,
203 os,
204 architecture,
205 })
206 }
207
208 fn find_docker_binary() -> Result<String> {
213 let path = which("docker").map_err(|_| Error::DockerNotFound)?;
214 Ok(path.to_string_lossy().to_string())
215 }
216
217 async fn get_docker_version(&self, binary_path: &str) -> Result<DockerVersion> {
219 let output = Command::new(binary_path)
220 .args(["--version"])
221 .stdout(Stdio::piped())
222 .stderr(Stdio::piped())
223 .output()
224 .await
225 .map_err(|e| Error::custom(format!("Failed to run 'docker --version': {e}")))?;
226
227 if !output.status.success() {
228 return Err(Error::command_failed(
229 "docker --version",
230 output.status.code().unwrap_or(-1),
231 String::from_utf8_lossy(&output.stdout).to_string(),
232 String::from_utf8_lossy(&output.stderr).to_string(),
233 ));
234 }
235
236 let version_output = String::from_utf8_lossy(&output.stdout);
237 debug!("Docker version output: {}", version_output);
238
239 let version_str = version_output
241 .split_whitespace()
242 .nth(2)
243 .and_then(|v| v.split(',').next())
244 .ok_or_else(|| {
245 Error::parse_error(format!("Could not parse version from: {version_output}"))
246 })?;
247
248 DockerVersion::parse(version_str)
249 }
250
251 async fn check_daemon(&self, binary_path: &str) -> (bool, Option<DockerVersion>) {
253 let output = Command::new(binary_path)
254 .args(["version", "--format", "{{.Server.Version}}"])
255 .stdout(Stdio::piped())
256 .stderr(Stdio::piped())
257 .output()
258 .await;
259
260 match output {
261 Ok(output) if output.status.success() => {
262 let server_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
263 if server_version_str.is_empty() {
264 (false, None)
265 } else {
266 match DockerVersion::parse(&server_version_str) {
267 Ok(version) => (true, Some(version)),
268 Err(_) => (true, None),
269 }
270 }
271 }
272 _ => (false, None),
273 }
274 }
275
276 fn get_system_info() -> (String, String) {
278 let os = std::env::consts::OS.to_string();
279 let arch = std::env::consts::ARCH.to_string();
280 (os, arch)
281 }
282}
283
284pub async fn ensure_docker() -> Result<DockerInfo> {
290 let checker = DockerPrerequisites::default();
291 checker.check().await
292}
293
294pub async fn ensure_docker_with_timeout(timeout: Duration) -> Result<DockerInfo> {
300 let checker = DockerPrerequisites::default().with_timeout(timeout);
301 checker.check().await
302}
303
304#[cfg(test)]
305mod tests {
306 use super::*;
307
308 #[test]
309 fn test_docker_version_parse() {
310 let version = DockerVersion::parse("24.0.7").unwrap();
311 assert_eq!(version.major, 24);
312 assert_eq!(version.minor, 0);
313 assert_eq!(version.patch, 7);
314 assert_eq!(version.version, "24.0.7");
315 }
316
317 #[test]
318 fn test_docker_version_parse_with_v_prefix() {
319 let version = DockerVersion::parse("v20.10.21").unwrap();
320 assert_eq!(version.major, 20);
321 assert_eq!(version.minor, 10);
322 assert_eq!(version.patch, 21);
323 assert_eq!(version.version, "20.10.21");
324 }
325
326 #[test]
327 fn test_docker_version_parse_invalid() {
328 assert!(DockerVersion::parse("invalid").is_err());
329 assert!(DockerVersion::parse("1.2").is_err());
330 assert!(DockerVersion::parse("a.b.c").is_err());
331 }
332
333 #[test]
334 fn test_version_meets_minimum() {
335 let current = DockerVersion::parse("24.0.7").unwrap();
336 let minimum = DockerVersion::parse("20.10.0").unwrap();
337 let too_high = DockerVersion::parse("25.0.0").unwrap();
338
339 assert!(current.meets_minimum(&minimum));
340 assert!(!current.meets_minimum(&too_high));
341
342 let exact = DockerVersion::parse("20.10.0").unwrap();
344 assert!(exact.meets_minimum(&minimum));
345
346 let newer_minor = DockerVersion::parse("20.11.0").unwrap();
348 let older_minor = DockerVersion::parse("20.9.0").unwrap();
349 assert!(newer_minor.meets_minimum(&minimum));
350 assert!(!older_minor.meets_minimum(&minimum));
351
352 let newer_patch = DockerVersion::parse("20.10.1").unwrap();
354 let older_patch = DockerVersion::parse("20.10.0").unwrap();
355 assert!(newer_patch.meets_minimum(&minimum));
356 assert!(older_patch.meets_minimum(&minimum)); }
358
359 #[test]
360 fn test_prerequisites_default() {
361 let prereqs = DockerPrerequisites::default();
362 assert_eq!(prereqs.minimum_version.version, "20.10.0");
363 }
364
365 #[test]
366 fn test_prerequisites_custom_minimum() {
367 let custom_version = DockerVersion::parse("25.0.0").unwrap();
368 let prereqs = DockerPrerequisites::new(custom_version.clone());
369 assert_eq!(prereqs.minimum_version, custom_version);
370 }
371
372 #[test]
373 fn test_prerequisites_timeout() {
374 let prereqs = DockerPrerequisites::default();
375 assert!(prereqs.timeout.is_none());
376
377 let prereqs_with_timeout =
378 DockerPrerequisites::default().with_timeout(Duration::from_secs(10));
379 assert_eq!(prereqs_with_timeout.timeout, Some(Duration::from_secs(10)));
380
381 let prereqs_with_secs = DockerPrerequisites::default().with_timeout_secs(30);
382 assert_eq!(prereqs_with_secs.timeout, Some(Duration::from_secs(30)));
383 }
384
385 #[tokio::test]
386 async fn test_ensure_docker_integration() {
387 let result = ensure_docker().await;
390
391 match result {
392 Ok(info) => {
393 assert!(!info.binary_path.is_empty());
394 assert!(!info.version.version.is_empty());
395 assert!(info.version.major >= 20);
396 println!(
397 "Docker found: {} at {}",
398 info.version.version, info.binary_path
399 );
400
401 if info.daemon_running {
402 println!("Docker daemon is running");
403 if let Some(server_version) = info.server_version {
404 println!("Server version: {}", server_version.version);
405 }
406 } else {
407 println!("Docker daemon is not running");
408 }
409 }
410 Err(Error::DockerNotFound) => {
411 println!("Docker not found - skipping integration test");
412 }
413 Err(e) => {
414 println!("Prerequisites check failed: {e}");
415 }
416 }
417 }
418}