From 8d27ecf6de4fd9cc338ae3a0946f53ba0fbb8978 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Mon, 19 Jan 2026 12:06:03 -0500 Subject: [PATCH] feat: Autoinstall docker --- Cargo.toml | 2 +- harmony/Cargo.toml | 16 +- harmony/src/domain/topology/docker/mod.rs | 11 + harmony/src/domain/topology/k8s.rs | 2 +- .../topology/k8s_anywhere/k8s_anywhere.rs | 32 +- harmony/src/domain/topology/mod.rs | 2 + harmony/src/modules/docker.rs | 79 +++++ harmony/src/modules/k3d/install.rs | 17 +- harmony/src/modules/mod.rs | 1 + {k3d => harmony_tools}/Cargo.toml | 4 +- harmony_tools/src/docker.rs | 300 ++++++++++++++++++ .../src/downloadable_asset.rs | 75 ++++- k3d/src/lib.rs => harmony_tools/src/k3d.rs | 8 +- harmony_tools/src/lib.rs | 6 + 14 files changed, 523 insertions(+), 32 deletions(-) create mode 100644 harmony/src/domain/topology/docker/mod.rs create mode 100644 harmony/src/modules/docker.rs rename {k3d => harmony_tools}/Cargo.toml (83%) create mode 100644 harmony_tools/src/docker.rs rename {k3d => harmony_tools}/src/downloadable_asset.rs (79%) rename k3d/src/lib.rs => harmony_tools/src/k3d.rs (99%) create mode 100644 harmony_tools/src/lib.rs diff --git a/Cargo.toml b/Cargo.toml index a256234..580c34b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -10,7 +10,7 @@ members = [ "opnsense-config", "opnsense-config-xml", "harmony_cli", - "k3d", + "harmony_tools", "harmony_composer", "harmony_inventory_agent", "harmony_secret_derive", diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 634cbe9..0fe3ac9 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -9,6 +9,14 @@ license.workspace = true testing = [] [dependencies] +opnsense-config = { path = "../opnsense-config" } +opnsense-config-xml = { path = "../opnsense-config-xml" } +harmony_macros = { path = "../harmony_macros" } +harmony_types = { path = "../harmony_types" } +harmony_inventory_agent = { path = "../harmony_inventory_agent" } +harmony_secret_derive = { path = "../harmony_secret_derive" } +harmony_secret = { path = "../harmony_secret" } +harmony_tools = { path = "../harmony_tools" } hex = "0.4" reqwest = { version = "0.11", features = [ "blocking", @@ -26,10 +34,6 @@ log.workspace = true env_logger.workspace = true async-trait.workspace = true cidr.workspace = true -opnsense-config = { path = "../opnsense-config" } -opnsense-config-xml = { path = "../opnsense-config-xml" } -harmony_macros = { path = "../harmony_macros" } -harmony_types = { path = "../harmony_types" } uuid.workspace = true url.workspace = true kube = { workspace = true, features = ["derive"] } @@ -39,7 +43,6 @@ http.workspace = true serde-value.workspace = true helm-wrapper-rs = "0.4.0" non-blank-string-rs = "1.0.4" -k3d-rs = { path = "../k3d" } directories.workspace = true lazy_static.workspace = true dockerfile_builder = "0.1.5" @@ -71,9 +74,6 @@ base64.workspace = true thiserror.workspace = true once_cell = "1.21.3" walkdir = "2.5.0" -harmony_inventory_agent = { path = "../harmony_inventory_agent" } -harmony_secret_derive = { path = "../harmony_secret_derive" } -harmony_secret = { path = "../harmony_secret" } askama.workspace = true sqlx.workspace = true inquire.workspace = true diff --git a/harmony/src/domain/topology/docker/mod.rs b/harmony/src/domain/topology/docker/mod.rs new file mode 100644 index 0000000..a994076 --- /dev/null +++ b/harmony/src/domain/topology/docker/mod.rs @@ -0,0 +1,11 @@ +use async_trait::async_trait; + +use std::collections::HashMap; + +/// Docker Capability +#[async_trait] +pub trait Docker { + async fn ensure_installed(&self) -> Result<(), String>; + fn get_docker_env(&self) -> HashMap; + fn docker_command(&self) -> std::process::Command; +} diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index ef34f3c..9d1e226 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -16,7 +16,7 @@ use kube::{ Api, AttachParams, DeleteParams, ListParams, ObjectList, Patch, PatchParams, ResourceExt, }, config::{KubeConfigOptions, Kubeconfig}, - core::{DynamicResourceScope, ErrorResponse}, + core::ErrorResponse, discovery::{ApiCapabilities, Scope}, error::DiscoveryError, runtime::reflector::Lookup, diff --git a/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs index 22dfaad..486b1f7 100644 --- a/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs +++ b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs @@ -1,7 +1,13 @@ -use std::{collections::BTreeMap, process::Command, sync::Arc, time::Duration}; +use std::{ + collections::{BTreeMap, HashMap}, + process::Command, + sync::Arc, + time::Duration, +}; use async_trait::async_trait; use base64::{Engine, engine::general_purpose}; +use harmony_tools::K3d; use harmony_types::rfc1123::Rfc1123Name; use k8s_openapi::api::{ core::v1::Secret, @@ -13,10 +19,12 @@ use serde::Serialize; use tokio::sync::OnceCell; use crate::{ + config::HARMONY_DATA_DIR, executors::ExecutorError, interpret::InterpretStatus, inventory::Inventory, modules::{ + docker::DockerInstallationScore, k3d::K3DInstallationScore, k8s::ingress::{K8sIngressScore, PathType}, monitoring::{ @@ -42,7 +50,7 @@ use crate::{ }, }, score::Score, - topology::{TlsRoute, TlsRouter, ingress::Ingress}, + topology::{Docker, TlsRoute, TlsRouter, ingress::Ingress}, }; use super::super::{ @@ -350,6 +358,24 @@ impl PrometheusMonitoring for K8sAnywhereTopology { } } +#[async_trait] +impl Docker for K8sAnywhereTopology { + async fn ensure_installed(&self) -> Result<(), String> { + DockerInstallationScore::default() + .interpret(&Inventory::empty(), self) + .await + .map_err(|e| format!("Could not ensure docker is installed : {e}"))?; + Ok(()) + } + fn get_docker_env(&self) -> HashMap { + harmony_tools::Docker::new(HARMONY_DATA_DIR.join("docker")).get_docker_env() + } + + fn docker_command(&self) -> std::process::Command { + harmony_tools::Docker::new(HARMONY_DATA_DIR.join("docker")).command() + } +} + impl Serialize for K8sAnywhereTopology { fn serialize(&self, _serializer: S) -> Result where @@ -737,7 +763,7 @@ impl K8sAnywhereTopology { // K3DInstallationScore should expose a method to get_client ? Not too sure what would be a // good implementation due to the stateful nature of the k3d thing. Which is why I went // with this solution for now - let k3d = k3d_rs::K3d::new(k3d_score.installation_path, Some(k3d_score.cluster_name)); + let k3d = K3d::new(k3d_score.installation_path, Some(k3d_score.cluster_name)); let state = match k3d.get_client().await { Ok(client) => K8sState { client: Arc::new(K8sClient::new(client)), diff --git a/harmony/src/domain/topology/mod.rs b/harmony/src/domain/topology/mod.rs index 42add5c..409c3fb 100644 --- a/harmony/src/domain/topology/mod.rs +++ b/harmony/src/domain/topology/mod.rs @@ -1,8 +1,10 @@ +mod docker; mod failover; mod ha_cluster; pub mod ingress; pub mod node_exporter; pub mod opnsense; +pub use docker::*; pub use failover::*; use harmony_types::net::IpAddress; mod host_binding; diff --git a/harmony/src/modules/docker.rs b/harmony/src/modules/docker.rs new file mode 100644 index 0000000..06c649c --- /dev/null +++ b/harmony/src/modules/docker.rs @@ -0,0 +1,79 @@ +use std::path::PathBuf; + +use async_trait::async_trait; +use log::debug; +use serde::Serialize; + +use crate::{ + config::HARMONY_DATA_DIR, + data::Version, + interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, + inventory::Inventory, + score::Score, + topology::{Docker, Topology}, +}; +use harmony_types::id::Id; + +#[derive(Debug, Clone, Serialize)] +pub struct DockerInstallationScore { + pub installation_path: PathBuf, +} + +impl Default for DockerInstallationScore { + fn default() -> Self { + Self { + installation_path: HARMONY_DATA_DIR.join("docker"), + } + } +} + +impl Score for DockerInstallationScore { + fn create_interpret(&self) -> Box> { + Box::new(DockerInstallationInterpret { + score: self.clone(), + }) + } + + fn name(&self) -> String { + "DockerInstallationScore".into() + } +} + +#[derive(Debug)] +pub struct DockerInstallationInterpret { + score: DockerInstallationScore, +} + +#[async_trait] +impl Interpret for DockerInstallationInterpret { + async fn execute( + &self, + _inventory: &Inventory, + _topology: &T, + ) -> Result { + let docker = harmony_tools::Docker::new(self.score.installation_path.clone()); + + match docker.ensure_installed().await { + Ok(_) => { + let msg = "Docker is installed and ready".to_string(); + debug!("{msg}"); + Ok(Outcome::success(msg)) + } + Err(msg) => Err(InterpretError::new(format!( + "failed to ensure docker is installed : {msg}" + ))), + } + } + fn get_name(&self) -> InterpretName { + InterpretName::Custom("DockerInstallation") + } + fn get_version(&self) -> Version { + todo!() + } + fn get_status(&self) -> InterpretStatus { + todo!() + } + fn get_children(&self) -> Vec { + todo!() + } +} diff --git a/harmony/src/modules/k3d/install.rs b/harmony/src/modules/k3d/install.rs index 244dff4..a6c07d5 100644 --- a/harmony/src/modules/k3d/install.rs +++ b/harmony/src/modules/k3d/install.rs @@ -1,6 +1,7 @@ use std::path::PathBuf; use async_trait::async_trait; +use harmony_tools::K3d; use log::debug; use serde::Serialize; @@ -10,7 +11,7 @@ use crate::{ interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::Inventory, score::Score, - topology::Topology, + topology::{Docker, Topology}, }; use harmony_types::id::Id; @@ -29,7 +30,7 @@ impl Default for K3DInstallationScore { } } -impl Score for K3DInstallationScore { +impl Score for K3DInstallationScore { fn create_interpret(&self) -> Box> { Box::new(K3dInstallationInterpret { score: self.clone(), @@ -47,19 +48,25 @@ pub struct K3dInstallationInterpret { } #[async_trait] -impl Interpret for K3dInstallationInterpret { +impl Interpret for K3dInstallationInterpret { async fn execute( &self, _inventory: &Inventory, - _topology: &T, + topology: &T, ) -> Result { - let k3d = k3d_rs::K3d::new( + let k3d = K3d::new( self.score.installation_path.clone(), Some(self.score.cluster_name.clone()), ); + Docker::ensure_installed(topology) + .await + .map_err(|e| InterpretError::new(format!("Docker requirement for k3d failed: {e}")))?; + match k3d.ensure_installed().await { Ok(_client) => { + // Ensure Docker is also ready as k3d depends on it + let msg = format!("k3d cluster '{}' installed ", self.score.cluster_name); debug!("{msg}"); Ok(Outcome::success(msg)) diff --git a/harmony/src/modules/mod.rs b/harmony/src/modules/mod.rs index c845fb1..a4abee4 100644 --- a/harmony/src/modules/mod.rs +++ b/harmony/src/modules/mod.rs @@ -4,6 +4,7 @@ pub mod brocade; pub mod cert_manager; pub mod dhcp; pub mod dns; +pub mod docker; pub mod dummy; pub mod helm; pub mod http; diff --git a/k3d/Cargo.toml b/harmony_tools/Cargo.toml similarity index 83% rename from k3d/Cargo.toml rename to harmony_tools/Cargo.toml index 23f1a4d..89d7bb9 100644 --- a/k3d/Cargo.toml +++ b/harmony_tools/Cargo.toml @@ -1,5 +1,6 @@ [package] -name = "k3d-rs" +name = "harmony_tools" +description = "Install tools such as k3d, docker and more" edition = "2021" version.workspace = true readme.workspace = true @@ -16,6 +17,7 @@ url.workspace = true sha2 = "0.10.8" futures-util = "0.3.31" kube.workspace = true +inquire.workspace = true [dev-dependencies] env_logger = { workspace = true } diff --git a/harmony_tools/src/docker.rs b/harmony_tools/src/docker.rs new file mode 100644 index 0000000..b708710 --- /dev/null +++ b/harmony_tools/src/docker.rs @@ -0,0 +1,300 @@ +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 + } +} diff --git a/k3d/src/downloadable_asset.rs b/harmony_tools/src/downloadable_asset.rs similarity index 79% rename from k3d/src/downloadable_asset.rs rename to harmony_tools/src/downloadable_asset.rs index 085d382..6ea56ff 100644 --- a/k3d/src/downloadable_asset.rs +++ b/harmony_tools/src/downloadable_asset.rs @@ -39,11 +39,20 @@ const CHECKSUM_FAILED_MSG: &str = "Downloaded file failed checksum verification" pub(crate) struct DownloadableAsset { pub(crate) url: Url, pub(crate) file_name: String, - pub(crate) checksum: String, + pub(crate) checksum: Option, } impl DownloadableAsset { fn verify_checksum(&self, file: PathBuf) -> bool { + // Skip verification if no checksum is provided + let expected_checksum = match &self.checksum { + Some(checksum) => checksum, + None => { + debug!("No checksum provided, skipping verification"); + return file.exists(); + } + }; + if !file.exists() { debug!("File does not exist: {:?}", file); return false; @@ -76,10 +85,10 @@ impl DownloadableAsset { let result = hasher.finalize(); let calculated_hash = format!("{:x}", result); - debug!("Expected checksum: {}", self.checksum); + debug!("Expected checksum: {}", expected_checksum); debug!("Calculated checksum: {}", calculated_hash); - calculated_hash == self.checksum + calculated_hash == *expected_checksum } /// Downloads the asset to the specified directory, verifying its checksum. @@ -151,7 +160,8 @@ impl DownloadableAsset { file.flush().await.expect("Failed to flush file"); drop(file); - if !self.verify_checksum(target_file_path.clone()) { + // Only verify checksum if one was provided + if self.checksum.is_some() && !self.verify_checksum(target_file_path.clone()) { return Err(CHECKSUM_FAILED_MSG.to_string()); } @@ -202,7 +212,7 @@ mod tests { let asset = DownloadableAsset { url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), file_name: "test.txt".to_string(), - checksum: TEST_CONTENT_HASH.to_string(), + checksum: Some(TEST_CONTENT_HASH.to_string()), }; let result = asset @@ -226,7 +236,7 @@ mod tests { let asset = DownloadableAsset { url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), file_name: "test.txt".to_string(), - checksum: TEST_CONTENT_HASH.to_string(), + checksum: Some(TEST_CONTENT_HASH.to_string()), }; let target_file_path = folder.join(&asset.file_name); @@ -248,7 +258,7 @@ mod tests { let asset = DownloadableAsset { url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), file_name: "test.txt".to_string(), - checksum: TEST_CONTENT_HASH.to_string(), + checksum: Some(TEST_CONTENT_HASH.to_string()), }; let result = asset.download_to_path(folder.join("error")).await; @@ -269,7 +279,7 @@ mod tests { let asset = DownloadableAsset { url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), file_name: "test.txt".to_string(), - checksum: TEST_CONTENT_HASH.to_string(), + checksum: Some(TEST_CONTENT_HASH.to_string()), }; let join_handle = @@ -293,11 +303,58 @@ mod tests { let asset = DownloadableAsset { url: Url::parse(&server.url("/specific/path.txt").to_string()).unwrap(), file_name: "path.txt".to_string(), - checksum: TEST_CONTENT_HASH.to_string(), + checksum: Some(TEST_CONTENT_HASH.to_string()), }; let result = asset.download_to_path(folder).await.unwrap(); let downloaded_content = std::fs::read_to_string(result).unwrap(); assert_eq!(downloaded_content, TEST_CONTENT); } + + #[tokio::test] + async fn test_download_without_checksum() { + let (folder, server) = setup_test(); + + server.expect( + Expectation::matching(matchers::any()) + .respond_with(responders::status_code(200).body(TEST_CONTENT)), + ); + + let asset = DownloadableAsset { + url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), + file_name: "test.txt".to_string(), + checksum: None, + }; + + let result = asset + .download_to_path(folder.join("no_checksum")) + .await + .unwrap(); + let downloaded_content = std::fs::read_to_string(result).unwrap(); + assert_eq!(downloaded_content, TEST_CONTENT); + } + + #[tokio::test] + async fn test_download_without_checksum_already_exists() { + let (folder, server) = setup_test(); + + server.expect( + Expectation::matching(matchers::any()) + .times(0) + .respond_with(responders::status_code(200).body(TEST_CONTENT)), + ); + + let asset = DownloadableAsset { + url: Url::parse(&server.url("/test.txt").to_string()).unwrap(), + file_name: "test.txt".to_string(), + checksum: None, + }; + + let target_file_path = folder.join(&asset.file_name); + std::fs::write(&target_file_path, TEST_CONTENT).unwrap(); + + let result = asset.download_to_path(folder).await.unwrap(); + let content = std::fs::read_to_string(result).unwrap(); + assert_eq!(content, TEST_CONTENT); + } } diff --git a/k3d/src/lib.rs b/harmony_tools/src/k3d.rs similarity index 99% rename from k3d/src/lib.rs rename to harmony_tools/src/k3d.rs index 63611f4..83e86f7 100644 --- a/k3d/src/lib.rs +++ b/harmony_tools/src/k3d.rs @@ -1,10 +1,9 @@ -mod downloadable_asset; -use downloadable_asset::*; - use kube::Client; use log::{debug, info}; use std::{ffi::OsStr, path::PathBuf}; +use crate::downloadable_asset::DownloadableAsset; + const K3D_BIN_FILE_NAME: &str = "k3d"; pub struct K3d { @@ -78,6 +77,7 @@ impl K3d { debug!("Found binary at {} with checksum {}", binary_url, checksum); + let checksum = Some(checksum); DownloadableAsset { url: binary_url, file_name: K3D_BIN_FILE_NAME.to_string(), @@ -399,7 +399,7 @@ mod test { use regex::Regex; use std::path::PathBuf; - use crate::{K3d, K3D_BIN_FILE_NAME}; + use crate::{k3d::K3D_BIN_FILE_NAME, K3d}; #[tokio::test] async fn k3d_latest_release_should_get_latest() { diff --git a/harmony_tools/src/lib.rs b/harmony_tools/src/lib.rs new file mode 100644 index 0000000..e261d81 --- /dev/null +++ b/harmony_tools/src/lib.rs @@ -0,0 +1,6 @@ +mod docker; +mod downloadable_asset; +mod k3d; +pub use docker::*; +use downloadable_asset::*; +pub use k3d::*;