docker_wrapper/
prerequisites.rs

1//! Prerequisites module for Docker detection and validation.
2//!
3//! This module provides functionality to detect Docker installation,
4//! validate version compatibility, and ensure the Docker daemon is running.
5
6use 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
15/// Default timeout for prerequisite checks (30 seconds)
16pub const DEFAULT_PREREQ_TIMEOUT: Duration = Duration::from_secs(30);
17
18/// Docker version information
19#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
20pub struct DockerVersion {
21    /// Full version string (e.g., "24.0.7")
22    pub version: String,
23    /// Major version number
24    pub major: u32,
25    /// Minor version number
26    pub minor: u32,
27    /// Patch version number
28    pub patch: u32,
29}
30
31impl DockerVersion {
32    /// Parse a Docker version string
33    ///
34    /// # Errors
35    /// Returns `Error::ParseError` if the version string is invalid
36    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    /// Check if this version meets the minimum requirement
67    #[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/// Docker system information
85#[derive(Debug, Clone, Serialize, Deserialize)]
86pub struct DockerInfo {
87    /// Docker version
88    pub version: DockerVersion,
89    /// Docker binary path
90    pub binary_path: String,
91    /// Whether Docker daemon is running
92    pub daemon_running: bool,
93    /// Docker server version (if daemon is running)
94    pub server_version: Option<DockerVersion>,
95    /// Operating system
96    pub os: String,
97    /// Architecture
98    pub architecture: String,
99}
100
101/// Main prerequisites checker
102pub struct DockerPrerequisites {
103    /// Minimum required Docker version
104    pub minimum_version: DockerVersion,
105    /// Timeout for prerequisite checks
106    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    /// Create a new prerequisites checker with custom minimum version
125    #[must_use]
126    pub fn new(minimum_version: DockerVersion) -> Self {
127        Self {
128            minimum_version,
129            timeout: None,
130        }
131    }
132
133    /// Set a timeout for prerequisite checks
134    ///
135    /// If any check takes longer than the specified duration, it will be
136    /// terminated and an `Error::Timeout` will be returned.
137    #[must_use]
138    pub fn with_timeout(mut self, timeout: Duration) -> Self {
139        self.timeout = Some(timeout);
140        self
141    }
142
143    /// Set a timeout in seconds for prerequisite checks
144    #[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    /// Check all Docker prerequisites
151    ///
152    /// # Errors
153    /// Returns various `Error` variants if Docker is not found,
154    /// daemon is not running, version requirements are not met, or the check times out
155    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    /// Internal check implementation without timeout wrapper
167    async fn check_internal(&self) -> Result<DockerInfo> {
168        info!("Checking Docker prerequisites...");
169
170        // Find Docker binary
171        let binary_path = Self::find_docker_binary()?;
172        debug!("Found Docker binary at: {}", binary_path);
173
174        // Get Docker version
175        let version = self.get_docker_version(&binary_path).await?;
176        info!("Found Docker version: {}", version.version);
177
178        // Check version compatibility
179        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        // Check if daemon is running
187        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        // Get system info
196        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    /// Find Docker binary in PATH
209    ///
210    /// Uses the `which` crate for cross-platform binary lookup,
211    /// working on both Unix and Windows systems.
212    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    /// Get Docker client version
218    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        // Parse "Docker version 24.0.7, build afdd53b" format
240        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    /// Check if Docker daemon is running and get server version
252    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    /// Get system information
277    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
284/// Convenience function to check Docker prerequisites with default settings
285///
286/// # Errors
287/// Returns various `Error` variants if Docker is not available
288/// or does not meet minimum requirements
289pub async fn ensure_docker() -> Result<DockerInfo> {
290    let checker = DockerPrerequisites::default();
291    checker.check().await
292}
293
294/// Convenience function to check Docker prerequisites with a timeout
295///
296/// # Errors
297/// Returns various `Error` variants if Docker is not available,
298/// does not meet minimum requirements, or the check times out
299pub 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        // Test exact match
343        let exact = DockerVersion::parse("20.10.0").unwrap();
344        assert!(exact.meets_minimum(&minimum));
345
346        // Test minor version differences
347        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        // Test patch version differences
353        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)); // Equal should pass
357    }
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        // This is an integration test that requires Docker to be installed
388        // It will be skipped in environments without Docker
389        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}