feat: OKD route CRD and OKD specific route score
All checks were successful
Run Check Script / check (pull_request) Successful in 1m30s

This commit is contained in:
2025-12-14 17:04:40 -05:00
parent 142300802d
commit d06bd4dac6
4 changed files with 457 additions and 38 deletions

View File

@@ -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<T: Topology + K8sclient + TlsRouter + Send + Sync> Score<T> for TlsPassthroughScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
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<T: Topology + K8sclient + TlsRouter + Send + Sync> Interpret<T> for TlsRouterInterpret {
impl<T: Topology + K8sclient + TlsRouter + Send + Sync> Interpret<T> for TlsPassthroughInterpret {
fn get_name(&self) -> InterpretName {
InterpretName::Custom("TlsRouterInterpret")
}
@@ -79,21 +94,3 @@ impl<T: Topology + K8sclient + TlsRouter + Send + Sync> Interpret<T> for TlsRout
)))
}
}
impl<T: Topology + K8sclient + TlsRouter + Send + Sync> Score<T> for TlsRouterScore {
fn create_interpret(&self) -> Box<dyn Interpret<T>> {
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
)
}
}

View File

@@ -1 +1,2 @@
pub mod nmstate;
pub mod route;

View File

@@ -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<String>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct Route {
// #[serde(skip_serializing_if = "Option::is_none")]
// pub api_version: Option<String>,
//
// #[serde(skip_serializing_if = "Option::is_none")]
// pub kind: Option<String>,
pub metadata: ObjectMeta,
pub spec: RouteSpec,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<RouteStatus>,
}
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<Route>,
}
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<Vec<RouteTargetReference>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub http_headers: Option<RouteHTTPHeaders>,
#[serde(skip_serializing_if = "Option::is_none")]
pub path: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub port: Option<RoutePort>,
#[serde(skip_serializing_if = "Option::is_none")]
pub subdomain: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub tls: Option<TLSConfig>,
pub to: RouteTargetReference,
#[serde(skip_serializing_if = "Option::is_none")]
pub wildcard_policy: Option<String>,
}
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<i32>,
}
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub certificate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub destination_ca_certificate: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub external_certificate: Option<LocalObjectReference>,
#[serde(skip_serializing_if = "Option::is_none")]
pub insecure_edge_termination_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub key: Option<String>,
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<Vec<RouteIngress>>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteIngress {
#[serde(skip_serializing_if = "Option::is_none")]
pub host: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub router_canonical_hostname: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub router_name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub wildcard_policy: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub conditions: Option<Vec<RouteIngressCondition>>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteIngressCondition {
#[serde(skip_serializing_if = "Option::is_none")]
pub last_transition_time: Option<Time>,
#[serde(skip_serializing_if = "Option::is_none")]
pub message: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
pub status: String,
#[serde(rename = "type")]
pub condition_type: String,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteHTTPHeader {
pub name: String,
pub action: RouteHTTPHeaderActionUnion,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteHTTPHeaderActionUnion {
#[serde(skip_serializing_if = "Option::is_none")]
pub set: Option<RouteSetHTTPHeader>,
#[serde(rename = "type")]
pub action_type: String,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteSetHTTPHeader {
pub value: String,
}
#[derive(Deserialize, Serialize, Clone, Debug, Default)]
#[serde(rename_all = "camelCase")]
pub struct RouteHTTPHeaderActions {
#[serde(skip_serializing_if = "Option::is_none")]
pub request: Option<Vec<RouteHTTPHeader>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub response: Option<Vec<RouteHTTPHeader>>,
}
#[derive(Deserialize, Serialize, Clone, Debug)]
#[serde(rename_all = "camelCase")]
pub struct RouteHTTPHeaders {
#[serde(skip_serializing_if = "Option::is_none")]
pub actions: Option<RouteHTTPHeaderActions>,
}

View File

@@ -0,0 +1,136 @@
// TODO
// Write OKDRouteScore : This is the real one which will apply the k8s resource and expose all
// relevant option to Harmony's various use cases
//
// Write OKDTlsPassthroughScore : This one will use an OKDRouteScore under the hood and simply fill
// in all settings to make this route a TlsPassthrough
//
// These scores are meant to be used by an OKD based topology to provide Capabilities like
// TlsRouter
//
// The first use case to serve here is the postgresql multisite setup, so exposing only the
// settings relevant to this use case is enough at first, following YAGNI.
//
// These scores are not intended to be used directly by a user, unless the user knows that he will
// always be dealing only with okd/openshift compatible topologies and is ready to manage the
// additional maintenance burden that comes with a lower level functionnality.
use k8s_openapi::apimachinery::pkg::util::intstr::IntOrString;
use kube::api::ObjectMeta;
use serde::Serialize;
use crate::modules::k8s::resource::K8sResourceScore;
use crate::modules::okd::crd::route::{
Route, RoutePort, RouteSpec, RouteTargetReference, TLSConfig,
};
use crate::score::Score;
use crate::topology::{K8sclient, Topology};
#[derive(Clone, Debug, Serialize)]
struct OKDRoutePort {
#[serde(rename = "targetPort")]
target_port: String,
}
#[derive(Clone, Debug, Serialize)]
struct OKDTLSConfig {
#[serde(rename = "termination")]
termination: String,
#[serde(rename = "insecureEdgeTerminationPolicy")]
insecure_edge_termination_policy: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct OKDRouteScore {
pub name: String,
pub namespace: String,
pub spec: RouteSpec,
}
impl OKDRouteScore {
pub fn new(name: &str, namespace: &str, spec: RouteSpec) -> Self {
Self {
name: name.to_string(),
namespace: namespace.to_string(),
spec,
}
}
}
impl<T: Topology + K8sclient> Score<T> for OKDRouteScore {
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
let route = Route {
metadata: ObjectMeta {
name: Some(self.name.clone()),
namespace: Some(self.namespace.clone()),
..ObjectMeta::default()
},
spec: self.spec.clone(),
status: None,
};
K8sResourceScore::single(route, Some(self.namespace.clone())).create_interpret()
}
fn name(&self) -> String {
format!("OKDRouteScore({})", self.name)
}
}
#[derive(Debug, Clone, Serialize)]
pub struct OKDTlsPassthroughScore {
pub name: String,
pub namespace: String,
pub backend: String,
pub hostname: String,
pub target_port: String,
}
impl OKDTlsPassthroughScore {
pub fn new(
name: &str,
namespace: &str,
backend: &str,
hostname: &str,
target_port: &str,
) -> Self {
Self {
name: name.to_string(),
namespace: namespace.to_string(),
backend: backend.to_string(),
hostname: hostname.to_string(),
target_port: target_port.to_string(),
}
}
}
impl<T: Topology + K8sclient> Score<T> for OKDTlsPassthroughScore {
fn create_interpret(&self) -> Box<dyn crate::interpret::Interpret<T>> {
let passthrough_spec = RouteSpec {
host: Some(self.hostname.clone()),
wildcard_policy: Some("None".to_string()),
to: RouteTargetReference {
kind: "Service".to_string(),
name: self.backend.clone(),
weight: Some(100),
},
port: Some(RoutePort {
target_port: IntOrString::String(self.target_port.clone()),
}),
tls: Some(TLSConfig {
termination: "passthrough".to_string(),
insecure_edge_termination_policy: Some("None".to_string()),
..Default::default()
}),
..Default::default()
};
let route_score = OKDRouteScore::new(&self.name, &self.namespace, passthrough_spec);
route_score.create_interpret()
}
fn name(&self) -> String {
format!(
"OKDTlsPassthroughScore({}:{}/{}{})",
self.backend, self.target_port, self.namespace, self.hostname
)
}
}