diff --git a/Cargo.lock b/Cargo.lock index 5c45111..4ddd642 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1811,6 +1811,21 @@ dependencies = [ "url", ] +[[package]] +name = "example-multisite-postgres" +version = "0.1.0" +dependencies = [ + "cidr", + "env_logger", + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "log", + "tokio", + "url", +] + [[package]] name = "example-nanodc" version = "0.1.0" @@ -1829,6 +1844,21 @@ dependencies = [ "url", ] +[[package]] +name = "example-nats" +version = "0.1.0" +dependencies = [ + "cidr", + "env_logger", + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "log", + "tokio", + "url", +] + [[package]] name = "example-ntfy" version = "0.1.0" @@ -1903,6 +1933,36 @@ dependencies = [ "url", ] +[[package]] +name = "example-postgresql" +version = "0.1.0" +dependencies = [ + "cidr", + "env_logger", + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "log", + "tokio", + "url", +] + +[[package]] +name = "example-public-postgres" +version = "0.1.0" +dependencies = [ + "cidr", + "env_logger", + "harmony", + "harmony_cli", + "harmony_macros", + "harmony_types", + "log", + "tokio", + "url", +] + [[package]] name = "example-opnsense-node-exporter" version = "0.1.0" @@ -2629,6 +2689,7 @@ dependencies = [ "log", "rand 0.9.2", "serde", + "serde_json", "url", ] diff --git a/brocade/src/lib.rs b/brocade/src/lib.rs index 05f4928..f51056e 100644 --- a/brocade/src/lib.rs +++ b/brocade/src/lib.rs @@ -57,7 +57,7 @@ enum ExecutionMode { #[derive(Clone, Debug)] pub struct BrocadeInfo { os: BrocadeOs, - version: String, + _version: String, } #[derive(Clone, Debug)] @@ -272,7 +272,7 @@ async fn get_brocade_info(session: &mut BrocadeSession) -> Result[a-zA-Z0-9.\-]+)") @@ -285,7 +285,7 @@ async fn get_brocade_info(session: &mut BrocadeSession) -> Result::from_env(), + vec![Box::new(postgres)], + None, + ) + .await + .unwrap(); +} diff --git a/examples/nats/Cargo.toml b/examples/nats/Cargo.toml new file mode 100644 index 0000000..2d2dcec --- /dev/null +++ b/examples/nats/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-nats" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +publish = false + +[dependencies] +harmony = { path = "../../harmony" } +harmony_cli = { path = "../../harmony_cli" } +harmony_types = { path = "../../harmony_types" } +cidr = { workspace = true } +tokio = { workspace = true } +harmony_macros = { path = "../../harmony_macros" } +log = { workspace = true } +env_logger = { workspace = true } +url = { workspace = true } diff --git a/examples/nats/src/main.rs b/examples/nats/src/main.rs new file mode 100644 index 0000000..ca0d7d5 --- /dev/null +++ b/examples/nats/src/main.rs @@ -0,0 +1,63 @@ +use std::str::FromStr; + +use harmony::{ + inventory::Inventory, + modules::helm::chart::{HelmChartScore, HelmRepository, NonBlankString}, + topology::K8sAnywhereTopology, +}; +use harmony_macros::hurl; +use log::info; + +#[tokio::main] +async fn main() { + // env_logger::init(); + let values_yaml = Some( + r#"config: + cluster: + enabled: true + replicas: 3 + jetstream: + enabled: true + fileStorage: + enabled: true + size: 10Gi + storageDirectory: /data/jetstream + leafnodes: + enabled: false + # port: 7422 + gateway: + enabled: false + # name: my-gateway + # port: 7522"# + .to_string(), + ); + let namespace = "nats"; + let nats = HelmChartScore { + namespace: Some(NonBlankString::from_str(namespace).unwrap()), + release_name: NonBlankString::from_str("nats").unwrap(), + chart_name: NonBlankString::from_str("nats/nats").unwrap(), + chart_version: None, + values_overrides: None, + values_yaml, + create_namespace: true, + install_only: true, + repository: Some(HelmRepository::new( + "nats".to_string(), + hurl!("https://nats-io.github.io/k8s/helm/charts/"), + true, + )), + }; + + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(nats)], + None, + ) + .await + .unwrap(); + + info!( + "Enjoy! You can test your nats cluster by running : `kubectl exec -n {namespace} -it deployment/nats-box -- nats pub test hi`" + ); +} diff --git a/examples/openbao/src/main.rs b/examples/openbao/src/main.rs index 52c5119..63918b8 100644 --- a/examples/openbao/src/main.rs +++ b/examples/openbao/src/main.rs @@ -1,4 +1,4 @@ -use std::{collections::HashMap, str::FromStr}; +use std::str::FromStr; use harmony::{ inventory::Inventory, diff --git a/examples/postgresql/Cargo.toml b/examples/postgresql/Cargo.toml new file mode 100644 index 0000000..df160a7 --- /dev/null +++ b/examples/postgresql/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-postgresql" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +publish = false + +[dependencies] +harmony = { path = "../../harmony" } +harmony_cli = { path = "../../harmony_cli" } +harmony_types = { path = "../../harmony_types" } +cidr = { workspace = true } +tokio = { workspace = true } +harmony_macros = { path = "../../harmony_macros" } +log = { workspace = true } +env_logger = { workspace = true } +url = { workspace = true } diff --git a/examples/postgresql/src/main.rs b/examples/postgresql/src/main.rs new file mode 100644 index 0000000..31a501c --- /dev/null +++ b/examples/postgresql/src/main.rs @@ -0,0 +1,26 @@ +use harmony::{ + inventory::Inventory, + modules::postgresql::{PostgreSQLScore, capability::PostgreSQLConfig}, + topology::K8sAnywhereTopology, +}; + +#[tokio::main] +async fn main() { + let postgresql = PostgreSQLScore { + config: PostgreSQLConfig { + cluster_name: "harmony-postgres-example".to_string(), // Override default name + namespace: "harmony-postgres-example".to_string(), + ..Default::default() // Use harmony defaults, they are based on CNPG's default values : + // "default" namespace, 1 instance, 1Gi storage + }, + }; + + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(postgresql)], + None, + ) + .await + .unwrap(); +} diff --git a/examples/public_postgres/Cargo.toml b/examples/public_postgres/Cargo.toml new file mode 100644 index 0000000..f0e4c83 --- /dev/null +++ b/examples/public_postgres/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "example-public-postgres" +edition = "2024" +version.workspace = true +readme.workspace = true +license.workspace = true +publish = false + +[dependencies] +harmony = { path = "../../harmony" } +harmony_cli = { path = "../../harmony_cli" } +harmony_types = { path = "../../harmony_types" } +cidr = { workspace = true } +tokio = { workspace = true } +harmony_macros = { path = "../../harmony_macros" } +log = { workspace = true } +env_logger = { workspace = true } +url = { workspace = true } diff --git a/examples/public_postgres/src/main.rs b/examples/public_postgres/src/main.rs new file mode 100644 index 0000000..029080e --- /dev/null +++ b/examples/public_postgres/src/main.rs @@ -0,0 +1,38 @@ +use harmony::{ + inventory::Inventory, + modules::postgresql::{ + K8sPostgreSQLScore, PostgreSQLConnectionScore, PublicPostgreSQLScore, + capability::PostgreSQLConfig, + }, + topology::K8sAnywhereTopology, +}; + +#[tokio::main] +async fn main() { + let postgres = PublicPostgreSQLScore { + config: PostgreSQLConfig { + cluster_name: "harmony-postgres-example".to_string(), // Override default name + namespace: "harmony-public-postgres".to_string(), + ..Default::default() // Use harmony defaults, they are based on CNPG's default values : + // 1 instance, 1Gi storage + }, + hostname: "postgrestest.sto1.nationtech.io".to_string(), + }; + + let test_connection = PostgreSQLConnectionScore { + name: "harmony-postgres-example".to_string(), + namespace: "harmony-public-postgres".to_string(), + cluster_name: "harmony-postgres-example".to_string(), + hostname: Some("postgrestest.sto1.nationtech.io".to_string()), + port_override: Some(443), + }; + + harmony_cli::run( + Inventory::autoload(), + K8sAnywhereTopology::from_env(), + vec![Box::new(postgres), Box::new(test_connection)], + None, + ) + .await + .unwrap(); +} diff --git a/harmony/src/domain/interpret/mod.rs b/harmony/src/domain/interpret/mod.rs index 0c9b326..c5e92ce 100644 --- a/harmony/src/domain/interpret/mod.rs +++ b/harmony/src/domain/interpret/mod.rs @@ -154,6 +154,12 @@ pub struct InterpretError { msg: String, } +impl From for String { + fn from(e: InterpretError) -> String { + format!("InterpretError : {}", e.msg) + } +} + impl std::fmt::Display for InterpretError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.msg) diff --git a/harmony/src/domain/topology/failover.rs b/harmony/src/domain/topology/failover.rs index 6df9cea..0388222 100644 --- a/harmony/src/domain/topology/failover.rs +++ b/harmony/src/domain/topology/failover.rs @@ -1,6 +1,7 @@ use async_trait::async_trait; -use crate::topology::{PreparationError, PreparationOutcome, Topology}; +use crate::topology::k8s_anywhere::K8sAnywhereConfig; +use crate::topology::{K8sAnywhereTopology, PreparationError, PreparationOutcome, Topology}; pub struct FailoverTopology { pub primary: T, @@ -8,12 +9,56 @@ pub struct FailoverTopology { } #[async_trait] -impl Topology for FailoverTopology { +impl Topology for FailoverTopology { fn name(&self) -> &str { "FailoverTopology" } async fn ensure_ready(&self) -> Result { - todo!() + let primary_outcome = self.primary.ensure_ready().await?; + let replica_outcome = self.replica.ensure_ready().await?; + + match (primary_outcome, replica_outcome) { + (PreparationOutcome::Noop, PreparationOutcome::Noop) => Ok(PreparationOutcome::Noop), + (p, r) => { + let mut details = Vec::new(); + if let PreparationOutcome::Success { details: d } = p { + details.push(format!("Primary: {}", d)); + } + if let PreparationOutcome::Success { details: d } = r { + details.push(format!("Replica: {}", d)); + } + Ok(PreparationOutcome::Success { + details: details.join(", "), + }) + } + } + } +} + +impl FailoverTopology { + /// Creates a new `FailoverTopology` from environment variables. + /// + /// Expects two environment variables: + /// - `HARMONY_FAILOVER_TOPOLOGY_K8S_PRIMARY`: Comma-separated `key=value` pairs, e.g., + /// `kubeconfig=/path/to/primary.kubeconfig,context_name=primary-ctx` + /// - `HARMONY_FAILOVER_TOPOLOGY_K8S_REPLICA`: Same format for the replica. + /// + /// Parses `kubeconfig` (path to kubeconfig file) and `context_name` (Kubernetes context), + /// and constructs `K8sAnywhereConfig` with local installs disabled (`use_local_k3d=false`, + /// `autoinstall=false`, `use_system_kubeconfig=false`). + /// `harmony_profile` is read from `HARMONY_PROFILE` env or defaults to `"dev"`. + /// + /// Panics if required env vars are missing or malformed. + pub fn from_env() -> Self { + let primary_config = + K8sAnywhereConfig::remote_k8s_from_env_var("HARMONY_FAILOVER_TOPOLOGY_K8S_PRIMARY"); + let replica_config = + K8sAnywhereConfig::remote_k8s_from_env_var("HARMONY_FAILOVER_TOPOLOGY_K8S_REPLICA"); + + let primary = K8sAnywhereTopology::with_config(primary_config); + let replica = K8sAnywhereTopology::with_config(replica_config); + + Self { primary, replica } } } diff --git a/harmony/src/domain/topology/k8s.rs b/harmony/src/domain/topology/k8s.rs index 60b0b38..43958f6 100644 --- a/harmony/src/domain/topology/k8s.rs +++ b/harmony/src/domain/topology/k8s.rs @@ -595,7 +595,20 @@ impl K8sClient { { let mut result = Vec::new(); for r in resource.iter() { - result.push(self.apply(r, ns).await?); + let apply_result = self.apply(r, ns).await; + if apply_result.is_err() { + // NOTE : We should be careful about this one, it may leak sensitive information in + // logs + // Maybe just reducing it to debug would be enough as we already know debug logs + // are unsafe. + // But keeping it at warn makes it much easier to understand what is going on. So be it for now. + warn!( + "Failed to apply k8s resource : {}", + serde_json::to_string_pretty(r).map_err(|e| Error::SerdeError(e))? + ); + } + + result.push(apply_result?); } Ok(result) @@ -762,6 +775,23 @@ impl K8sClient { } pub async fn from_kubeconfig(path: &str) -> Option { + Self::from_kubeconfig_with_opts(path, &KubeConfigOptions::default()).await + } + + pub async fn from_kubeconfig_with_context( + path: &str, + context: Option, + ) -> Option { + let mut opts = KubeConfigOptions::default(); + opts.context = context; + + Self::from_kubeconfig_with_opts(path, &opts).await + } + + pub async fn from_kubeconfig_with_opts( + path: &str, + opts: &KubeConfigOptions, + ) -> Option { let k = match Kubeconfig::read_from(path) { Ok(k) => k, Err(e) => { @@ -769,13 +799,9 @@ impl K8sClient { return None; } }; + Some(K8sClient::new( - Client::try_from( - Config::from_custom_kubeconfig(k, &KubeConfigOptions::default()) - .await - .unwrap(), - ) - .unwrap(), + Client::try_from(Config::from_custom_kubeconfig(k, &opts).await.unwrap()).unwrap(), )) } } diff --git a/harmony/src/domain/topology/k8s_anywhere/.k8s_anywhere.rs.swp b/harmony/src/domain/topology/k8s_anywhere/.k8s_anywhere.rs.swp new file mode 100644 index 0000000..ff072bd Binary files /dev/null and b/harmony/src/domain/topology/k8s_anywhere/.k8s_anywhere.rs.swp differ diff --git a/harmony/src/domain/topology/k8s_anywhere.rs b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs similarity index 78% rename from harmony/src/domain/topology/k8s_anywhere.rs rename to harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs index 6137a7c..5b691cc 100644 --- a/harmony/src/domain/topology/k8s_anywhere.rs +++ b/harmony/src/domain/topology/k8s_anywhere/k8s_anywhere.rs @@ -2,6 +2,7 @@ use std::{collections::BTreeMap, process::Command, sync::Arc, time::Duration}; use async_trait::async_trait; use base64::{Engine, engine::general_purpose}; +use harmony_types::rfc1123::Rfc1123Name; use k8s_openapi::api::{ core::v1::Secret, rbac::v1::{ClusterRoleBinding, RoleRef, Subject}, @@ -34,16 +35,17 @@ use crate::{ service_monitor::ServiceMonitor, }, }, + okd::route::OKDTlsPassthroughScore, prometheus::{ k8s_prometheus_alerting_score::K8sPrometheusCRDAlertingScore, prometheus::PrometheusMonitoring, rhob_alerting_score::RHOBAlertingScore, }, }, score::Score, - topology::ingress::Ingress, + topology::{TlsRoute, TlsRouter, ingress::Ingress}, }; -use super::{ +use super::super::{ DeploymentTarget, HelmCommand, K8sclient, MultiTargetTopology, PreparationError, PreparationOutcome, Topology, k8s::K8sClient, @@ -103,6 +105,41 @@ impl K8sclient for K8sAnywhereTopology { } } +#[async_trait] +impl TlsRouter for K8sAnywhereTopology { + async fn get_wildcard_domain(&self) -> Result, String> { + todo!() + } + + /// Returns the port that this router exposes externally. + async fn get_router_port(&self) -> u16 { + // TODO un-hardcode this :) + 443 + } + + async fn install_route(&self, route: TlsRoute) -> Result<(), String> { + let distro = self + .get_k8s_distribution() + .await + .map_err(|e| format!("Could not get k8s distribution {e}"))?; + + match distro { + KubernetesDistribution::OpenshiftFamily => { + OKDTlsPassthroughScore { + name: Rfc1123Name::try_from(route.backend_info_string().as_str())?, + route, + } + .interpret(&Inventory::empty(), self) + .await?; + Ok(()) + } + KubernetesDistribution::K3sFamily | KubernetesDistribution::Default => Err(format!( + "Distribution not supported yet for Tlsrouter {distro:?}" + )), + } + } +} + #[async_trait] impl Grafana for K8sAnywhereTopology { async fn ensure_grafana_operator( @@ -344,6 +381,7 @@ impl K8sAnywhereTopology { pub async fn get_k8s_distribution(&self) -> Result<&KubernetesDistribution, PreparationError> { self.k8s_distribution .get_or_try_init(async || { + debug!("Trying to detect k8s distribution"); let client = self.k8s_client().await.unwrap(); let discovery = client.discovery().await.map_err(|e| { @@ -359,14 +397,17 @@ impl K8sAnywhereTopology { .groups() .any(|g| g.name() == "project.openshift.io") { + info!("Found KubernetesDistribution OpenshiftFamily"); return Ok(KubernetesDistribution::OpenshiftFamily); } // K3d / K3s if version.git_version.contains("k3s") { + info!("Found KubernetesDistribution K3sFamily"); return Ok(KubernetesDistribution::K3sFamily); } + info!("Could not identify KubernetesDistribution, using Default"); return Ok(KubernetesDistribution::Default); }) .await @@ -614,7 +655,7 @@ impl K8sAnywhereTopology { } async fn try_load_kubeconfig(&self, path: &str) -> Option { - K8sClient::from_kubeconfig(path).await + K8sClient::from_kubeconfig_with_context(path, self.config.k8s_context.clone()).await } fn get_k3d_installation_score(&self) -> K3DInstallationScore { @@ -652,7 +693,14 @@ impl K8sAnywhereTopology { return Ok(Some(K8sState { client: Arc::new(client), source: K8sSource::Kubeconfig, - message: format!("Loaded k8s client from kubeconfig {kubeconfig}"), + message: format!( + "Loaded k8s client from kubeconfig {kubeconfig} using context {}", + self.config + .k8s_context + .as_ref() + .map(|s| s.clone()) + .unwrap_or_default() + ), })); } None => { @@ -892,9 +940,71 @@ pub struct K8sAnywhereConfig { /// default: true pub use_local_k3d: bool, pub harmony_profile: String, + + /// Name of the kubeconfig context to use. + /// + /// If None, it will use the current context. + /// + /// If the context name is not found, it will fail to initialize. + pub k8s_context: Option, } impl K8sAnywhereConfig { + /// Reads an environment variable `env_var` and parses its content : + /// Comma-separated `key=value` pairs, e.g., + /// `kubeconfig=/path/to/primary.kubeconfig,context=primary-ctx` + /// + /// Then creates a K8sAnywhereConfig from it local installs disabled (`use_local_k3d=false`, + /// `autoinstall=false`, `use_system_kubeconfig=false`). + /// `harmony_profile` is read from `HARMONY_PROFILE` env or defaults to `"dev"`. + /// + /// If no kubeconfig path is provided it will fall back to system kubeconfig + /// + /// Panics if `env_var` is missing or malformed. + pub fn remote_k8s_from_env_var(env_var: &str) -> Self { + Self::remote_k8s_from_env_var_with_profile(env_var, "HARMONY_PROFILE") + } + + pub fn remote_k8s_from_env_var_with_profile(env_var: &str, profile_env_var: &str) -> Self { + debug!("Looking for env var named : {env_var}"); + let env_var_value = std::env::var(env_var) + .map_err(|e| format!("Missing required env var {env_var} : {e}")) + .unwrap(); + info!("Initializing remote k8s from env var value : {env_var_value}"); + + let mut kubeconfig: Option = None; + let mut k8s_context: Option = None; + + for part in env_var_value.split(',') { + let kv: Vec<&str> = part.splitn(2, '=').collect(); + if kv.len() == 2 { + match kv[0].trim() { + "kubeconfig" => kubeconfig = Some(kv[1].trim().to_string()), + "context" => k8s_context = Some(kv[1].trim().to_string()), + _ => {} + } + } + } + + debug!("Found in {env_var} : kubeconfig {kubeconfig:?} and context {k8s_context:?}"); + + let use_system_kubeconfig = kubeconfig.is_none(); + + if let Some(kubeconfig_value) = std::env::var("KUBECONFIG").ok().map(|v| v.to_string()) { + kubeconfig.get_or_insert(kubeconfig_value); + } + info!("Loading k8s environment with kubeconfig {kubeconfig:?} and context {k8s_context:?}"); + + K8sAnywhereConfig { + kubeconfig, + k8s_context, + use_system_kubeconfig, + autoinstall: false, + use_local_k3d: false, + harmony_profile: std::env::var(profile_env_var).unwrap_or_else(|_| "dev".to_string()), + } + } + fn from_env() -> Self { Self { kubeconfig: std::env::var("KUBECONFIG").ok().map(|v| v.to_string()), @@ -909,6 +1019,7 @@ impl K8sAnywhereConfig { ), use_local_k3d: std::env::var("HARMONY_USE_LOCAL_K3D") .map_or_else(|_| true, |v| v.parse().ok().unwrap_or(true)), + k8s_context: std::env::var("HARMONY_K8S_CONTEXT").ok(), } } } @@ -1107,3 +1218,181 @@ fn extract_base_domain(host: &str) -> Option { None } } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::{AtomicUsize, Ordering}; + + static TEST_COUNTER: AtomicUsize = AtomicUsize::new(0); + + /// Sets environment variables with unique names to avoid concurrency issues between tests. + /// Returns the names of the (config_var, profile_var) used. + fn setup_env_vars(config_value: Option<&str>, profile_value: Option<&str>) -> (String, String) { + let id = TEST_COUNTER.fetch_add(1, Ordering::SeqCst); + let config_var = format!("TEST_VAR_{}", id); + let profile_var = format!("TEST_PROFILE_{}", id); + + unsafe { + if let Some(v) = config_value { + std::env::set_var(&config_var, v); + } else { + std::env::remove_var(&config_var); + } + + if let Some(v) = profile_value { + std::env::set_var(&profile_var, v); + } else { + std::env::remove_var(&profile_var); + } + } + + (config_var, profile_var) + } + + /// Runs a test in a separate thread to avoid polluting the process environment. + fn run_in_isolated_env(f: F) + where + F: FnOnce() + Send + 'static, + { + let handle = std::thread::spawn(f); + handle.join().expect("Test thread panicked"); + } + + #[test] + fn test_remote_k8s_from_env_var_full() { + let (config_var, profile_var) = + setup_env_vars(Some("kubeconfig=/foo.kc,context=bar"), Some("testprof")); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!(cfg.kubeconfig.as_deref(), Some("/foo.kc")); + assert_eq!(cfg.k8s_context.as_deref(), Some("bar")); + assert_eq!(cfg.harmony_profile, "testprof"); + assert!(!cfg.use_local_k3d); + assert!(!cfg.autoinstall); + assert!(!cfg.use_system_kubeconfig); + } + + #[test] + fn test_remote_k8s_from_env_var_only_kubeconfig() { + let (config_var, profile_var) = setup_env_vars(Some("kubeconfig=/foo.kc"), None); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!(cfg.kubeconfig.as_deref(), Some("/foo.kc")); + assert_eq!(cfg.k8s_context, None); + assert_eq!(cfg.harmony_profile, "dev"); + } + + #[test] + fn test_remote_k8s_from_env_var_only_context() { + run_in_isolated_env(|| { + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let (config_var, profile_var) = setup_env_vars(Some("context=bar"), None); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!(cfg.kubeconfig, None); + assert_eq!(cfg.k8s_context.as_deref(), Some("bar")); + }); + } + + #[test] + fn test_remote_k8s_from_env_var_unknown_key_trim() { + run_in_isolated_env(|| { + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let (config_var, profile_var) = setup_env_vars( + Some(" unknown=bla , kubeconfig= /foo.kc ,context= bar "), + None, + ); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!(cfg.kubeconfig.as_deref(), Some("/foo.kc")); + assert_eq!(cfg.k8s_context.as_deref(), Some("bar")); + }); + } + + #[test] + fn test_remote_k8s_from_env_var_empty_malformed() { + run_in_isolated_env(|| { + unsafe { + std::env::remove_var("KUBECONFIG"); + } + let (config_var, profile_var) = setup_env_vars(Some("malformed,no=,equal"), None); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + // Unknown/malformed ignored, defaults to None + assert_eq!(cfg.kubeconfig, None); + assert_eq!(cfg.k8s_context, None); + }); + } + + #[test] + fn test_remote_k8s_from_env_var_kubeconfig_fallback() { + run_in_isolated_env(|| { + unsafe { + std::env::set_var("KUBECONFIG", "/fallback/path"); + } + let (config_var, profile_var) = setup_env_vars(Some("context=bar"), None); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!(cfg.kubeconfig.as_deref(), Some("/fallback/path")); + assert_eq!(cfg.k8s_context.as_deref(), Some("bar")); + }); + } + + #[test] + fn test_remote_k8s_from_env_var_kubeconfig_no_fallback_if_provided() { + run_in_isolated_env(|| { + unsafe { + std::env::set_var("KUBECONFIG", "/fallback/path"); + } + let (config_var, profile_var) = + setup_env_vars(Some("kubeconfig=/primary/path,context=bar"), None); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + // Primary path should take precedence + assert_eq!(cfg.kubeconfig.as_deref(), Some("/primary/path")); + assert_eq!(cfg.k8s_context.as_deref(), Some("bar")); + }); + } + + #[test] + #[should_panic(expected = "Missing required env var")] + fn test_remote_k8s_from_env_var_missing() { + let (config_var, profile_var) = setup_env_vars(None, None); + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + } + + #[test] + fn test_remote_k8s_from_env_var_context_key() { + let (config_var, profile_var) = setup_env_vars( + Some("context=default/api-sto1-harmony-mcd:6443/kube:admin"), + None, + ); + + let cfg = + K8sAnywhereConfig::remote_k8s_from_env_var_with_profile(&config_var, &profile_var); + + assert_eq!( + cfg.k8s_context.as_deref(), + Some("default/api-sto1-harmony-mcd:6443/kube:admin") + ); + } +} diff --git a/harmony/src/domain/topology/k8s_anywhere/mod.rs b/harmony/src/domain/topology/k8s_anywhere/mod.rs new file mode 100644 index 0000000..be87082 --- /dev/null +++ b/harmony/src/domain/topology/k8s_anywhere/mod.rs @@ -0,0 +1,3 @@ +mod k8s_anywhere; +mod postgres; +pub use k8s_anywhere::*; diff --git a/harmony/src/domain/topology/k8s_anywhere/postgres.rs b/harmony/src/domain/topology/k8s_anywhere/postgres.rs new file mode 100644 index 0000000..2bf800b --- /dev/null +++ b/harmony/src/domain/topology/k8s_anywhere/postgres.rs @@ -0,0 +1,128 @@ +use async_trait::async_trait; + +use crate::{ + interpret::Outcome, + inventory::Inventory, + modules::postgresql::{ + K8sPostgreSQLScore, + capability::{PostgreSQL, PostgreSQLConfig, PostgreSQLEndpoint, ReplicationCerts}, + }, + score::Score, + topology::{K8sAnywhereTopology, K8sclient}, +}; + +use k8s_openapi::api::core::v1::{Secret, Service}; +use log::info; + +#[async_trait] +impl PostgreSQL for K8sAnywhereTopology { + async fn deploy(&self, config: &PostgreSQLConfig) -> Result { + K8sPostgreSQLScore { + config: config.clone(), + } + .interpret(&Inventory::empty(), self) + .await + .map_err(|e| format!("Failed to deploy k8s postgresql : {e}"))?; + + Ok(config.cluster_name.clone()) + } + + /// Extracts PostgreSQL-specific replication certs (PEM format) from a deployed primary cluster. + /// Abstracts away storage/retrieval details (e.g., secrets, files). + async fn get_replication_certs( + &self, + config: &PostgreSQLConfig, + ) -> Result { + let cluster_name = &config.cluster_name; + let namespace = &config.namespace; + let k8s_client = self.k8s_client().await.map_err(|e| e.to_string())?; + + let replication_secret_name = format!("{cluster_name}-replication"); + let replication_secret = k8s_client + .get_resource::(&replication_secret_name, Some(namespace)) + .await + .map_err(|e| format!("Failed to get {replication_secret_name}: {e}"))? + .ok_or_else(|| format!("Replication secret '{replication_secret_name}' not found"))?; + + let ca_secret_name = format!("{cluster_name}-ca"); + let ca_secret = k8s_client + .get_resource::(&ca_secret_name, Some(namespace)) + .await + .map_err(|e| format!("Failed to get {ca_secret_name}: {e}"))? + .ok_or_else(|| format!("CA secret '{ca_secret_name}' not found"))?; + + let replication_data = replication_secret + .data + .as_ref() + .ok_or("Replication secret has no data".to_string())?; + let ca_data = ca_secret + .data + .as_ref() + .ok_or("CA secret has no data".to_string())?; + + let tls_key_bs = replication_data + .get("tls.key") + .ok_or("missing tls.key in replication secret".to_string())?; + let tls_crt_bs = replication_data + .get("tls.crt") + .ok_or("missing tls.crt in replication secret".to_string())?; + let ca_crt_bs = ca_data + .get("ca.crt") + .ok_or("missing ca.crt in CA secret".to_string())?; + + let streaming_replica_key_pem = String::from_utf8_lossy(&tls_key_bs.0).to_string(); + let streaming_replica_cert_pem = String::from_utf8_lossy(&tls_crt_bs.0).to_string(); + let ca_cert_pem = String::from_utf8_lossy(&ca_crt_bs.0).to_string(); + + info!("Successfully extracted replication certs for cluster '{cluster_name}'"); + + Ok(ReplicationCerts { + ca_cert_pem, + streaming_replica_cert_pem, + streaming_replica_key_pem, + }) + } + + /// Gets the internal/private endpoint (e.g., k8s service FQDN:5432) for the cluster. + async fn get_endpoint(&self, config: &PostgreSQLConfig) -> Result { + let cluster_name = &config.cluster_name; + let namespace = &config.namespace; + + let k8s_client = self.k8s_client().await.map_err(|e| e.to_string())?; + + let service_name = format!("{cluster_name}-rw"); + let service = k8s_client + .get_resource::(&service_name, Some(namespace)) + .await + .map_err(|e| format!("Failed to get service '{service_name}': {e}"))? + .ok_or_else(|| { + format!("Service '{service_name}' not found for cluster '{cluster_name}") + })?; + + let ns = service + .metadata + .namespace + .as_deref() + .unwrap_or("default") + .to_string(); + let host = format!("{service_name}.{ns}.svc.cluster.local"); + + info!("Internal endpoint for '{cluster_name}': {host}:5432"); + + Ok(PostgreSQLEndpoint { host, port: 5432 }) + } + + // /// Gets the public/externally routable endpoint if configured (e.g., OKD Route:443 for TLS passthrough). + // /// Returns None if no public endpoint (internal-only cluster). + // /// UNSTABLE: This is opinionated for initial multisite use cases. Networking abstraction is complex + // /// (cf. k8s Ingress -> Gateway API evolution); may move to higher-order Networking/PostgreSQLNetworking trait. + // async fn get_public_endpoint( + // &self, + // cluster_name: &str, + // ) -> Result, String> { + // // TODO: Implement OpenShift Route lookup targeting '{cluster_name}-rw' service on port 5432 with TLS passthrough + // // For now, return None assuming internal-only access or manual route configuration + // info!("Public endpoint lookup not implemented for '{cluster_name}', returning None"); + // Ok(None) + // } +} diff --git a/harmony/src/domain/topology/mod.rs b/harmony/src/domain/topology/mod.rs index 142969a..6181372 100644 --- a/harmony/src/domain/topology/mod.rs +++ b/harmony/src/domain/topology/mod.rs @@ -16,7 +16,7 @@ pub use k8s_anywhere::*; pub use localhost::*; pub mod k8s; mod load_balancer; -mod router; +pub mod router; mod tftp; use async_trait::async_trait; pub use ha_cluster::*; diff --git a/harmony/src/domain/topology/router.rs b/harmony/src/domain/topology/router.rs index 7c56e6a..5217c82 100644 --- a/harmony/src/domain/topology/router.rs +++ b/harmony/src/domain/topology/router.rs @@ -1,11 +1,20 @@ +use async_trait::async_trait; use cidr::Ipv4Cidr; use derive_new::new; +use serde::Serialize; use super::{IpAddress, LogicalHost}; +/// Basic network router abstraction (L3 IP routing/gateway). +/// Distinguished from TlsRouter (L4 TLS passthrough). pub trait Router: Send + Sync { + /// Gateway IP address for this subnet/router. fn get_gateway(&self) -> IpAddress; + + /// CIDR block managed by this router. fn get_cidr(&self) -> Ipv4Cidr; + + /// Logical host associated with this router. fn get_host(&self) -> LogicalHost; } @@ -38,3 +47,78 @@ impl Router for UnmanagedRouter { todo!() } } + +/// Desired state config for a TLS passthrough route. +/// Forwards external TLS (port 443) → backend service:target_port (no termination at router). +/// Inspired by CNPG multisite: exposes `-rw`/`-ro` services publicly via OKD Route/HAProxy/K8s +/// Gateway etc. +/// +/// # Example +/// ``` +/// use harmony::topology::router::TlsRoute; +/// let postgres_rw = TlsRoute { +/// hostname: "postgres-cluster-example.public.domain.io".to_string(), +/// backend: "postgres-cluster-example-rw".to_string(), // k8s Service or HAProxy upstream +/// target_port: 5432, +/// namespace: "sample-namespace".to_string(), +/// }; +/// ``` +#[derive(Clone, Debug, Serialize)] +pub struct TlsRoute { + /// Public hostname clients connect to (TLS SNI, port 443 implicit). + /// Router matches this for passthrough forwarding. + pub hostname: String, + + /// Backend/host identifier (k8s Service, HAProxy upstream, IP/FQDN, etc.). + pub backend: String, + + /// Backend TCP port (Postgres: 5432). + pub target_port: u16, + + /// The environment in which it lives. + /// TODO clarify how we handle this in higher level abstractions. The namespace name is a + /// direct mapping to k8s but that could be misleading for other implementations. + pub namespace: String, +} + +impl TlsRoute { + pub fn to_string_short(&self) -> String { + format!("{}-{}:{}", self.hostname, self.backend, self.target_port) + } + + pub fn backend_info_string(&self) -> String { + format!("{}:{}", self.backend, self.target_port) + } +} + +/// Installs and queries TLS passthrough routes (L4 TCP/SNI forwarding, no TLS termination). +/// Agnostic to impl: OKD Route, AWS NLB+HAProxy, k3s Envoy Gateway, Apache ProxyPass. +/// Used by PostgreSQL capability to expose CNPG clusters multisite (site1 → site2 replication). +/// +/// # Usage +/// ```ignore +/// use harmony::topology::router::TlsRoute; +/// // After CNPG deploy, expose RW endpoint +/// async fn route() { +/// let topology = okd_topology(); +/// let route = TlsRoute { /* ... */ }; +/// topology.install_route(route).await; // OKD Route, HAProxy reload, etc. +/// } +/// ``` +#[async_trait] +pub trait TlsRouter: Send + Sync { + /// Provisions the route (idempotent where possible). + /// Example: OKD Route{ host, to: backend:target_port, tls: {passthrough} }; + /// HAProxy frontend→backend \"postgres-upstream\". + async fn install_route(&self, config: TlsRoute) -> Result<(), String>; + + /// Gets the base domain that can be used to deploy applications that will be automatically + /// routed to this cluster. + /// + /// For example, if we have *.apps.nationtech.io pointing to a public load balancer, then this + /// function would install route apps.nationtech.io + async fn get_wildcard_domain(&self) -> Result, String>; + + /// Returns the port that this router exposes externally. + async fn get_router_port(&self) -> u16; +} diff --git a/harmony/src/modules/application/features/helm_argocd_score.rs b/harmony/src/modules/application/features/helm_argocd_score.rs index 9e9096f..4a65a1e 100644 --- a/harmony/src/modules/application/features/helm_argocd_score.rs +++ b/harmony/src/modules/application/features/helm_argocd_score.rs @@ -1,6 +1,5 @@ use async_trait::async_trait; use harmony_macros::hurl; -use kube::{Api, api::GroupVersionKind}; use log::{debug, info, trace, warn}; use non_blank_string_rs::NonBlankString; use serde::Serialize; diff --git a/harmony/src/modules/k8s/failover.rs b/harmony/src/modules/k8s/failover.rs new file mode 100644 index 0000000..939d9ab --- /dev/null +++ b/harmony/src/modules/k8s/failover.rs @@ -0,0 +1,19 @@ +use std::sync::Arc; + +use async_trait::async_trait; +use log::warn; + +use crate::topology::{FailoverTopology, K8sclient, k8s::K8sClient}; + +#[async_trait] +impl K8sclient for FailoverTopology { + // TODO figure out how to structure this properly. This gives access only to the primary k8s + // client, which will work in many cases but is clearly not good enough for all uses cases + // where k8s_client can be used. Logging a warning for now. + async fn k8s_client(&self) -> Result, String> { + warn!( + "Failover topology k8s_client capability currently defers to the primary only. Make sure to check this is OK for you" + ); + self.primary.k8s_client().await + } +} diff --git a/harmony/src/modules/k8s/mod.rs b/harmony/src/modules/k8s/mod.rs index 56c9201..29264b4 100644 --- a/harmony/src/modules/k8s/mod.rs +++ b/harmony/src/modules/k8s/mod.rs @@ -1,5 +1,6 @@ pub mod apps; pub mod deployment; +mod failover; pub mod ingress; pub mod namespace; pub mod resource; diff --git a/harmony/src/modules/k8s/resource.rs b/harmony/src/modules/k8s/resource.rs index 57f9731..dde7339 100644 --- a/harmony/src/modules/k8s/resource.rs +++ b/harmony/src/modules/k8s/resource.rs @@ -79,7 +79,33 @@ where _inventory: &Inventory, topology: &T, ) -> Result { - info!("Applying {} resources", self.score.resource.len()); + // TODO improve this log + let resource_names: Vec = self + .score + .resource + .iter() + .map(|r| { + format!( + "{}{}", + r.meta() + .name + .as_ref() + .map(|n| format!("{n}")) + .unwrap_or_default(), + r.meta() + .namespace + .as_ref() + .map(|ns| format!("@{}", ns)) + .unwrap_or_default() + ) + }) + .collect(); + + info!( + "Applying {} resources : {}", + resource_names.len(), + resource_names.join(", ") + ); topology .k8s_client() .await diff --git a/harmony/src/modules/mod.rs b/harmony/src/modules/mod.rs index 550be75..c845fb1 100644 --- a/harmony/src/modules/mod.rs +++ b/harmony/src/modules/mod.rs @@ -13,6 +13,7 @@ pub mod k8s; pub mod lamp; pub mod load_balancer; pub mod monitoring; +pub mod network; pub mod okd; pub mod opnsense; pub mod postgresql; diff --git a/harmony/src/modules/network/failover.rs b/harmony/src/modules/network/failover.rs new file mode 100644 index 0000000..3880c02 --- /dev/null +++ b/harmony/src/modules/network/failover.rs @@ -0,0 +1,22 @@ +use async_trait::async_trait; +use log::warn; + +use crate::topology::{FailoverTopology, TlsRoute, TlsRouter}; + +#[async_trait] +impl TlsRouter for FailoverTopology { + async fn get_wildcard_domain(&self) -> Result, String> { + todo!() + } + + /// Returns the port that this router exposes externally. + async fn get_router_port(&self) -> u16 { + todo!() + } + async fn install_route(&self, config: TlsRoute) -> Result<(), String> { + warn!( + "Failover topology TlsRouter capability currently defers to the primary only. Make sure to check this is OK for you. The Replica Topology WILL NOT be affected here" + ); + self.primary.install_route(config).await + } +} diff --git a/harmony/src/modules/network/mod.rs b/harmony/src/modules/network/mod.rs new file mode 100644 index 0000000..53c91f2 --- /dev/null +++ b/harmony/src/modules/network/mod.rs @@ -0,0 +1,3 @@ +mod failover; +mod tls_router; +pub use tls_router::*; diff --git a/harmony/src/modules/network/tls_router.rs b/harmony/src/modules/network/tls_router.rs new file mode 100644 index 0000000..762f981 --- /dev/null +++ b/harmony/src/modules/network/tls_router.rs @@ -0,0 +1,92 @@ +use async_trait::async_trait; +use harmony_types::id::Id; +use serde::Serialize; + +use crate::data::Version; +use crate::domain::topology::router::{TlsRoute, TlsRouter}; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; +use crate::score::Score; +use crate::topology::{K8sclient, Topology}; + +/// Score for provisioning a TLS passthrough route. +/// Exposes backend services via TLS passthrough (L4 TCP/SNI forwarding). +/// Agnostic to underlying router impl (OKD Route, HAProxy, Envoy, etc.). +/// +/// TlsPassthroughScore relies on the TlsRouter Capability for its entire functionnality, +/// the implementation depends entirely on how the Topology implements it. +/// +/// # Usage +/// ``` +/// use harmony::modules::network::TlsPassthroughScore; +/// use harmony::topology::router::TlsRoute; +/// let score = TlsPassthroughScore { +/// route: TlsRoute { +/// backend: "postgres-cluster-rw".to_string(), +/// hostname: "postgres-rw.example.com".to_string(), +/// target_port: 5432, +/// namespace: "example-namespace".to_string(), +/// }, +/// }; +/// ``` +/// +/// # Hint +/// +/// **This TlsPassthroughScore should be used whenever possible.** It is effectively +/// an abstraction over the concept of tls passthrough, and it will allow much more flexible +/// usage over multiple types of Topology than using a lower level module such as +/// OKDTlsPassthroughScore. +/// +/// On the other hand, some implementation specific options might not be available or practical +/// to use through this high level TlsPassthroughScore. +#[derive(Debug, Clone, Serialize)] +pub struct TlsPassthroughScore { + pub route: TlsRoute, +} + +impl Score for TlsPassthroughScore { + fn create_interpret(&self) -> Box> { + Box::new(TlsPassthroughInterpret { + tls_route: self.route.clone(), + }) + } + + fn name(&self) -> String { + format!( + "TlsRouterScore({}:{} → {})", + self.route.backend, self.route.target_port, self.route.hostname + ) + } +} + +/// Custom interpret: provisions the TLS passthrough route on the topology. +#[derive(Debug, Clone)] +struct TlsPassthroughInterpret { + tls_route: TlsRoute, +} + +#[async_trait] +impl Interpret for TlsPassthroughInterpret { + fn get_name(&self) -> InterpretName { + InterpretName::Custom("TlsRouterInterpret") + } + fn get_version(&self) -> Version { + todo!() + } + fn get_status(&self) -> InterpretStatus { + todo!() + } + fn get_children(&self) -> Vec { + todo!() + } + async fn execute(&self, _inventory: &Inventory, topo: &T) -> Result { + topo.install_route(self.tls_route.clone()) + .await + .map_err(|e| InterpretError::new(e.to_string()))?; + + Ok(Outcome::success(format!( + "TLS route installed: {} → {}:{}", + self.tls_route.hostname, self.tls_route.backend, self.tls_route.target_port + ))) + } +} diff --git a/harmony/src/modules/okd/crd/mod.rs b/harmony/src/modules/okd/crd/mod.rs index 568db3f..71c4d0a 100644 --- a/harmony/src/modules/okd/crd/mod.rs +++ b/harmony/src/modules/okd/crd/mod.rs @@ -1 +1,2 @@ pub mod nmstate; +pub mod route; diff --git a/harmony/src/modules/okd/crd/route.rs b/harmony/src/modules/okd/crd/route.rs new file mode 100644 index 0000000..4c396d0 --- /dev/null +++ b/harmony/src/modules/okd/crd/route.rs @@ -0,0 +1,287 @@ +use k8s_openapi::apimachinery::pkg::apis::meta::v1::{ListMeta, ObjectMeta, Time}; +use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString; +use k8s_openapi::{NamespaceResourceScope, Resource}; +use serde::{Deserialize, Serialize}; + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct LocalObjectReference { + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct Route { + #[serde(skip_serializing_if = "Option::is_none")] + pub api_version: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub kind: Option, + pub metadata: ObjectMeta, + + pub spec: RouteSpec, + + #[serde(skip_serializing_if = "Option::is_none")] + pub status: Option, +} + +impl Resource for Route { + const API_VERSION: &'static str = "route.openshift.io/v1"; + const GROUP: &'static str = "route.openshift.io"; + const VERSION: &'static str = "v1"; + const KIND: &'static str = "Route"; + const URL_PATH_SEGMENT: &'static str = "routes"; + type Scope = NamespaceResourceScope; +} + +impl k8s_openapi::Metadata for Route { + type Ty = ObjectMeta; + + fn metadata(&self) -> &Self::Ty { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut Self::Ty { + &mut self.metadata + } +} + +impl Default for Route { + fn default() -> Self { + Route { + api_version: Some("route.openshift.io/v1".to_string()), + kind: Some("Route".to_string()), + metadata: ObjectMeta::default(), + spec: RouteSpec::default(), + status: None, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteList { + pub metadata: ListMeta, + pub items: Vec, +} + +impl Default for RouteList { + fn default() -> Self { + Self { + metadata: ListMeta::default(), + items: Vec::new(), + } + } +} + +impl Resource for RouteList { + const API_VERSION: &'static str = "route.openshift.io/v1"; + const GROUP: &'static str = "route.openshift.io"; + const VERSION: &'static str = "v1"; + const KIND: &'static str = "RouteList"; + const URL_PATH_SEGMENT: &'static str = "routes"; + type Scope = NamespaceResourceScope; +} + +impl k8s_openapi::Metadata for RouteList { + type Ty = ListMeta; + + fn metadata(&self) -> &Self::Ty { + &self.metadata + } + + fn metadata_mut(&mut self) -> &mut Self::Ty { + &mut self.metadata + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteSpec { + #[serde(skip_serializing_if = "Option::is_none")] + pub alternate_backends: Option>, + + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub http_headers: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub path: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub port: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub subdomain: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub tls: Option, + + pub to: RouteTargetReference, + + #[serde(skip_serializing_if = "Option::is_none")] + pub wildcard_policy: Option, +} +impl Default for RouteSpec { + fn default() -> RouteSpec { + RouteSpec { + alternate_backends: None, + host: None, + http_headers: None, + path: None, + port: None, + subdomain: None, + tls: None, + to: RouteTargetReference::default(), + wildcard_policy: None, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteTargetReference { + pub kind: String, + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub weight: Option, +} +impl Default for RouteTargetReference { + fn default() -> RouteTargetReference { + RouteTargetReference { + kind: String::default(), + name: String::default(), + weight: None, + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RoutePort { + pub target_port: u16, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct TLSConfig { + #[serde(skip_serializing_if = "Option::is_none")] + pub ca_certificate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub certificate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub destination_ca_certificate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub external_certificate: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub insecure_edge_termination_policy: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub key: Option, + + pub termination: String, +} + +impl Default for TLSConfig { + fn default() -> Self { + Self { + ca_certificate: None, + certificate: None, + destination_ca_certificate: None, + external_certificate: None, + insecure_edge_termination_policy: None, + key: None, + termination: "edge".to_string(), + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteStatus { + #[serde(skip_serializing_if = "Option::is_none")] + pub ingress: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteIngress { + #[serde(skip_serializing_if = "Option::is_none")] + pub host: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub router_canonical_hostname: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub router_name: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub wildcard_policy: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub conditions: Option>, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct RouteIngressCondition { + #[serde(skip_serializing_if = "Option::is_none")] + pub last_transition_time: Option