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 tokio::process::Command;
10use tracing::{debug, info, warn};
11
12/// Docker version information
13#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
14pub struct DockerVersion {
15    /// Full version string (e.g., "24.0.7")
16    pub version: String,
17    /// Major version number
18    pub major: u32,
19    /// Minor version number
20    pub minor: u32,
21    /// Patch version number
22    pub patch: u32,
23}
24
25impl DockerVersion {
26    /// Parse a Docker version string
27    ///
28    /// # Errors
29    /// Returns `Error::ParseError` if the version string is invalid
30    pub fn parse(version_str: &str) -> Result<Self> {
31        let clean_version = version_str.trim().trim_start_matches('v');
32        let parts: Vec<&str> = clean_version.split('.').collect();
33
34        if parts.len() < 3 {
35            return Err(Error::parse_error(format!(
36                "Invalid version format: {version_str}"
37            )));
38        }
39
40        let major = parts[0]
41            .parse()
42            .map_err(|_| Error::parse_error(format!("Invalid major version: {}", parts[0])))?;
43
44        let minor = parts[1]
45            .parse()
46            .map_err(|_| Error::parse_error(format!("Invalid minor version: {}", parts[1])))?;
47
48        let patch = parts[2]
49            .parse()
50            .map_err(|_| Error::parse_error(format!("Invalid patch version: {}", parts[2])))?;
51
52        Ok(Self {
53            version: clean_version.to_string(),
54            major,
55            minor,
56            patch,
57        })
58    }
59
60    /// Check if this version meets the minimum requirement
61    #[must_use]
62    pub fn meets_minimum(&self, minimum: &DockerVersion) -> bool {
63        if self.major > minimum.major {
64            return true;
65        }
66        if self.major == minimum.major {
67            if self.minor > minimum.minor {
68                return true;
69            }
70            if self.minor == minimum.minor && self.patch >= minimum.patch {
71                return true;
72            }
73        }
74        false
75    }
76}
77
78/// Docker system information
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct DockerInfo {
81    /// Docker version
82    pub version: DockerVersion,
83    /// Docker binary path
84    pub binary_path: String,
85    /// Whether Docker daemon is running
86    pub daemon_running: bool,
87    /// Docker server version (if daemon is running)
88    pub server_version: Option<DockerVersion>,
89    /// Operating system
90    pub os: String,
91    /// Architecture
92    pub architecture: String,
93}
94
95/// Main prerequisites checker
96pub struct DockerPrerequisites {
97    /// Minimum required Docker version
98    pub minimum_version: DockerVersion,
99}
100
101impl Default for DockerPrerequisites {
102    fn default() -> Self {
103        Self {
104            minimum_version: DockerVersion {
105                version: "20.10.0".to_string(),
106                major: 20,
107                minor: 10,
108                patch: 0,
109            },
110        }
111    }
112}
113
114impl DockerPrerequisites {
115    /// Create a new prerequisites checker with custom minimum version
116    #[must_use]
117    pub fn new(minimum_version: DockerVersion) -> Self {
118        Self { minimum_version }
119    }
120
121    /// Check all Docker prerequisites
122    ///
123    /// # Errors
124    /// Returns various `Error` variants if Docker is not found,
125    /// daemon is not running, or version requirements are not met
126    pub async fn check(&self) -> Result<DockerInfo> {
127        info!("Checking Docker prerequisites...");
128
129        // Find Docker binary
130        let binary_path = self.find_docker_binary().await?;
131        debug!("Found Docker binary at: {}", binary_path);
132
133        // Get Docker version
134        let version = self.get_docker_version(&binary_path).await?;
135        info!("Found Docker version: {}", version.version);
136
137        // Check version compatibility
138        if !version.meets_minimum(&self.minimum_version) {
139            return Err(Error::UnsupportedVersion {
140                found: version.version.clone(),
141                minimum: self.minimum_version.version.clone(),
142            });
143        }
144
145        // Check if daemon is running
146        let (daemon_running, server_version) = self.check_daemon(&binary_path).await;
147
148        if daemon_running {
149            info!("Docker daemon is running");
150        } else {
151            warn!("Docker daemon is not running");
152        }
153
154        // Get system info
155        let (os, architecture) = Self::get_system_info();
156
157        Ok(DockerInfo {
158            version,
159            binary_path,
160            daemon_running,
161            server_version,
162            os,
163            architecture,
164        })
165    }
166
167    /// Find Docker binary in PATH
168    async fn find_docker_binary(&self) -> Result<String> {
169        let output = Command::new("which")
170            .arg("docker")
171            .stdout(Stdio::piped())
172            .stderr(Stdio::piped())
173            .output()
174            .await
175            .map_err(|e| Error::custom(format!("Failed to run 'which docker': {e}")))?;
176
177        if !output.status.success() {
178            return Err(Error::DockerNotFound);
179        }
180
181        let path = String::from_utf8_lossy(&output.stdout).trim().to_string();
182        if path.is_empty() {
183            return Err(Error::DockerNotFound);
184        }
185
186        Ok(path)
187    }
188
189    /// Get Docker client version
190    async fn get_docker_version(&self, binary_path: &str) -> Result<DockerVersion> {
191        let output = Command::new(binary_path)
192            .args(["--version"])
193            .stdout(Stdio::piped())
194            .stderr(Stdio::piped())
195            .output()
196            .await
197            .map_err(|e| Error::custom(format!("Failed to run 'docker --version': {e}")))?;
198
199        if !output.status.success() {
200            return Err(Error::command_failed(
201                "docker --version",
202                output.status.code().unwrap_or(-1),
203                String::from_utf8_lossy(&output.stdout).to_string(),
204                String::from_utf8_lossy(&output.stderr).to_string(),
205            ));
206        }
207
208        let version_output = String::from_utf8_lossy(&output.stdout);
209        debug!("Docker version output: {}", version_output);
210
211        // Parse "Docker version 24.0.7, build afdd53b" format
212        let version_str = version_output
213            .split_whitespace()
214            .nth(2)
215            .and_then(|v| v.split(',').next())
216            .ok_or_else(|| {
217                Error::parse_error(format!("Could not parse version from: {version_output}"))
218            })?;
219
220        DockerVersion::parse(version_str)
221    }
222
223    /// Check if Docker daemon is running and get server version
224    async fn check_daemon(&self, binary_path: &str) -> (bool, Option<DockerVersion>) {
225        let output = Command::new(binary_path)
226            .args(["version", "--format", "{{.Server.Version}}"])
227            .stdout(Stdio::piped())
228            .stderr(Stdio::piped())
229            .output()
230            .await;
231
232        match output {
233            Ok(output) if output.status.success() => {
234                let server_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
235                if server_version_str.is_empty() {
236                    (false, None)
237                } else {
238                    match DockerVersion::parse(&server_version_str) {
239                        Ok(version) => (true, Some(version)),
240                        Err(_) => (true, None),
241                    }
242                }
243            }
244            _ => (false, None),
245        }
246    }
247
248    /// Get system information
249    fn get_system_info() -> (String, String) {
250        let os = std::env::consts::OS.to_string();
251        let arch = std::env::consts::ARCH.to_string();
252        (os, arch)
253    }
254}
255
256/// Convenience function to check Docker prerequisites with default settings
257///
258/// # Errors
259/// Returns various `Error` variants if Docker is not available
260/// or does not meet minimum requirements
261pub async fn ensure_docker() -> Result<DockerInfo> {
262    let checker = DockerPrerequisites::default();
263    checker.check().await
264}
265
266#[cfg(test)]
267mod tests {
268    use super::*;
269
270    #[test]
271    fn test_docker_version_parse() {
272        let version = DockerVersion::parse("24.0.7").unwrap();
273        assert_eq!(version.major, 24);
274        assert_eq!(version.minor, 0);
275        assert_eq!(version.patch, 7);
276        assert_eq!(version.version, "24.0.7");
277    }
278
279    #[test]
280    fn test_docker_version_parse_with_v_prefix() {
281        let version = DockerVersion::parse("v20.10.21").unwrap();
282        assert_eq!(version.major, 20);
283        assert_eq!(version.minor, 10);
284        assert_eq!(version.patch, 21);
285        assert_eq!(version.version, "20.10.21");
286    }
287
288    #[test]
289    fn test_docker_version_parse_invalid() {
290        assert!(DockerVersion::parse("invalid").is_err());
291        assert!(DockerVersion::parse("1.2").is_err());
292        assert!(DockerVersion::parse("a.b.c").is_err());
293    }
294
295    #[test]
296    fn test_version_meets_minimum() {
297        let current = DockerVersion::parse("24.0.7").unwrap();
298        let minimum = DockerVersion::parse("20.10.0").unwrap();
299        let too_high = DockerVersion::parse("25.0.0").unwrap();
300
301        assert!(current.meets_minimum(&minimum));
302        assert!(!current.meets_minimum(&too_high));
303
304        // Test exact match
305        let exact = DockerVersion::parse("20.10.0").unwrap();
306        assert!(exact.meets_minimum(&minimum));
307
308        // Test minor version differences
309        let newer_minor = DockerVersion::parse("20.11.0").unwrap();
310        let older_minor = DockerVersion::parse("20.9.0").unwrap();
311        assert!(newer_minor.meets_minimum(&minimum));
312        assert!(!older_minor.meets_minimum(&minimum));
313
314        // Test patch version differences
315        let newer_patch = DockerVersion::parse("20.10.1").unwrap();
316        let older_patch = DockerVersion::parse("20.10.0").unwrap();
317        assert!(newer_patch.meets_minimum(&minimum));
318        assert!(older_patch.meets_minimum(&minimum)); // Equal should pass
319    }
320
321    #[test]
322    fn test_prerequisites_default() {
323        let prereqs = DockerPrerequisites::default();
324        assert_eq!(prereqs.minimum_version.version, "20.10.0");
325    }
326
327    #[test]
328    fn test_prerequisites_custom_minimum() {
329        let custom_version = DockerVersion::parse("25.0.0").unwrap();
330        let prereqs = DockerPrerequisites::new(custom_version.clone());
331        assert_eq!(prereqs.minimum_version, custom_version);
332    }
333
334    #[tokio::test]
335    async fn test_ensure_docker_integration() {
336        // This is an integration test that requires Docker to be installed
337        // It will be skipped in environments without Docker
338        let result = ensure_docker().await;
339
340        match result {
341            Ok(info) => {
342                assert!(!info.binary_path.is_empty());
343                assert!(!info.version.version.is_empty());
344                assert!(info.version.major >= 20);
345                println!(
346                    "Docker found: {} at {}",
347                    info.version.version, info.binary_path
348                );
349
350                if info.daemon_running {
351                    println!("Docker daemon is running");
352                    if let Some(server_version) = info.server_version {
353                        println!("Server version: {}", server_version.version);
354                    }
355                } else {
356                    println!("Docker daemon is not running");
357                }
358            }
359            Err(Error::DockerNotFound) => {
360                println!("Docker not found - skipping integration test");
361            }
362            Err(e) => {
363                println!("Prerequisites check failed: {e}");
364            }
365        }
366    }
367}