use crate::error::{Error, Result};
use serde::{Deserialize, Serialize};
use std::process::Stdio;
use std::time::Duration;
use tokio::process::Command;
use tokio::time::timeout;
use tracing::{debug, info, warn};
use which::which;
pub const DEFAULT_PREREQ_TIMEOUT: Duration = Duration::from_secs(30);
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct DockerVersion {
pub version: String,
pub major: u32,
pub minor: u32,
pub patch: u32,
}
impl DockerVersion {
pub fn parse(version_str: &str) -> Result<Self> {
let clean_version = version_str.trim().trim_start_matches('v');
let parts: Vec<&str> = clean_version.split('.').collect();
if parts.len() < 3 {
return Err(Error::parse_error(format!(
"Invalid version format: {version_str}"
)));
}
let major = parts[0]
.parse()
.map_err(|_| Error::parse_error(format!("Invalid major version: {}", parts[0])))?;
let minor = parts[1]
.parse()
.map_err(|_| Error::parse_error(format!("Invalid minor version: {}", parts[1])))?;
let patch = parts[2]
.parse()
.map_err(|_| Error::parse_error(format!("Invalid patch version: {}", parts[2])))?;
Ok(Self {
version: clean_version.to_string(),
major,
minor,
patch,
})
}
#[must_use]
pub fn meets_minimum(&self, minimum: &DockerVersion) -> bool {
if self.major > minimum.major {
return true;
}
if self.major == minimum.major {
if self.minor > minimum.minor {
return true;
}
if self.minor == minimum.minor && self.patch >= minimum.patch {
return true;
}
}
false
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DockerInfo {
pub version: DockerVersion,
pub binary_path: String,
pub daemon_running: bool,
pub server_version: Option<DockerVersion>,
pub os: String,
pub architecture: String,
}
pub struct DockerPrerequisites {
pub minimum_version: DockerVersion,
pub timeout: Option<Duration>,
}
impl Default for DockerPrerequisites {
fn default() -> Self {
Self {
minimum_version: DockerVersion {
version: "20.10.0".to_string(),
major: 20,
minor: 10,
patch: 0,
},
timeout: None,
}
}
}
impl DockerPrerequisites {
#[must_use]
pub fn new(minimum_version: DockerVersion) -> Self {
Self {
minimum_version,
timeout: None,
}
}
#[must_use]
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
#[must_use]
pub fn with_timeout_secs(mut self, seconds: u64) -> Self {
self.timeout = Some(Duration::from_secs(seconds));
self
}
pub async fn check(&self) -> Result<DockerInfo> {
if let Some(timeout_duration) = self.timeout {
match timeout(timeout_duration, self.check_internal()).await {
Ok(result) => result,
Err(_) => Err(Error::timeout(timeout_duration.as_secs())),
}
} else {
self.check_internal().await
}
}
async fn check_internal(&self) -> Result<DockerInfo> {
info!("Checking Docker prerequisites...");
let binary_path = Self::find_docker_binary()?;
debug!("Found Docker binary at: {}", binary_path);
let version = self.get_docker_version(&binary_path).await?;
info!("Found Docker version: {}", version.version);
if !version.meets_minimum(&self.minimum_version) {
return Err(Error::UnsupportedVersion {
found: version.version.clone(),
minimum: self.minimum_version.version.clone(),
});
}
let (daemon_running, server_version) = self.check_daemon(&binary_path).await;
if daemon_running {
info!("Docker daemon is running");
} else {
warn!("Docker daemon is not running");
}
let (os, architecture) = Self::get_system_info();
Ok(DockerInfo {
version,
binary_path,
daemon_running,
server_version,
os,
architecture,
})
}
fn find_docker_binary() -> Result<String> {
let path = which("docker").map_err(|_| Error::DockerNotFound)?;
Ok(path.to_string_lossy().to_string())
}
async fn get_docker_version(&self, binary_path: &str) -> Result<DockerVersion> {
let output = Command::new(binary_path)
.args(["--version"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| Error::custom(format!("Failed to run 'docker --version': {e}")))?;
if !output.status.success() {
return Err(Error::command_failed(
"docker --version",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout).to_string(),
String::from_utf8_lossy(&output.stderr).to_string(),
));
}
let version_output = String::from_utf8_lossy(&output.stdout);
debug!("Docker version output: {}", version_output);
let version_str = version_output
.split_whitespace()
.nth(2)
.and_then(|v| v.split(',').next())
.ok_or_else(|| {
Error::parse_error(format!("Could not parse version from: {version_output}"))
})?;
DockerVersion::parse(version_str)
}
async fn check_daemon(&self, binary_path: &str) -> (bool, Option<DockerVersion>) {
let output = Command::new(binary_path)
.args(["version", "--format", "{{.Server.Version}}"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
match output {
Ok(output) if output.status.success() => {
let server_version_str = String::from_utf8_lossy(&output.stdout).trim().to_string();
if server_version_str.is_empty() {
(false, None)
} else {
match DockerVersion::parse(&server_version_str) {
Ok(version) => (true, Some(version)),
Err(_) => (true, None),
}
}
}
_ => (false, None),
}
}
fn get_system_info() -> (String, String) {
let os = std::env::consts::OS.to_string();
let arch = std::env::consts::ARCH.to_string();
(os, arch)
}
}
pub async fn ensure_docker() -> Result<DockerInfo> {
let checker = DockerPrerequisites::default();
checker.check().await
}
pub async fn ensure_docker_with_timeout(timeout: Duration) -> Result<DockerInfo> {
let checker = DockerPrerequisites::default().with_timeout(timeout);
checker.check().await
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_docker_version_parse() {
let version = DockerVersion::parse("24.0.7").unwrap();
assert_eq!(version.major, 24);
assert_eq!(version.minor, 0);
assert_eq!(version.patch, 7);
assert_eq!(version.version, "24.0.7");
}
#[test]
fn test_docker_version_parse_with_v_prefix() {
let version = DockerVersion::parse("v20.10.21").unwrap();
assert_eq!(version.major, 20);
assert_eq!(version.minor, 10);
assert_eq!(version.patch, 21);
assert_eq!(version.version, "20.10.21");
}
#[test]
fn test_docker_version_parse_invalid() {
assert!(DockerVersion::parse("invalid").is_err());
assert!(DockerVersion::parse("1.2").is_err());
assert!(DockerVersion::parse("a.b.c").is_err());
}
#[test]
fn test_version_meets_minimum() {
let current = DockerVersion::parse("24.0.7").unwrap();
let minimum = DockerVersion::parse("20.10.0").unwrap();
let too_high = DockerVersion::parse("25.0.0").unwrap();
assert!(current.meets_minimum(&minimum));
assert!(!current.meets_minimum(&too_high));
let exact = DockerVersion::parse("20.10.0").unwrap();
assert!(exact.meets_minimum(&minimum));
let newer_minor = DockerVersion::parse("20.11.0").unwrap();
let older_minor = DockerVersion::parse("20.9.0").unwrap();
assert!(newer_minor.meets_minimum(&minimum));
assert!(!older_minor.meets_minimum(&minimum));
let newer_patch = DockerVersion::parse("20.10.1").unwrap();
let older_patch = DockerVersion::parse("20.10.0").unwrap();
assert!(newer_patch.meets_minimum(&minimum));
assert!(older_patch.meets_minimum(&minimum)); }
#[test]
fn test_prerequisites_default() {
let prereqs = DockerPrerequisites::default();
assert_eq!(prereqs.minimum_version.version, "20.10.0");
}
#[test]
fn test_prerequisites_custom_minimum() {
let custom_version = DockerVersion::parse("25.0.0").unwrap();
let prereqs = DockerPrerequisites::new(custom_version.clone());
assert_eq!(prereqs.minimum_version, custom_version);
}
#[test]
fn test_prerequisites_timeout() {
let prereqs = DockerPrerequisites::default();
assert!(prereqs.timeout.is_none());
let prereqs_with_timeout =
DockerPrerequisites::default().with_timeout(Duration::from_secs(10));
assert_eq!(prereqs_with_timeout.timeout, Some(Duration::from_secs(10)));
let prereqs_with_secs = DockerPrerequisites::default().with_timeout_secs(30);
assert_eq!(prereqs_with_secs.timeout, Some(Duration::from_secs(30)));
}
#[tokio::test]
async fn test_ensure_docker_integration() {
let result = ensure_docker().await;
match result {
Ok(info) => {
assert!(!info.binary_path.is_empty());
assert!(!info.version.version.is_empty());
assert!(info.version.major >= 20);
println!(
"Docker found: {} at {}",
info.version.version, info.binary_path
);
if info.daemon_running {
println!("Docker daemon is running");
if let Some(server_version) = info.server_version {
println!("Server version: {}", server_version.version);
}
} else {
println!("Docker daemon is not running");
}
}
Err(Error::DockerNotFound) => {
println!("Docker not found - skipping integration test");
}
Err(e) => {
println!("Prerequisites check failed: {e}");
}
}
}
}