use crate::downloadable_asset::DownloadableAsset; use inquire::Select; use log::{debug, error, info, trace, warn}; use std::collections::HashMap; use std::fmt; use std::path::PathBuf; use url::Url; pub struct Docker { base_dir: PathBuf, } #[derive(Debug, PartialEq)] pub enum DockerVariant { Standard, Rootless, Manual, } impl fmt::Display for DockerVariant { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { DockerVariant::Standard => write!(f, "Standard Docker (requires sudo)"), DockerVariant::Rootless => write!(f, "Rootless Docker (no sudo required)"), DockerVariant::Manual => { write!(f, "Exit and install manually (Docker or podman-docker)") } } } } impl Docker { pub fn new(base_dir: PathBuf) -> Self { Self { base_dir } } /// Provides the DOCKER_HOST and DOCKER_SOCK env vars for local usage. /// /// If a rootless Docker installation is detected in the user's home directory, /// it returns the appropriate `DOCKER_HOST` pointing to the user's Docker socket. /// Otherwise, it returns an empty HashMap, assuming the standard system-wide /// Docker installation is used. pub fn get_docker_env(&self) -> HashMap { let mut env = HashMap::new(); if let Ok(home) = std::env::var("HOME") { let rootless_sock = PathBuf::from(&home).join(".docker/run/docker.sock"); let rootless_bin = PathBuf::from(&home).join("bin/docker"); if rootless_bin.exists() && rootless_sock.exists() { let docker_host = format!("unix://{}", rootless_sock.display()); debug!( "Detected rootless Docker, setting DOCKER_HOST={}", docker_host ); env.insert("DOCKER_HOST".to_string(), docker_host); } } env } /// Gets the path to the docker binary pub fn get_bin_path(&self) -> PathBuf { // Check standard PATH first if let Ok(path) = std::process::Command::new("which") .arg("docker") .output() .map(|o| PathBuf::from(String::from_utf8_lossy(&o.stdout).trim())) { if path.exists() { debug!("Found Docker in PATH: {:?}", path); return path; } } // Check common rootless location if let Ok(home) = std::env::var("HOME") { let rootless_path = PathBuf::from(home).join("bin/docker"); if rootless_path.exists() { debug!("Found rootless Docker at: {:?}", rootless_path); return rootless_path; } } debug!("Docker not found in PATH or rootless location, using 'docker' from PATH"); PathBuf::from("docker") } /// Checks if docker is installed and available in the PATH pub fn is_installed(&self) -> bool { let bin_path = self.get_bin_path(); trace!("Checking if Docker is installed at: {:?}", bin_path); std::process::Command::new(&bin_path) .arg("--version") .output() .map(|output| { if output.status.success() { trace!("Docker version check successful"); true } else { trace!( "Docker version check failed with status: {:?}", output.status ); false } }) .map_err(|e| { trace!("Failed to execute Docker version check: {}", e); e }) .unwrap_or(false) } /// Prompts the user to choose an installation method fn prompt_for_installation(&self) -> DockerVariant { let options = vec![ DockerVariant::Standard, DockerVariant::Rootless, DockerVariant::Manual, ]; Select::new( "Docker binary was not found. How would you like to proceed?", options, ) .with_help_message("Standard requires sudo. Rootless runs in user space.") .prompt() .unwrap_or(DockerVariant::Manual) } /// Installs docker using the official shell script pub async fn install(&self, variant: DockerVariant) -> Result<(), String> { let (script_url, script_name, use_sudo) = match variant { DockerVariant::Standard => ("https://get.docker.com", "get-docker.sh", true), DockerVariant::Rootless => ( "https://get.docker.com/rootless", "get-docker-rootless.sh", false, ), DockerVariant::Manual => return Err("Manual installation selected".to_string()), }; info!("Installing {}...", variant); debug!("Downloading installation script from: {}", script_url); // Download the installation script let asset = DownloadableAsset { url: Url::parse(script_url).map_err(|e| { error!("Failed to parse installation script URL: {}", e); format!("Failed to parse installation script URL: {}", e) })?, file_name: script_name.to_string(), checksum: Some(String::new()), // Skip checksum verification for official scripts }; let downloaded_script = asset .download_to_path(self.base_dir.join("scripts")) .await .map_err(|e| { error!("Failed to download installation script: {}", e); format!("Failed to download installation script: {}", e) })?; debug!("Installation script downloaded to: {:?}", downloaded_script); // Execute the installation script let mut cmd = std::process::Command::new("sh"); if use_sudo { cmd.arg("sudo").arg("sh"); } cmd.arg(&downloaded_script); debug!("Executing installation command: {:?}", cmd); let status = cmd.status().map_err(|e| { error!("Failed to execute docker installation script: {}", e); format!("Failed to execute docker installation script: {}", e) })?; if status.success() { info!("{} installed successfully", variant); if variant == DockerVariant::Rootless { warn!("Please follow the instructions above to finish rootless setup (environment variables)."); } // Validate the installation by running hello-world self.validate_installation()?; Ok(()) } else { error!( "{} installation script failed with exit code: {:?}", variant, status.code() ); Err(format!("{} installation script failed", variant)) } } /// Validates the Docker installation by running a test container. /// /// This method runs `docker run --rm hello-world` to verify that Docker /// is properly installed and functional. fn validate_installation(&self) -> Result<(), String> { info!("Validating Docker installation by running hello-world container..."); let output = self .command() .args(["run", "--rm", "hello-world"]) .output() .map_err(|e| { error!("Failed to execute hello-world validation: {}", e); format!("Failed to execute hello-world validation: {}", e) })?; if output.status.success() { let stdout = String::from_utf8_lossy(&output.stdout); if stdout.contains("Hello from Docker!") { info!("Docker installation validated successfully"); trace!("Validation output: {}", stdout); Ok(()) } else { warn!("Hello-world container ran but expected output not found"); debug!("Output was: {}", stdout); Err("Docker validation failed: unexpected output from hello-world".to_string()) } } else { let stderr = String::from_utf8_lossy(&output.stderr); error!( "Hello-world validation failed with exit code: {:?}", output.status.code() ); debug!("Validation stderr: {}", stderr); if !stderr.is_empty() { Err(format!("Docker validation failed: {}", stderr.trim())) } else { Err( "Docker validation failed: hello-world container did not run successfully" .to_string(), ) } } } /// Ensures docker is installed, prompting if necessary pub async fn ensure_installed(&self) -> Result<(), String> { if self.is_installed() { debug!("Docker is already installed at: {:?}", self.get_bin_path()); return Ok(()); } debug!("Docker is not installed, prompting for installation method"); match self.prompt_for_installation() { DockerVariant::Manual => { info!("User chose manual installation"); Err("Docker installation cancelled by user. Please install docker or podman-docker manually.".to_string()) } variant => self.install(variant).await, } } /// Creates a pre-configured Command for running Docker commands. /// /// The returned Command is set up with: /// - The correct Docker binary path (handles rootless installations) /// - Appropriate environment variables (e.g., DOCKER_HOST for rootless) /// /// # Example /// /// ```no_run /// # use harmony_tools::Docker; /// # use std::path::PathBuf; /// # let docker = Docker::new(PathBuf::from(".")); /// let mut cmd = docker.command(); /// cmd.args(["ps", "-a"]); /// // Now cmd is ready to be executed /// ``` pub fn command(&self) -> std::process::Command { let bin_path = self.get_bin_path(); trace!("Creating Docker command with binary: {:?}", bin_path); let mut cmd = std::process::Command::new(&bin_path); // Add Docker-specific environment variables let env = self.get_docker_env(); if !env.is_empty() { trace!("Setting Docker environment variables: {:?}", env); for (key, value) in env { cmd.env(key, value); } } else { trace!("No Docker-specific environment variables to set"); } cmd } }