From 9edc42a6651dd7b93432d11ab31fa78c6bc1d93b Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Thu, 11 Dec 2025 14:35:18 -0500 Subject: [PATCH 01/20] feat: PostgreSQLScore happy path using cnpg operator --- harmony/src/modules/postgresql/cnpg/crd.rs | 57 ++++++++ harmony/src/modules/postgresql/cnpg/mod.rs | 2 + harmony/src/modules/postgresql/mod.rs | 3 + harmony/src/modules/postgresql/score.rs | 145 ++++++++++----------- 4 files changed, 134 insertions(+), 73 deletions(-) create mode 100644 harmony/src/modules/postgresql/cnpg/crd.rs create mode 100644 harmony/src/modules/postgresql/cnpg/mod.rs diff --git a/harmony/src/modules/postgresql/cnpg/crd.rs b/harmony/src/modules/postgresql/cnpg/crd.rs new file mode 100644 index 0000000..fad1003 --- /dev/null +++ b/harmony/src/modules/postgresql/cnpg/crd.rs @@ -0,0 +1,57 @@ +use kube::{api::ObjectMeta, CustomResource}; +use serde::{Deserialize, Serialize}; + +#[derive(CustomResource, Deserialize, Serialize, Clone, Debug)] +#[kube( + group = "postgresql.cnpg.io", + version = "v1", + kind = "Cluster", + plural = "clusters", + namespaced = true, + schema = "disabled" +)] +pub struct ClusterSpec { + pub instances: i32, + pub image_name: Option, + pub storage: Storage, + pub bootstrap: Bootstrap, +} + +impl Default for Cluster { + fn default() -> Self { + Cluster { + metadata: ObjectMeta::default(), + spec: ClusterSpec::default(), + } + } +} + +impl Default for ClusterSpec { + fn default() -> Self { + Self { + instances: 1, + image_name: None, + storage: Storage::default(), + bootstrap: Bootstrap::default(), + } + } +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct Storage { + pub size: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct Bootstrap { + pub initdb: Initdb, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Default)] +#[serde(rename_all = "camelCase")] +pub struct Initdb { + pub database: String, + pub owner: String, +} diff --git a/harmony/src/modules/postgresql/cnpg/mod.rs b/harmony/src/modules/postgresql/cnpg/mod.rs new file mode 100644 index 0000000..0237c67 --- /dev/null +++ b/harmony/src/modules/postgresql/cnpg/mod.rs @@ -0,0 +1,2 @@ +mod crd; +pub use crd::*; diff --git a/harmony/src/modules/postgresql/mod.rs b/harmony/src/modules/postgresql/mod.rs index 539a298..377e4d4 100644 --- a/harmony/src/modules/postgresql/mod.rs +++ b/harmony/src/modules/postgresql/mod.rs @@ -1,6 +1,9 @@ pub mod capability; mod score; +pub use score::*; pub mod failover; mod operator; pub use operator::*; + +pub mod cnpg; diff --git a/harmony/src/modules/postgresql/score.rs b/harmony/src/modules/postgresql/score.rs index 5c6f428..d32c1fc 100644 --- a/harmony/src/modules/postgresql/score.rs +++ b/harmony/src/modules/postgresql/score.rs @@ -1,88 +1,87 @@ -use crate::{ - domain::{data::Version, interpret::InterpretStatus}, - interpret::{Interpret, InterpretError, InterpretName, Outcome}, - inventory::Inventory, - modules::postgresql::capability::PostgreSQL, - score::Score, - topology::Topology, -}; - -use super::capability::*; - -use harmony_types::id::Id; - -use async_trait::async_trait; -use log::info; use serde::Serialize; -#[derive(Clone, Debug, Serialize)] +use crate::interpret::Interpret; +use crate::modules::k8s::resource::K8sResourceScore; +use crate::modules::postgresql::cnpg::{Bootstrap, Cluster, ClusterSpec, Initdb, Storage}; +use crate::score::Score; +use crate::topology::{K8sclient, Topology}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + +/// Deploys an opinionated, highly available PostgreSQL cluster managed by CNPG. +/// +/// # Goals +/// - Production-ready Postgres HA (3 instances), persistent storage, app DB. +/// +/// # Usage +/// ``` +/// use harmony::modules::postgresql::PostgreSQLScore; +/// let score = PostgreSQLScore::new("my-app-ns"); +/// ``` +/// +/// # Limitations (Happy Path) +/// - Requires CNPG operator installed (use CloudNativePgOperatorScore). +/// - No backups, monitoring, extensions configured. +/// +/// TODO : refactor this to declare a clean dependency on cnpg operator. Then cnpg operator will +/// self-deploy either using operatorhub or helm chart depending on k8s flavor. This is cnpg +/// specific behavior +#[derive(Debug, Clone, Serialize)] pub struct PostgreSQLScore { - config: PostgreSQLConfig, + pub namespace: String, + pub instances: i32, + pub storage_size: String, + pub image_name: Option, } -#[derive(Debug, Clone)] -pub struct PostgreSQLInterpret { - config: PostgreSQLConfig, - version: Version, - status: InterpretStatus, -} - -impl PostgreSQLInterpret { - pub fn new(config: PostgreSQLConfig) -> Self { - let version = Version::from("1.0.0").expect("Version should be valid"); +impl Default for PostgreSQLScore { + fn default() -> Self { Self { - config, - version, - status: InterpretStatus::QUEUED, + namespace: "default".to_string(), + instances: 1, + storage_size: "1Gi".to_string(), + image_name: None, // This lets cnpg use its default image } } } -impl Score for PostgreSQLScore { - fn name(&self) -> String { - "PostgreSQLScore".to_string() +impl PostgreSQLScore { + pub fn new(namespace: &str) -> Self { + Self { + namespace: namespace.to_string(), + ..Default::default() + } } +} +impl Score for PostgreSQLScore { fn create_interpret(&self) -> Box> { - Box::new(PostgreSQLInterpret::new(self.config.clone())) - } -} - -#[async_trait] -impl Interpret for PostgreSQLInterpret { - fn get_name(&self) -> InterpretName { - InterpretName::Custom("PostgreSQLInterpret") - } - - fn get_version(&self) -> crate::domain::data::Version { - self.version.clone() - } - - fn get_status(&self) -> InterpretStatus { - self.status.clone() - } - - fn get_children(&self) -> Vec { - todo!() - } - - async fn execute( - &self, - _inventory: &Inventory, - topology: &T, - ) -> Result { - info!( - "Executing PostgreSQLInterpret with config {:?}", - self.config - ); - - let cluster_name = topology - .deploy(&self.config) - .await - .map_err(|e| InterpretError::from(e))?; - - Ok(Outcome::success(format!( - "Deployed PostgreSQL cluster `{cluster_name}`" - ))) + let metadata = ObjectMeta { + name: Some("postgres".to_string()), + namespace: Some(self.namespace.clone()), + ..ObjectMeta::default() + }; + + let spec = ClusterSpec { + instances: self.instances, + image_name: self.image_name.clone(), + storage: Storage { + size: self.storage_size.clone(), + }, + bootstrap: Bootstrap { + initdb: Initdb { + database: "app".to_string(), + owner: "app".to_string(), + }, + }, + ..ClusterSpec::default() + }; + + let cluster = Cluster { metadata, spec }; + + K8sResourceScore::single(cluster, Some(self.namespace.clone())).create_interpret() + } + + fn name(&self) -> String { + format!("PostgreSQLScore({})", self.namespace) } } -- 2.39.5 From 2e367d88d4a5bf37e415854de7c41f637ef46f8a Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Thu, 11 Dec 2025 22:54:57 -0500 Subject: [PATCH 02/20] feat: PostgreSQL score works, added postgresql example, tested on OKD 4.19, added note about incompatible default namespace settings --- examples/postgresql/Cargo.toml | 18 ++++++++++++++++++ examples/postgresql/src/main.rs | 22 ++++++++++++++++++++++ harmony/src/modules/postgresql/cnpg/crd.rs | 1 + harmony/src/modules/postgresql/score.rs | 10 ++++++++-- 4 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 examples/postgresql/Cargo.toml create mode 100644 examples/postgresql/src/main.rs 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..13da98a --- /dev/null +++ b/examples/postgresql/src/main.rs @@ -0,0 +1,22 @@ +use harmony::{ + inventory::Inventory, modules::postgresql::PostgreSQLScore, topology::K8sAnywhereTopology, +}; + +#[tokio::main] +async fn main() { + let postgresql = PostgreSQLScore { + 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/harmony/src/modules/postgresql/cnpg/crd.rs b/harmony/src/modules/postgresql/cnpg/crd.rs index fad1003..5f06156 100644 --- a/harmony/src/modules/postgresql/cnpg/crd.rs +++ b/harmony/src/modules/postgresql/cnpg/crd.rs @@ -10,6 +10,7 @@ use serde::{Deserialize, Serialize}; namespaced = true, schema = "disabled" )] +#[serde(rename_all = "camelCase")] pub struct ClusterSpec { pub instances: i32, pub image_name: Option, diff --git a/harmony/src/modules/postgresql/score.rs b/harmony/src/modules/postgresql/score.rs index d32c1fc..2a5e82f 100644 --- a/harmony/src/modules/postgresql/score.rs +++ b/harmony/src/modules/postgresql/score.rs @@ -27,6 +27,9 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; /// specific behavior #[derive(Debug, Clone, Serialize)] pub struct PostgreSQLScore { + pub name: String, + /// **Note :** on OpenShfit based clusters, the namespace `default` has security + /// settings incompatible with the default CNPG behavior. pub namespace: String, pub instances: i32, pub storage_size: String, @@ -36,7 +39,10 @@ pub struct PostgreSQLScore { impl Default for PostgreSQLScore { fn default() -> Self { Self { - namespace: "default".to_string(), + name: "harmony-pg".to_string(), + // We are using the namespace harmony by default since some clusters (openshift family) + // have incompatible configuration of the default namespace with cnpg + namespace: "harmony".to_string(), instances: 1, storage_size: "1Gi".to_string(), image_name: None, // This lets cnpg use its default image @@ -56,7 +62,7 @@ impl PostgreSQLScore { impl Score for PostgreSQLScore { fn create_interpret(&self) -> Box> { let metadata = ObjectMeta { - name: Some("postgres".to_string()), + name: Some(self.name.clone()), namespace: Some(self.namespace.clone()), ..ObjectMeta::default() }; -- 2.39.5 From b61e4f9a96812eecd346b59c376fb5dc7c5c1e14 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 13 Dec 2025 09:47:59 -0500 Subject: [PATCH 03/20] wip: Expose postgres publicly. Created tlsroute capability and postgres implementations --- harmony/src/domain/topology/router.rs | 81 +++++++++++++++++ harmony/src/modules/postgresql/mod.rs | 2 + .../src/modules/postgresql/score_public.rs | 89 +++++++++++++++++++ 3 files changed, 172 insertions(+) create mode 100644 harmony/src/modules/postgresql/score_public.rs diff --git a/harmony/src/domain/topology/router.rs b/harmony/src/domain/topology/router.rs index 7c56e6a..3c8721d 100644 --- a/harmony/src/domain/topology/router.rs +++ b/harmony/src/domain/topology/router.rs @@ -1,11 +1,19 @@ +use async_trait::async_trait; use cidr::Ipv4Cidr; use derive_new::new; 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 +46,76 @@ impl Router for UnmanagedRouter { todo!() } } + + +#[derive(Clone, Debug)] + +/// 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::domain::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, +/// }; +/// ``` +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, +} + +#[async_trait] + +/// 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 +/// ```rust,no_run +/// // After CNPG deploy, expose RW endpoint +/// let topology = okd_topology(); +/// let route = TlsRoute { /* ... */ }; +/// topology.install_route(route).await?; // OKD Route, HAProxy reload, etc. +/// +/// // Client: psql \\"host={route.hostname} port=443 sslmode=verify-ca sslnegotiation=direct\\" +/// let public_ep = Endpoint { host: topology.hostname(), port: 443 }; +/// ``` +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>; + + /// Installed route's public hostname. + fn hostname(&self) -> String; + + /// Installed route's backend identifier. + fn backend(&self) -> String; + + /// Installed route's backend port. + fn target_port(&self) -> u16; +} + +impl std::fmt::Debug for dyn TlsRouter { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_fmt(format_args!( + "TlsRouter[hostname={}, backend={}:{}]", + self.hostname(), + self.backend(), + self.target_port() + )) + } +} + diff --git a/harmony/src/modules/postgresql/mod.rs b/harmony/src/modules/postgresql/mod.rs index 377e4d4..11c21aa 100644 --- a/harmony/src/modules/postgresql/mod.rs +++ b/harmony/src/modules/postgresql/mod.rs @@ -1,6 +1,8 @@ pub mod capability; mod score; pub use score::*; +mod score_public; +pub use score_public::*; pub mod failover; mod operator; diff --git a/harmony/src/modules/postgresql/score_public.rs b/harmony/src/modules/postgresql/score_public.rs new file mode 100644 index 0000000..1bbe8fa --- /dev/null +++ b/harmony/src/modules/postgresql/score_public.rs @@ -0,0 +1,89 @@ +use async_trait::async_trait; +use serde::Serialize; + +use crate::domain::topology::router::{TlsRoute, TlsRouter}; +use crate::interpret::Interpret; +use crate::modules::k8s::resource::K8sResourceScore; +use crate::modules::postgresql::cnpg::{Bootstrap, Cluster, ClusterSpec, Initdb, Storage}; +use crate::modules::postgresql::PostgreSQLScore; +use crate::score::Score; +use crate::topology::{K8sclient, Topology}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + +/// Deploys a public PostgreSQL cluster: CNPG + TLS passthrough route for RW endpoint. +/// For failover/multisite: exposes single-instance or small HA Postgres publicly. +/// +/// Sequence: PostgreSQLScore → TlsRouter::install_route (RW backend). +/// +/// # Usage +/// ``` +/// use harmony::modules::postgresql::PublicPostgreSQLScore; +/// let score = PublicPostgreSQLScore::new("harmony", "pg-rw.example.com"); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct PublicPostgreSQLScore { + /// Inner non-public Postgres cluster config. + pub inner: PostgreSQLScore, + /// Public hostname for RW TLS passthrough (port 443 → cluster-rw:5432). + pub hostname: String, +} + +impl Default for PublicPostgreSQLScore { + fn default() -> Self { + Self { + inner: PostgreSQLScore::default(), + hostname: "postgres.default.public".to_string(), + } + } +} + +impl PublicPostgreSQLScore { + pub fn new(namespace: &str, hostname: &str) -> Self { + Self { + inner: PostgreSQLScore::new(namespace), + hostname: hostname.to_string(), + } + } +} + +/// Custom interpret: deploy Postgres then install public TLS route. +#[derive(Debug, Clone)] +struct PublicPostgreSQLInterpret { + postgres_score: PostgreSQLScore, + tls_route: TlsRoute, +} + +#[async_trait] +impl Interpret for PublicPostgreSQLInterpret { + async fn interpret(&self, topo: &T) -> Result<(), Box> { + // Deploy CNPG cluster first (creates -rw service) + self.postgres_score.create_interpret().interpret(topo).await?; + + // Expose RW publicly via TLS passthrough + topo.install_route(self.tls_route.clone()).await.map_err(|e| Box::new(std::io::Error::new(std::io::ErrorKind::Other, e)) as Box)?; + + Ok(()) + } +} + +impl Score for PublicPostgreSQLScore { + fn create_interpret(&self) -> Box> { + let rw_backend = format!("{}-rw", self.inner.name); + let tls_route = TlsRoute { + hostname: self.hostname.clone(), + backend: rw_backend, + target_port: 5432, + }; + + Box::new(PublicPostgreSQLInterpret { + postgres_score: self.inner.clone(), + tls_route, + }) + } + + fn name(&self) -> String { + format!("PublicPostgreSQLScore({}:{})", self.inner.namespace, self.hostname) + } +} + +// TODO: Add RO route (separate hostname/backend="cluster-ro"), backups, failover logic. -- 2.39.5 From 2254641f3d269154af377ed8795a0f587f9b1277 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 13 Dec 2025 17:56:53 -0500 Subject: [PATCH 04/20] fix: Tests, doctests, formatting --- harmony/src/domain/topology/mod.rs | 2 +- harmony/src/domain/topology/router.rs | 24 +++++----- harmony/src/modules/postgresql/cnpg/crd.rs | 2 +- .../src/modules/postgresql/score_public.rs | 47 +++++++++++++++---- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/harmony/src/domain/topology/mod.rs b/harmony/src/domain/topology/mod.rs index 64e12d0..0e49efd 100644 --- a/harmony/src/domain/topology/mod.rs +++ b/harmony/src/domain/topology/mod.rs @@ -15,7 +15,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 3c8721d..c1a48fc 100644 --- a/harmony/src/domain/topology/router.rs +++ b/harmony/src/domain/topology/router.rs @@ -47,17 +47,16 @@ impl Router for UnmanagedRouter { } } - #[derive(Clone, Debug)] /// 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::domain::topology::router::TlsRoute; +/// 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 @@ -81,19 +80,19 @@ pub struct TlsRoute { /// 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 -/// ```rust,no_run +/// ```ignore +/// use harmony::topology::router::TlsRoute; /// // After CNPG deploy, expose RW endpoint -/// let topology = okd_topology(); -/// let route = TlsRoute { /* ... */ }; -/// topology.install_route(route).await?; // OKD Route, HAProxy reload, etc. -/// -/// // Client: psql \\"host={route.hostname} port=443 sslmode=verify-ca sslnegotiation=direct\\" -/// let public_ep = Endpoint { host: topology.hostname(), port: 443 }; +/// async fn route() { +/// let topology = okd_topology(); +/// let route = TlsRoute { /* ... */ }; +/// topology.install_route(route).await; // OKD Route, HAProxy reload, etc. +/// } /// ``` pub trait TlsRouter: Send + Sync { -/// Provisions the route (idempotent where possible). + /// 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>; @@ -118,4 +117,3 @@ impl std::fmt::Debug for dyn TlsRouter { )) } } - diff --git a/harmony/src/modules/postgresql/cnpg/crd.rs b/harmony/src/modules/postgresql/cnpg/crd.rs index 5f06156..122923e 100644 --- a/harmony/src/modules/postgresql/cnpg/crd.rs +++ b/harmony/src/modules/postgresql/cnpg/crd.rs @@ -1,4 +1,4 @@ -use kube::{api::ObjectMeta, CustomResource}; +use kube::{CustomResource, api::ObjectMeta}; use serde::{Deserialize, Serialize}; #[derive(CustomResource, Deserialize, Serialize, Clone, Debug)] diff --git a/harmony/src/modules/postgresql/score_public.rs b/harmony/src/modules/postgresql/score_public.rs index 1bbe8fa..73c2193 100644 --- a/harmony/src/modules/postgresql/score_public.rs +++ b/harmony/src/modules/postgresql/score_public.rs @@ -1,20 +1,23 @@ 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; +use crate::interpret::{Interpret, InterpretError, InterpretName, InterpretStatus, Outcome}; +use crate::inventory::Inventory; use crate::modules::k8s::resource::K8sResourceScore; -use crate::modules::postgresql::cnpg::{Bootstrap, Cluster, ClusterSpec, Initdb, Storage}; use crate::modules::postgresql::PostgreSQLScore; +use crate::modules::postgresql::cnpg::{Bootstrap, Cluster, ClusterSpec, Initdb, Storage}; use crate::score::Score; use crate::topology::{K8sclient, Topology}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; /// Deploys a public PostgreSQL cluster: CNPG + TLS passthrough route for RW endpoint. /// For failover/multisite: exposes single-instance or small HA Postgres publicly. -/// +/// /// Sequence: PostgreSQLScore → TlsRouter::install_route (RW backend). -/// +/// /// # Usage /// ``` /// use harmony::modules::postgresql::PublicPostgreSQLScore; @@ -55,14 +58,35 @@ struct PublicPostgreSQLInterpret { #[async_trait] impl Interpret for PublicPostgreSQLInterpret { - async fn interpret(&self, topo: &T) -> Result<(), Box> { + fn get_name(&self) -> InterpretName { + InterpretName::Custom("PublicPostgreSQLInterpret") + } + 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 { // Deploy CNPG cluster first (creates -rw service) - self.postgres_score.create_interpret().interpret(topo).await?; + self.postgres_score + .create_interpret() + .execute(inventory, topo) + .await?; // Expose RW publicly via TLS passthrough - topo.install_route(self.tls_route.clone()).await.map_err(|e| Box::new(std::io::Error::new(std::io::ErrorKind::Other, e)) as Box)?; + topo.install_route(self.tls_route.clone()) + .await + .map_err(|e| InterpretError::new(e))?; - Ok(()) + Ok(Outcome::success(format!( + "Public CNPG cluster '{}' deployed with TLS passthrough route '{}'", + self.postgres_score.name.clone(), + self.tls_route.hostname + ))) } } @@ -82,8 +106,11 @@ impl Score for PublicPostg } fn name(&self) -> String { - format!("PublicPostgreSQLScore({}:{})", self.inner.namespace, self.hostname) + format!( + "PublicPostgreSQLScore({}:{})", + self.inner.namespace, self.hostname + ) } -} +} // TODO: Add RO route (separate hostname/backend="cluster-ro"), backups, failover logic. -- 2.39.5 From 142300802d90bccffd667745697b6ace6ac61802 Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sat, 13 Dec 2025 22:32:27 -0500 Subject: [PATCH 05/20] wip: TlsRoute score first version --- harmony/src/domain/topology/router.rs | 3 +- harmony/src/modules/mod.rs | 1 + harmony/src/modules/network/mod.rs | 2 + harmony/src/modules/network/tls_router.rs | 99 +++++++++++++++++++++++ harmony/src/modules/okd/mod.rs | 1 + 5 files changed, 104 insertions(+), 2 deletions(-) create mode 100644 harmony/src/modules/network/mod.rs create mode 100644 harmony/src/modules/network/tls_router.rs diff --git a/harmony/src/domain/topology/router.rs b/harmony/src/domain/topology/router.rs index c1a48fc..66ae8f9 100644 --- a/harmony/src/domain/topology/router.rs +++ b/harmony/src/domain/topology/router.rs @@ -75,8 +75,6 @@ pub struct TlsRoute { pub target_port: u16, } -#[async_trait] - /// 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). @@ -91,6 +89,7 @@ pub struct 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} }; diff --git a/harmony/src/modules/mod.rs b/harmony/src/modules/mod.rs index 910b535..4c6e7b6 100644 --- a/harmony/src/modules/mod.rs +++ b/harmony/src/modules/mod.rs @@ -11,6 +11,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/mod.rs b/harmony/src/modules/network/mod.rs new file mode 100644 index 0000000..c70dd89 --- /dev/null +++ b/harmony/src/modules/network/mod.rs @@ -0,0 +1,2 @@ +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..c5e520d --- /dev/null +++ b/harmony/src/modules/network/tls_router.rs @@ -0,0 +1,99 @@ +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.). +/// +/// # Usage +/// ``` +/// use harmony::modules::network::TlsRouterScore; +/// let score = TlsRouterScore::new("postgres-cluster-rw", "pg-rw.example.com", 5432); +/// ``` +#[derive(Debug, Clone, Serialize)] +pub struct TlsRouterScore { + /// Backend identifier (k8s Service, HAProxy upstream, IP/FQDN, etc.). + pub backend: String, + /// Public hostname clients connect to (TLS SNI, port 443 implicit). + pub hostname: String, + /// Backend TCP port. + pub target_port: u16, +} + +impl Default for TlsRouterScore { + fn default() -> Self { + Self { + backend: "default-backend".to_string(), + hostname: "tls.default.public".to_string(), + target_port: 5432, + } + } +} + +impl TlsRouterScore { + pub fn new(backend: &str, hostname: &str, target_port: u16) -> Self { + Self { + backend: backend.to_string(), + hostname: hostname.to_string(), + target_port, + } + } +} + +/// Custom interpret: provisions the TLS passthrough route on the topology. +#[derive(Debug, Clone)] +struct TlsRouterInterpret { + tls_route: TlsRoute, +} + +#[async_trait] +impl Interpret for TlsRouterInterpret { + 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 + ))) + } +} + +impl Score for TlsRouterScore { + fn create_interpret(&self) -> Box> { + let tls_route = TlsRoute { + hostname: self.hostname.clone(), + backend: self.backend.clone(), + target_port: self.target_port, + }; + Box::new(TlsRouterInterpret { tls_route }) + } + + fn name(&self) -> String { + format!( + "TlsRouterScore({}:{ } → {})", + self.backend, self.target_port, self.hostname + ) + } +} diff --git a/harmony/src/modules/okd/mod.rs b/harmony/src/modules/okd/mod.rs index 8bb85ef..da6f9e8 100644 --- a/harmony/src/modules/okd/mod.rs +++ b/harmony/src/modules/okd/mod.rs @@ -12,6 +12,7 @@ pub mod dns; pub mod installation; pub mod ipxe; pub mod load_balancer; +pub mod route; pub mod templates; pub mod upgrade; pub use bootstrap_01_prepare::*; -- 2.39.5 From d06bd4dac67f5f82be0eace0afeeaa46dc45976f Mon Sep 17 00:00:00 2001 From: Jean-Gabriel Gill-Couture Date: Sun, 14 Dec 2025 17:04:40 -0500 Subject: [PATCH 06/20] feat: OKD route CRD and OKD specific route score --- harmony/src/modules/network/tls_router.rs | 73 +++--- harmony/src/modules/okd/crd/mod.rs | 1 + harmony/src/modules/okd/crd/route.rs | 285 ++++++++++++++++++++++ harmony/src/modules/okd/route.rs | 136 +++++++++++ 4 files changed, 457 insertions(+), 38 deletions(-) create mode 100644 harmony/src/modules/okd/crd/route.rs create mode 100644 harmony/src/modules/okd/route.rs diff --git a/harmony/src/modules/network/tls_router.rs b/harmony/src/modules/network/tls_router.rs index c5e520d..5f43df3 100644 --- a/harmony/src/modules/network/tls_router.rs +++ b/harmony/src/modules/network/tls_router.rs @@ -13,13 +13,30 @@ use crate::topology::{K8sclient, Topology}; /// 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::TlsRouterScore; -/// let score = TlsRouterScore::new("postgres-cluster-rw", "pg-rw.example.com", 5432); +/// use harmony::modules::network::TlsPassthroughScore; +/// let score = TlsPassthroughScore { +/// backend: "postgres-cluster-rw".to_string(), +/// hostname: "postgres-rw.example.com".to_string(), +/// target_port: 5432, +/// }; /// ``` +/// +/// # 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 TlsRouterScore { +pub struct TlsPassthroughScore { /// Backend identifier (k8s Service, HAProxy upstream, IP/FQDN, etc.). pub backend: String, /// Public hostname clients connect to (TLS SNI, port 443 implicit). @@ -28,34 +45,32 @@ pub struct TlsRouterScore { pub target_port: u16, } -impl Default for TlsRouterScore { - fn default() -> Self { - Self { - backend: "default-backend".to_string(), - hostname: "tls.default.public".to_string(), - target_port: 5432, - } +impl Score for TlsPassthroughScore { + fn create_interpret(&self) -> Box> { + let tls_route = TlsRoute { + hostname: self.hostname.clone(), + backend: self.backend.clone(), + target_port: self.target_port, + }; + Box::new(TlsPassthroughInterpret { tls_route }) } -} -impl TlsRouterScore { - pub fn new(backend: &str, hostname: &str, target_port: u16) -> Self { - Self { - backend: backend.to_string(), - hostname: hostname.to_string(), - target_port, - } + fn name(&self) -> String { + format!( + "TlsRouterScore({}:{} → {})", + self.backend, self.target_port, self.hostname + ) } } /// Custom interpret: provisions the TLS passthrough route on the topology. #[derive(Debug, Clone)] -struct TlsRouterInterpret { +struct TlsPassthroughInterpret { tls_route: TlsRoute, } #[async_trait] -impl Interpret for TlsRouterInterpret { +impl Interpret for TlsPassthroughInterpret { fn get_name(&self) -> InterpretName { InterpretName::Custom("TlsRouterInterpret") } @@ -79,21 +94,3 @@ impl Interpret for TlsRout ))) } } - -impl Score for TlsRouterScore { - fn create_interpret(&self) -> Box> { - let tls_route = TlsRoute { - hostname: self.hostname.clone(), - backend: self.backend.clone(), - target_port: self.target_port, - }; - Box::new(TlsRouterInterpret { tls_route }) - } - - fn name(&self) -> String { - format!( - "TlsRouterScore({}:{ } → {})", - self.backend, self.target_port, self.hostname - ) - } -} 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..ad146de --- /dev/null +++ b/harmony/src/modules/okd/crd/route.rs @@ -0,0 +1,285 @@ +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 { + 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: IntOrString, +} + +#[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