Skip to main content

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 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
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        #[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        // Get system info
197        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    /// Find Docker binary in PATH
210    ///
211    /// Uses the `which` crate for cross-platform binary lookup,
212    /// working on both Unix and Windows systems.
213    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    /// Get Docker client version
219    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        // Parse "Docker version 24.0.7, build afdd53b" format
241        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    /// Check if Docker daemon is running and get server version
253    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    /// Get system information
278    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
285/// Convenience function to check Docker prerequisites with default settings
286///
287/// # Errors
288/// Returns various `Error` variants if Docker is not available
289/// or does not meet minimum requirements
290pub async fn ensure_docker() -> Result<DockerInfo> {
291    let checker = DockerPrerequisites::default();
292    checker.check().await
293}
294
295/// Convenience function to check Docker prerequisites with a timeout
296///
297/// # Errors
298/// Returns various `Error` variants if Docker is not available,
299/// does not meet minimum requirements, or the check times out
300pub 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        // Test exact match
344        let exact = DockerVersion::parse("20.10.0").unwrap();
345        assert!(exact.meets_minimum(&minimum));
346
347        // Test minor version differences
348        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        // Test patch version differences
354        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)); // Equal should pass
358    }
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        // This is an integration test that requires Docker to be installed
389        // It will be skipped in environments without Docker
390        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}