diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index b1d2a2f..fb2d3e2 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -137,7 +137,7 @@ impl K8sClient { // Get the application-controller ServiceAccount name (fallback to default) pub async fn get_argocd_controller_sa_name(&self, ns: &str) -> Result { let api: Api = Api::namespaced(self.client.clone(), ns); - let lp = ListParams::default().labels("app.kubernetes.io/name=argocd-application-controller"); + let lp = ListParams::default().labels("app.kubernetes.io/component=controller"); let list = api.list(&lp).await?; if let Some(dep) = list.items.get(0) { if let Some(sa) = dep diff --git a/harmony/src/domain/topology/k8s_anywhere.rs b/harmony/src/domain/topology/k8s_anywhere.rs index 5e448b8..67489f9 100644 --- a/harmony/src/domain/topology/k8s_anywhere.rs +++ b/harmony/src/domain/topology/k8s_anywhere.rs @@ -2,7 +2,7 @@ use std::{process::Command, sync::Arc}; use async_trait::async_trait; use kube::api::GroupVersionKind; -use log::{debug, info, warn}; +use log::{debug, info, trace, warn}; use serde::Serialize; use tokio::sync::OnceCell; @@ -71,6 +71,7 @@ pub struct K8sAnywhereTopology { #[async_trait] impl K8sclient for K8sAnywhereTopology { async fn k8s_client(&self) -> Result, String> { + trace!("getting k8s client"); let state = match self.k8s_state.get() { Some(state) => state, None => return Err("K8s state not initialized yet".to_string()), @@ -620,36 +621,56 @@ impl TenantManager for K8sAnywhereTopology { #[async_trait] impl Ingress for K8sAnywhereTopology { - //TODO this is specifically for openshift/okd which violates the k8sanywhere idea async fn get_domain(&self, service: &str) -> Result { + use log::{trace, debug, warn}; + let client = self.k8s_client().await?; if let Some(Some(k8s_state)) = self.k8s_state.get() { match k8s_state.source { - K8sSource::LocalK3d => Ok(format!("{service}.local.k3d")), + K8sSource::LocalK3d => { + // Local developer UX + return Ok(format!("{service}.local.k3d")); + } K8sSource::Kubeconfig => { - self.openshift_ingress_operator_available().await?; + trace!("K8sSource is kubeconfig; attempting to detect domain"); - let gvk = GroupVersionKind { - group: "operator.openshift.io".into(), - version: "v1".into(), - kind: "IngressController".into(), - }; - let ic = client - .get_resource_json_value( - "default", - Some("openshift-ingress-operator"), - &gvk, - ) - .await - .map_err(|_| { - PreparationError::new("Failed to fetch IngressController".to_string()) - })?; + // 1) Try OpenShift IngressController domain (backward compatible) + if self.openshift_ingress_operator_available().await.is_ok() { + trace!("OpenShift ingress operator detected; using IngressController"); + let gvk = GroupVersionKind { + group: "operator.openshift.io".into(), + version: "v1".into(), + kind: "IngressController".into(), + }; + let ic = client + .get_resource_json_value("default", Some("openshift-ingress-operator"), &gvk) + .await + .map_err(|_| PreparationError::new("Failed to fetch IngressController".to_string()))?; - match ic.data["status"]["domain"].as_str() { - Some(domain) => Ok(format!("{service}.{domain}")), - None => Err(PreparationError::new("Could not find domain".to_string())), + if let Some(domain) = ic.data["status"]["domain"].as_str() { + return Ok(format!("{service}.{domain}")); + } else { + warn!("OpenShift IngressController present but no status.domain set"); + } + } else { + trace!("OpenShift ingress operator not detected; trying generic Kubernetes"); } + + // 2) Try NGINX Ingress Controller common setups + // 2.a) Well-known namespace/name for the controller Service + // - upstream default: namespace "ingress-nginx", service "ingress-nginx-controller" + // - some distros: "ingress-nginx-controller" svc in "ingress-nginx" ns + // If found with LoadBalancer ingress hostname, use its base domain. + if let Some(domain) = try_nginx_lb_domain(&client).await? { + return Ok(format!("{service}.{domain}")); + } + + // 3) Fallback: internal cluster DNS suffix (service.namespace.svc.cluster.local) + // We don't have tenant namespace here, so we fallback to 'default' with a warning. + warn!("Could not determine external ingress domain; falling back to internal-only DNS"); + let internal = format!("{service}.default.svc.cluster.local"); + Ok(internal) } } } else { @@ -659,3 +680,57 @@ impl Ingress for K8sAnywhereTopology { } } } + +async fn try_nginx_lb_domain(client: &K8sClient) -> Result, PreparationError> { + use log::{trace, debug}; + + // Try common service path: svc/ingress-nginx-controller in ns/ingress-nginx + let svc_gvk = GroupVersionKind { + group: "".into(), // core + version: "v1".into(), + kind: "Service".into(), + }; + + let candidates = [ + ("ingress-nginx", "ingress-nginx-controller"), + ("ingress-nginx", "ingress-nginx-controller-internal"), + ("ingress-nginx", "ingress-nginx"), // some charts name the svc like this + ("kube-system", "ingress-nginx-controller"), // less common but seen + ]; + + for (ns, name) in candidates { + trace!("Checking NGINX Service {ns}/{name} for LoadBalancer hostname"); + if let Ok(svc) = client.get_resource_json_value(ns, Some(name), &svc_gvk).await { + let lb_hosts = svc.data["status"]["loadBalancer"]["ingress"].as_array().cloned().unwrap_or_default(); + for entry in lb_hosts { + if let Some(host) = entry.get("hostname").and_then(|v| v.as_str()) { + debug!("Found NGINX LB hostname: {host}"); + if let Some(domain) = extract_base_domain(host) { + return Ok(Some(domain.to_string())); + } else { + return Ok(Some(host.to_string())); // already a domain + } + } + if let Some(ip) = entry.get("ip").and_then(|v| v.as_str()) { + // If only an IP is exposed, we can't create a hostname; return None to keep searching + debug!("NGINX LB exposes IP {ip} (no hostname); skipping"); + } + } + } + } + + Ok(None) +} + +fn extract_base_domain(host: &str) -> Option { + // For a host like a1b2c3d4e5f6abcdef.elb.amazonaws.com -> base domain elb.amazonaws.com + // For a managed DNS like xyz.example.com -> base domain example.com (keep 2+ labels) + // Heuristic: keep last 2 labels by default; special-case known multi-label TLDs if needed. + let parts: Vec<&str> = host.split('.').collect(); + if parts.len() >= 2 { + // Very conservative: last 2 labels + Some(parts[parts.len() - 2..].join(".")) + } else { + None + } +} diff --git a/harmony/src/modules/application/features/argo_types.rs b/harmony/src/modules/application/features/argo_types.rs index f36d424..998419a 100644 --- a/harmony/src/modules/application/features/argo_types.rs +++ b/harmony/src/modules/application/features/argo_types.rs @@ -21,7 +21,7 @@ pub struct Helm { pub skip_schema_validation: Option, pub version: Option, pub kube_version: Option, - pub api_versions: Vec, + // pub api_versions: Vec, pub namespace: Option, } @@ -105,7 +105,7 @@ impl Default for ArgoApplication { skip_schema_validation: None, version: None, kube_version: None, - api_versions: vec![], + // api_versions: vec![], namespace: None, }, path: "".to_string(), @@ -155,7 +155,7 @@ impl From for ArgoApplication { skip_schema_validation: None, version: None, kube_version: None, - api_versions: vec![], + // api_versions: vec![], namespace: None, }, }, @@ -181,13 +181,11 @@ impl From for ArgoApplication { } impl ArgoApplication { - pub fn to_yaml(&self) -> serde_yaml::Value { + pub fn to_yaml(&self, target_namespace: Option<&str>) -> serde_yaml::Value { let name = &self.name; - let namespace = if let Some(ns) = self.namespace.as_ref() { - ns - } else { - "argocd" - }; + let default_ns = "argocd".to_string(); + let namespace: &str = + target_namespace.unwrap_or(self.namespace.as_ref().unwrap_or(&default_ns)); let project = &self.project; let yaml_str = format!( @@ -285,7 +283,7 @@ mod tests { skip_schema_validation: None, version: None, kube_version: None, - api_versions: vec![], + // api_versions: vec![], namespace: None, }, path: "".to_string(), @@ -345,7 +343,7 @@ spec: assert_eq!( expected_yaml_output.trim(), - serde_yaml::to_string(&app.clone().to_yaml()) + serde_yaml::to_string(&app.clone().to_yaml(None)) .unwrap() .trim() ); diff --git a/harmony/src/modules/application/features/helm_argocd_score.rs b/harmony/src/modules/application/features/helm_argocd_score.rs index 0bf1c69..edf3a95 100644 --- a/harmony/src/modules/application/features/helm_argocd_score.rs +++ b/harmony/src/modules/application/features/helm_argocd_score.rs @@ -1,5 +1,5 @@ use async_trait::async_trait; -use kube::api::GroupVersionKind; +use log::{debug, info, trace, warn}; use non_blank_string_rs::NonBlankString; use serde::Serialize; use std::{str::FromStr, sync::Arc}; @@ -8,9 +8,12 @@ use crate::{ data::Version, interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}, inventory::Inventory, - modules::{argocd::{detect_argo_deployment_type, ArgoDeploymentType}, helm::chart::{HelmChartScore, HelmRepository}}, + modules::{ + argocd::{ArgoDeploymentType, detect_argo_deployment_type}, + helm::chart::{HelmChartScore, HelmRepository}, + }, score::Score, - topology::{ingress::Ingress, k8s::K8sClient, HelmCommand, K8sclient, Topology}, + topology::{HelmCommand, K8sclient, Topology, ingress::Ingress, k8s::K8sClient}, }; use harmony_types::id::Id; @@ -19,7 +22,7 @@ use super::ArgoApplication; #[derive(Debug, Serialize, Clone)] pub struct ArgoHelmScore { pub namespace: String, - // TODO remove this field and rely on topology, it can now know what flavor it is running + // TODO: remove and rely on topology (it now knows the flavor) pub openshift: bool, pub argo_apps: Vec, } @@ -50,38 +53,101 @@ impl Interpret for ArgoInter inventory: &Inventory, topology: &T, ) -> Result { - let k8s_client = topology.k8s_client().await?; - let svc = format!("argo-{}", self.score.namespace.clone()); + trace!("Starting ArgoInterpret execution {self:?}"); + let k8s_client: Arc = topology.k8s_client().await?; + trace!("Got k8s client"); + let desired_ns = self.score.namespace.clone(); + + debug!("ArgoInterpret detecting cluster configuration"); + let svc = format!("argo-{}", desired_ns); let domain = topology.get_domain(&svc).await?; + debug!("Resolved Argo service domain for '{}': {}", svc, domain); - let current_argo_deployment = detect_argo_deployment_type(&k8s_client, &self.score.namespace).await?; + // Detect current Argo deployment type + let current = detect_argo_deployment_type(&k8s_client, &desired_ns).await?; + info!("Detected Argo deployment type: {:?}", current); - match current_argo_deployment { - ArgoDeploymentType::NotInstalled => todo!(), - ArgoDeploymentType::AvailableInDesiredNamespace(_) => todo!(), - ArgoDeploymentType::InstalledClusterWide(_) => todo!(), - ArgoDeploymentType::InstalledNamespaceScoped(_) => todo!(), + // Decide control namespace and whether we must install + let (control_ns, must_install) = match current.clone() { + ArgoDeploymentType::NotInstalled => { + info!( + "Argo CD not installed. Will install via Helm into namespace '{}'.", + desired_ns + ); + (desired_ns.clone(), true) + } + ArgoDeploymentType::AvailableInDesiredNamespace(ns) => { + info!( + "Argo CD already installed by Harmony in '{}'. Skipping install.", + ns + ); + (ns, false) + } + ArgoDeploymentType::InstalledClusterWide(ns) => { + info!( + "Argo CD installed cluster-wide in namespace '{}'.", + ns + ); + (ns, false) + } + ArgoDeploymentType::InstalledNamespaceScoped(ns) => { + // TODO we could support this use case by installing a new argo instance. But that + // means handling a few cases that are out of scope for now : + // - Wether argo operator is installed + // - Managing CRD versions compatibility + // - Potentially handling the various k8s flavors and setups we might encounter + // + // There is a possibility that the helm chart already handles most or even all of these use cases but they are out of scope for now. + let msg = format!( + "Argo CD found in '{}' but it is namespace-scoped and not supported for attachment yet.", + ns + ); + warn!("{}", msg); + return Err(InterpretError::new(msg)); + } }; - let helm_score = - argo_helm_chart_score(&self.score.namespace, self.score.openshift, &domain); - helm_score.interpret(inventory, topology).await?; + info!("ArgoCD will be installed : {must_install} . Current argocd status : {current:?} "); + if must_install { + let helm_score = argo_helm_chart_score(&desired_ns, self.score.openshift, &domain); + info!( + "Installing Argo CD via Helm into namespace '{}' ...", + desired_ns + ); + helm_score.interpret(inventory, topology).await?; + info!("Argo CD install complete in '{}'.", desired_ns); + } + + let yamls: Vec = self + .argo_apps + .iter() + .map(|a| a.to_yaml(Some(&control_ns))) + .collect(); + info!( + "Applying {} Argo application object(s) into control namespace '{}'.", + yamls.len(), + control_ns + ); k8s_client - .apply_yaml_many(&self.argo_apps.iter().map(|a| a.to_yaml()).collect(), None) + .apply_yaml_many(&yamls, Some(control_ns.as_str())) .await - .unwrap(); + .map_err(|e| InterpretError::new(format!("Failed applying Argo CRs: {e}")))?; Ok(Outcome::success_with_details( format!( "ArgoCD {} {}", self.argo_apps.len(), - match self.argo_apps.len() { - 1 => "application", - _ => "applications", + if self.argo_apps.len() == 1 { + "application" + } else { + "applications" } ), - vec![format!("argo application: http://{}", domain)], + vec![ + format!("control_namespace={}", control_ns), + format!("argo ui: http://{}", domain), + ], )) } @@ -90,7 +156,7 @@ impl Interpret for ArgoInter } fn get_version(&self) -> Version { - todo!() + Version::from("0.1.0").unwrap() } fn get_status(&self) -> InterpretStatus { @@ -98,7 +164,7 @@ impl Interpret for ArgoInter } fn get_children(&self) -> Vec { - todo!() + vec![] } } diff --git a/harmony/src/modules/application/features/packaging_deployment.rs b/harmony/src/modules/application/features/packaging_deployment.rs index 0da3393..ed46090 100644 --- a/harmony/src/modules/application/features/packaging_deployment.rs +++ b/harmony/src/modules/application/features/packaging_deployment.rs @@ -198,7 +198,7 @@ impl< openshift: true, argo_apps: vec![ArgoApplication::from(CDApplicationConfig { // helm pull oci://hub.nationtech.io/harmony/harmony-example-rust-webapp-chart --version 0.1.0 - version: Version::from("0.1.0").unwrap(), + version: Version::from("0.2.1").unwrap(), helm_chart_repo_url: "hub.nationtech.io/harmony".to_string(), helm_chart_name: format!("{}-chart", self.application.name()), values_overrides: None, diff --git a/harmony/src/modules/application/rust.rs b/harmony/src/modules/application/rust.rs index 500b6fe..8384e78 100644 --- a/harmony/src/modules/application/rust.rs +++ b/harmony/src/modules/application/rust.rs @@ -480,7 +480,7 @@ apiVersion: v2 name: {chart_name} description: A Helm chart for the {app_name} web application. type: application -version: 0.2.0 +version: 0.2.1 appVersion: "{image_tag}" "#, ); diff --git a/harmony/src/modules/argocd/mod.rs b/harmony/src/modules/argocd/mod.rs index 7aace2c..1d59d0a 100644 --- a/harmony/src/modules/argocd/mod.rs +++ b/harmony/src/modules/argocd/mod.rs @@ -29,58 +29,142 @@ pub enum ArgoDeploymentType { pub async fn discover_argo_all( k8s: &Arc, ) -> Result, InterpretError> { + use log::{debug, info, trace, warn}; + + trace!("Starting Argo discovery"); + // CRDs let mut has_crds = true; - for crd in vec!["applications.argoproj.io", "appprojects.argoproj.io"] { + let required_crds = vec!["applications.argoproj.io", "appprojects.argoproj.io"]; + trace!("Checking required Argo CRDs: {:?}", required_crds); + + for crd in required_crds { + trace!("Verifying CRD presence: {crd}"); let crd_exists = k8s.has_crd(crd).await.map_err(|e| { InterpretError::new(format!("Failed to verify existence of CRD {crd}: {e}")) })?; + debug!("CRD {crd} exists: {crd_exists}"); if !crd_exists { - info!("Missing argo CRD {crd}, looks like ArgoCD is not installed"); + info!( + "Missing Argo CRD {crd}, looks like Argo CD is not installed (or partially installed)" + ); has_crds = false; break; } } - // Namespaces that have healthy argocd deployments - let candidate_namespaces = k8s + trace!( + "Listing namespaces with healthy Argo CD deployments using selector app.kubernetes.io/part-of=argocd" + ); + let mut candidate_namespaces = k8s .list_namespaces_with_healthy_deployments("app.kubernetes.io/part-of=argocd") .await .map_err(|e| InterpretError::new(format!("List healthy argocd deployments: {e}")))?; + trace!( + "Listing namespaces with healthy Argo CD deployments using selector app.kubernetes.io/name=argo-cd" + ); + candidate_namespaces.append( + &mut k8s + .list_namespaces_with_healthy_deployments("app.kubernetes.io/name=argo-cd") + .await + .map_err(|e| InterpretError::new(format!("List healthy argocd deployments: {e}")))?, + ); + + debug!( + "Discovered {} candidate namespace(s) for Argo CD: {:?}", + candidate_namespaces.len(), + candidate_namespaces + ); let mut found = Vec::new(); for ns in candidate_namespaces { + trace!("Evaluating namespace '{ns}' for Argo CD instance"); + // Require the application-controller to be healthy (sanity check) + trace!( + "Checking healthy deployment with label app.kubernetes.io/name=argocd-application-controller in namespace '{ns}'" + ); let controller_ok = k8s .has_healthy_deployment_with_label( &ns, "app.kubernetes.io/name=argocd-application-controller", ) .await - .unwrap_or(false); + .unwrap_or_else(|e| { + warn!( + "Error while checking application-controller health in namespace '{ns}': {e}" + ); + false + }) || k8s + .has_healthy_deployment_with_label( + &ns, + "app.kubernetes.io/component=controller", + ) + .await + .unwrap_or_else(|e| { + warn!( + "Error while checking application-controller health in namespace '{ns}': {e}" + ); + false + }); + debug!("Namespace '{ns}': application-controller healthy = {controller_ok}"); + if !controller_ok { + trace!("Skipping namespace '{ns}' because application-controller is not healthy"); continue; } - let scope = if k8s.is_argocd_cluster_wide(&ns).await? { - ArgoScope::ClusterWide(ns.to_string()) - } else { - ArgoScope::NamespaceScoped(ns.to_string()) + trace!("Determining Argo CD scope for namespace '{ns}' (cluster-wide vs namespace-scoped)"); + let scope = match k8s.is_argocd_cluster_wide(&ns).await { + Ok(true) => { + debug!("Namespace '{ns}' identified as cluster-wide Argo CD control plane"); + ArgoScope::ClusterWide(ns.to_string()) + } + Ok(false) => { + debug!("Namespace '{ns}' identified as namespace-scoped Argo CD control plane"); + ArgoScope::NamespaceScoped(ns.to_string()) + } + Err(e) => { + warn!( + "Failed to determine Argo CD scope for namespace '{ns}': {e}. Assuming namespace-scoped." + ); + ArgoScope::NamespaceScoped(ns.to_string()) + } + }; + + trace!("Checking optional ApplicationSet CRD (applicationsets.argoproj.io)"); + let has_applicationset = match k8s.has_crd("applicationsets.argoproj.io").await { + Ok(v) => { + debug!("applicationsets.argoproj.io present: {v}"); + v + } + Err(e) => { + warn!("Failed to check applicationsets.argoproj.io CRD: {e}. Assuming absent."); + false + } }; let argo = DiscoveredArgo { - control_namespace: ns, + control_namespace: ns.clone(), scope, has_crds, - has_applicationset: k8s.has_crd("applicationsets.argoproj.io").await?, + has_applicationset, }; - debug!("Found argo instance {argo:?}"); - + debug!("Discovered Argo instance in '{ns}': {argo:?}"); found.push(argo); } + if found.is_empty() { + info!("No Argo CD installations discovered"); + } else { + info!( + "Argo CD discovery complete: {} instance(s) found", + found.len() + ); + } + Ok(found) } @@ -89,6 +173,7 @@ pub async fn detect_argo_deployment_type( desired_namespace: &str, ) -> Result { let discovered = discover_argo_all(k8s).await?; + debug!("Discovered argo instances {discovered:?}"); if discovered.is_empty() { return Ok(ArgoDeploymentType::NotInstalled);