From acfb93f1a2655d6044f4f8564d6a85ee3ad54065 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Thu, 12 Jun 2025 11:35:02 -0400 Subject: [PATCH] feat: add dry-run functionality and similar dependency - Implemented a dry-run mode for K8s resource patching, displaying diffs before applying changes. - Added the `similar` dependency for calculating and displaying text diffs. - Enhanced K8s resource application to handle various port specifications in NetworkPolicy ingress rules. - Added support for port ranges and lists of ports in NetworkPolicy rules. - Updated K8s client to utilize the dry-run configuration setting. - Added configuration option `HARMONY_DRY_RUN` to enable or disable dry-run mode. --- Cargo.lock | 7 +++ Cargo.toml | 47 ++++++-------- harmony/Cargo.toml | 1 + harmony/src/domain/config.rs | 2 + harmony/src/domain/topology/k8s.rs | 77 ++++++++++++++++++++++- harmony/src/domain/topology/tenant/k8s.rs | 24 ++++++- 6 files changed, 126 insertions(+), 32 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0ee6318..eeeee6f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1576,6 +1576,7 @@ dependencies = [ "serde-value", "serde_json", "serde_yaml", + "similar", "temp-dir", "temp-file", "tokio", @@ -4090,6 +4091,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "similar" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" + [[package]] name = "simple_asn1" version = "0.6.3" diff --git a/Cargo.toml b/Cargo.toml index 970300d..c081c86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,34 +20,23 @@ readme = "README.md" license = "GNU AGPL v3" [workspace.dependencies] -log = "0.4.22" -env_logger = "0.11.5" -derive-new = "0.7.0" -async-trait = "0.1.82" -tokio = { version = "1.40.0", features = [ - "io-std", - "fs", - "macros", - "rt-multi-thread", -] } +log = "0.4" +env_logger = "0.11" +derive-new = "0.7" +async-trait = "0.1" +tokio = { version = "1.40", features = ["io-std", "fs", "macros", "rt-multi-thread"] } cidr = { features = ["serde"], version = "0.2" } -russh = "0.45.0" -russh-keys = "0.45.0" -rand = "0.8.5" -url = "2.5.4" -kube = "0.98.0" -k8s-openapi = { version = "0.24.0", features = ["v1_30"] } -serde_yaml = "0.9.34" -serde-value = "0.7.0" -http = "1.2.0" -inquire = "0.7.5" -convert_case = "0.8.0" +russh = "0.45" +russh-keys = "0.45" +rand = "0.8" +url = "2.5" +kube = "0.98" +k8s-openapi = { version = "0.24", features = ["v1_30"] } +serde_yaml = "0.9" +serde-value = "0.7" +http = "1.2" +inquire = "0.7" +convert_case = "0.8" chrono = "0.4" - -[workspace.dependencies.uuid] -version = "1.11.0" -features = [ - "v4", # Lets you generate random UUIDs - "fast-rng", # Use a faster (but still sufficiently random) RNG - "macro-diagnostics", # Enable better diagnostics for compile-time UUIDs -] +similar = "2" +uuid = { version = "1.11", features = [ "v4", "fast-rng", "macro-diagnostics" ] } diff --git a/harmony/Cargo.toml b/harmony/Cargo.toml index 54cf36d..f84bd63 100644 --- a/harmony/Cargo.toml +++ b/harmony/Cargo.toml @@ -53,3 +53,4 @@ fqdn = { version = "0.4.6", features = [ ] } temp-dir = "0.1.14" dyn-clone = "1.0.19" +similar.workspace = true diff --git a/harmony/src/domain/config.rs b/harmony/src/domain/config.rs index 53d7446..7812616 100644 --- a/harmony/src/domain/config.rs +++ b/harmony/src/domain/config.rs @@ -10,4 +10,6 @@ lazy_static! { std::env::var("HARMONY_REGISTRY_URL").unwrap_or_else(|_| "hub.nationtech.io".to_string()); pub static ref REGISTRY_PROJECT: String = std::env::var("HARMONY_REGISTRY_PROJECT").unwrap_or_else(|_| "harmony".to_string()); + pub static ref DRY_RUN: bool = + std::env::var("HARMONY_DRY_RUN").map_or(true, |value| value.parse().unwrap_or(true)); } diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index 9e0a6db..9565a3d 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -4,9 +4,11 @@ use kube::{ Api, Client, Config, Error, Resource, api::{Patch, PatchParams}, config::{KubeConfigOptions, Kubeconfig}, + core::ErrorResponse, }; use log::{debug, error, trace}; use serde::de::DeserializeOwned; +use similar::TextDiff; #[derive(new)] pub struct K8sClient { @@ -48,8 +50,79 @@ impl K8sClient { .name .as_ref() .expect("K8s Resource should have a name"); - api.patch(name, &patch_params, &Patch::Apply(resource)) - .await + + if *crate::config::DRY_RUN { + match api.get(name).await { + Ok(current) => { + trace!("Received current value {current:#?}"); + // The resource exists, so we calculate and display a diff. + println!("\nPerforming dry-run for resource: '{}'", name); + let mut current_yaml = serde_yaml::to_value(¤t) + .expect(&format!("Could not serialize current value : {current:#?}")); + if current_yaml.is_mapping() && current_yaml.get("status").is_some() { + let map = current_yaml.as_mapping_mut().unwrap(); + let removed = map.remove_entry("status"); + trace!("Removed status {:?}", removed); + } else { + trace!( + "Did not find status entry for current object {}/{}", + current.meta().namespace.as_ref().unwrap_or(&"".to_string()), + current.meta().name.as_ref().unwrap_or(&"".to_string()) + ); + } + let current_yaml = serde_yaml::to_string(¤t_yaml) + .unwrap_or_else(|_| "Failed to serialize current resource".to_string()); + let new_yaml = serde_yaml::to_string(resource) + .unwrap_or_else(|_| "Failed to serialize new resource".to_string()); + + if current_yaml == new_yaml { + println!("No changes detected."); + // Return the current resource state as there are no changes. + return Ok(current); + } + + println!("Changes detected:"); + let diff = TextDiff::from_lines(¤t_yaml, &new_yaml); + + // Iterate over the changes and print them in a git-like diff format. + for change in diff.iter_all_changes() { + let sign = match change.tag() { + similar::ChangeTag::Delete => "-", + similar::ChangeTag::Insert => "+", + similar::ChangeTag::Equal => " ", + }; + print!("{}{}", sign, change); + } + // In a dry run, we return the new resource state that would have been applied. + Ok(resource.clone()) + } + Err(Error::Api(ErrorResponse { code: 404, .. })) => { + // The resource does not exist, so the "diff" is the entire new resource. + println!("\nPerforming dry-run for new resource: '{}'", name); + println!( + "Resource does not exist. It would be created with the following content:" + ); + let new_yaml = serde_yaml::to_string(resource) + .unwrap_or_else(|_| "Failed to serialize new resource".to_string()); + + // Print each line of the new resource with a '+' prefix. + for line in new_yaml.lines() { + println!("+{}", line); + } + // In a dry run, we return the new resource state that would have been created. + Ok(resource.clone()) + } + Err(e) => { + // Another API error occurred. + error!("Failed to get resource '{}': {}", name, e); + Err(e) + } + } + } else { + return api + .patch(name, &patch_params, &Patch::Apply(resource)) + .await; + } } pub async fn apply_many(&self, resource: &Vec, ns: Option<&str>) -> Result, Error> diff --git a/harmony/src/domain/topology/tenant/k8s.rs b/harmony/src/domain/topology/tenant/k8s.rs index a03e8d7..6ad5ae1 100644 --- a/harmony/src/domain/topology/tenant/k8s.rs +++ b/harmony/src/domain/topology/tenant/k8s.rs @@ -138,6 +138,7 @@ impl K8sTenantManager { "kind": "NetworkPolicy", "metadata": { "name": format!("{}-network-policy", config.name), + "namespace": self.get_namespace_name(config), }, "spec": { "podSelector": {}, @@ -219,8 +220,29 @@ impl K8sTenantManager { }) }) .collect(); + let ports: Option> = + c.1.as_ref().map(|spec| match &spec.data { + super::PortSpecData::SinglePort(port) => vec![NetworkPolicyPort { + port: Some(IntOrString::Int(port.clone().into())), + ..Default::default() + }], + super::PortSpecData::PortRange(start, end) => vec![NetworkPolicyPort { + port: Some(IntOrString::Int(start.clone().into())), + end_port: Some(end.clone().into()), + protocol: None, // Not currently supported by Harmony + }], + + super::PortSpecData::ListOfPorts(items) => items + .iter() + .map(|i| NetworkPolicyPort { + port: Some(IntOrString::Int(i.clone().into())), + ..Default::default() + }) + .collect(), + }); let rule = serde_json::from_value::(json!({ - "from": cidr_list + "from": cidr_list, + "ports": ports, })) .map_err(|e| { ExecutorError::ConfigurationError(format!(