From 5012d9d6cf3da77bc60101f771be1bba1730df4e Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:26:43 -0400 Subject: [PATCH 01/51] ci: enforce the coverage floor that the Makefile already defines Signed-off-by: Shane Utt --- .github/workflows/tests.yaml | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index b3f5a70..41e99f6 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -66,6 +66,27 @@ jobs: - name: Tests run: make test + # --------------------------------------------------------------------------- + # Coverage floor + # --------------------------------------------------------------------------- + + coverage: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: Install cargo-llvm-cov + uses: taiki-e/install-action@9ba3ac3fd006a70c6e186a683577abc1ccf0ff3a # v2.62.44 + with: + tool: cargo-llvm-cov + + - name: Coverage + run: make coverage-check + # --------------------------------------------------------------------------- # Supply-chain audit # --------------------------------------------------------------------------- From 395a56d0a1509533888aeb67dbbc4781bbd4f3c4 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:27:10 -0400 Subject: [PATCH 02/51] perf(api): paginate cluster-wide list calls Signed-off-by: Shane Utt --- src/controller/gateway.rs | 9 +-- src/controller/gateway_helpers.rs | 27 ++++----- src/controller/httproute.rs | 4 +- src/listing.rs | 96 +++++++++++++++++++++++++++++++ src/main.rs | 1 + 5 files changed, 114 insertions(+), 23 deletions(-) create mode 100644 src/listing.rs diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index bb255ad..9c7f4d2 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -23,6 +23,7 @@ use crate::{ context::{Context, GATEWAY_FINALIZER}, error::{OperatorError, Result}, gateway_api::{conditions, route_status}, + listing, }; // ----------------------------------------------------------------------------- @@ -160,16 +161,12 @@ async fn can_accept_routes(client: &kube::Client, gw: &Gateway, ns: &str, config /// Lists all `HTTPRoute` resources across all namespaces. async fn list_all_routes(client: &kube::Client) -> Result> { - let api = Api::::all(client.clone()); - let list = api.list(&kube::api::ListParams::default()).await?; - Ok(list.items) + listing::list_all(&Api::::all(client.clone())).await } /// Lists all `ReferenceGrant` resources across all namespaces. async fn list_all_grants(client: &kube::Client) -> Result> { - let api = Api::::all(client.clone()); - let list = api.list(&kube::api::ListParams::default()).await?; - Ok(list.items) + listing::list_all(&Api::::all(client.clone())).await } /// Rejects a Gateway whose spec this operator cannot honour. diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index b46fcca..9410f2d 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -29,7 +29,7 @@ use k8s_openapi::{ }; use kube::{ Api, ResourceExt as _, - api::{ListParams, ObjectList, Patch, PatchParams}, + api::{Patch, PatchParams}, }; use serde_json::{Value, json}; use tracing::{debug, info, warn}; @@ -48,6 +48,7 @@ use crate::{ gateway_api::{ attachment, conditions, hostname, listener_conflict, reference_grant, route_status, route_validation, status, }, + listing, resources::{ configmap::build_configmap, deployment::{DeploymentParams, build_deployment}, @@ -1112,13 +1113,9 @@ async fn check_cross_ns_grant( } /// Lists `ReferenceGrant` resources in the given namespace. -async fn list_reference_grants( - client: &kube::Client, - ns: &str, -) -> std::result::Result, kube::Error> { +async fn list_reference_grants(client: &kube::Client, ns: &str) -> Result> { let api = Api::::namespaced(client.clone(), ns); - let list = api.list(&ListParams::default()).await?; - Ok(list.items) + listing::list_all(&api).await } /// Checks whether a Gateway-to-Secret cross-namespace ref is allowed. @@ -1205,16 +1202,16 @@ async fn filter_routes_by_allowed_namespaces<'a>( attached .iter() .filter(|(route, section_names)| { - route_allowed_by_any_listener(route, section_names, listeners, gateway_ns, all_namespaces.as_ref()) + route_allowed_by_any_listener(route, section_names, listeners, gateway_ns, all_namespaces.as_deref()) }) .cloned() .collect() } /// Fetches all namespaces from the cluster, returning `None` on error. -async fn fetch_all_namespaces(client: &kube::Client) -> Option> { - match Api::::all(client.clone()).list(&ListParams::default()).await { - Ok(list) => Some(list), +async fn fetch_all_namespaces(client: &kube::Client) -> Option> { + match listing::list_all(&Api::::all(client.clone())).await { + Ok(namespaces) => Some(namespaces), Err(e) => { warn!(%e, "failed to list namespaces for route filtering"); None @@ -1228,7 +1225,7 @@ fn route_allowed_by_any_listener( section_names: &[Option], listeners: &[GatewayListeners], gateway_ns: &str, - all_namespaces: Option<&ObjectList>, + all_namespaces: Option<&[Namespace]>, ) -> bool { let route_ns = route_status::route_namespace(route); section_names.iter().any(|section| { @@ -1249,7 +1246,7 @@ fn is_namespace_allowed( listener: &GatewayListeners, route_ns: &str, gateway_ns: &str, - all_namespaces: Option<&ObjectList>, + all_namespaces: Option<&[Namespace]>, ) -> bool { let from = listener .allowed_routes @@ -1270,7 +1267,7 @@ fn is_namespace_allowed( fn namespace_matches_selector( listener: &GatewayListeners, route_ns: &str, - all_namespaces: Option<&ObjectList>, + all_namespaces: Option<&[Namespace]>, ) -> bool { let selector = listener .allowed_routes @@ -1285,7 +1282,7 @@ fn namespace_matches_selector( return false; }; - all_ns.items.iter().any(|ns_obj| { + all_ns.iter().any(|ns_obj| { let ns_name = ns_obj.metadata.name.as_deref().unwrap_or(""); ns_name == route_ns && matches_label_selector(ns_obj, selector) }) diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index 07a5588..6ba88ae 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -151,8 +151,8 @@ async fn lookup_parent_gateway(gw_name: &str, gw_ns: &str, route_ns: &str, ctx: /// Lists all [`ReferenceGrant`] resources in the cluster. async fn list_reference_grants(ctx: &Context) -> Vec { let grant_api = Api::::all(ctx.client.clone()); - match grant_api.list(&kube::api::ListParams::default()).await { - Ok(list) => list.items, + match crate::listing::list_all(&grant_api).await { + Ok(grants) => grants, Err(e) => { warn!(%e, "failed to list ReferenceGrants"); Vec::new() diff --git a/src/listing.rs b/src/listing.rs new file mode 100644 index 0000000..6871e6c --- /dev/null +++ b/src/listing.rs @@ -0,0 +1,96 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Paginated collection listing. +//! +//! An unbounded `LIST` asks the API server to marshal every object of a +//! kind into one response. On a large cluster that is a multi-megabyte +//! body the operator must hold entirely in memory, and one the API +//! server may refuse outright. Every cluster-wide read goes through +//! here so the cost stays bounded by page size rather than cluster size. + +use kube::{Api, api::ListParams}; +use serde::de::DeserializeOwned; + +use crate::error::Result; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Objects requested per `LIST` page. +const PAGE_SIZE: u32 = 500; + +// ----------------------------------------------------------------------------- +// Listing +// ----------------------------------------------------------------------------- + +/// Lists every object the API exposes, following continuation tokens. +/// +/// # Errors +/// +/// Returns an error if any page request fails. A partial listing is +/// never returned: a caller acting on half a cluster's routes would +/// generate a config that silently drops the rest. +pub(crate) async fn list_all(api: &Api) -> Result> +where + K: Clone + std::fmt::Debug + DeserializeOwned, +{ + let mut params = ListParams::default().limit(PAGE_SIZE); + let mut items = Vec::new(); + + loop { + let page = api.list(¶ms).await?; + let next = page.metadata.continue_.clone(); + items.extend(page.items); + + match next.filter(|token| !token.is_empty()) { + Some(token) => params = params.continue_token(&token), + None => return Ok(items), + } + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn test_list_params_carry_the_page_limit() { + let params = ListParams::default().limit(PAGE_SIZE); + + assert_eq!( + params.limit, + Some(PAGE_SIZE), + "the limit must reach the API server or the listing stays unbounded" + ); + } + + #[test] + fn test_continue_token_is_threaded_into_params() { + let params = ListParams::default().limit(PAGE_SIZE).continue_token("abc"); + + assert_eq!( + params.continue_token.as_deref(), + Some("abc"), + "the continuation token must be carried into the next page request" + ); + } +} diff --git a/src/main.rs b/src/main.rs index 236e235..480fba8 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod controller; mod endpoints; mod error; mod gateway_api; +mod listing; mod resources; use std::{future::Future, sync::Arc}; From 698acfd3898a831d644c0256241882cc821e9f14 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:27:17 -0400 Subject: [PATCH 03/51] fix(routes): reject method and queryParams matches Praxis cannot honour Signed-off-by: Shane Utt --- src/config/generate.rs | 26 +------ src/config/routing.rs | 111 +++------------------------- src/controller/gateway_class.rs | 11 +-- src/gateway_api/route_validation.rs | 20 +++++ 4 files changed, 38 insertions(+), 130 deletions(-) diff --git a/src/config/generate.rs b/src/config/generate.rs index be6d1ba..faeebb9 100644 --- a/src/config/generate.rs +++ b/src/config/generate.rs @@ -236,14 +236,10 @@ fn route_sort_key(route: &PraxisRoute) -> (u8, std::cmp::Reverse, bool, s /// Counts the non-path constraints a route carries. /// /// Two routes matching the same path are ordered by how specific they -/// are, so a rule constrained by a method or a query parameter is -/// evaluated before an otherwise identical unconstrained one. +/// are, so a header-constrained rule is evaluated before an otherwise +/// identical unconstrained one. fn extra_constraints(route: &PraxisRoute) -> usize { - let headers = route.headers.as_ref().map_or(0, BTreeMap::len); - let query = route.query_params.as_ref().map_or(0, BTreeMap::len); - let method = usize::from(route.method.is_some()); - - headers + query + method + route.headers.as_ref().map_or(0, BTreeMap::len) } /// Builds the `router` filter entry from matched routes. @@ -323,8 +319,6 @@ mod tests { path_prefix: "/api/".to_owned(), host: None, headers: None, - method: None, - query_params: None, cluster: "default~my-svc~8080".to_owned(), listener_names: vec![], }; @@ -378,8 +372,6 @@ mod tests { path_prefix: "/".to_owned(), host: None, headers: None, - method: None, - query_params: None, cluster: "default~svc~80".to_owned(), listener_names: vec![], }; @@ -440,8 +432,6 @@ mod tests { path_prefix: "/".to_owned(), host: None, headers: None, - method: None, - query_params: None, cluster: "default~svc~80".to_owned(), listener_names: vec![], }; @@ -486,8 +476,6 @@ mod tests { path_prefix: "/api/".to_owned(), host: None, headers: None, - method: None, - query_params: None, cluster: "test-cluster".to_owned(), listener_names: vec![], }; @@ -574,8 +562,6 @@ mod tests { path_prefix: "/".to_owned(), host: Some("bar.com".to_owned()), headers: None, - method: None, - query_params: None, cluster: "v1".to_owned(), listener_names: vec![Some("listener-1".to_owned())], }; @@ -585,8 +571,6 @@ mod tests { path_prefix: "/".to_owned(), host: Some("foo.bar.com".to_owned()), headers: None, - method: None, - query_params: None, cluster: "v2".to_owned(), listener_names: vec![Some("listener-2".to_owned())], }; @@ -596,8 +580,6 @@ mod tests { path_prefix: "/".to_owned(), host: Some("*.bar.com".to_owned()), headers: None, - method: None, - query_params: None, cluster: "v3".to_owned(), listener_names: vec![Some("listener-3".to_owned())], }; @@ -683,8 +665,6 @@ mod tests { path_prefix: "/".to_owned(), host: None, headers: None, - method: None, - query_params: None, cluster: "v3".to_owned(), listener_names: vec![Some("l3".to_owned()), Some("l4".to_owned())], }; diff --git a/src/config/routing.rs b/src/config/routing.rs index 92e7bea..57e4290 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -44,10 +44,6 @@ pub(crate) struct PraxisRoute { #[serde(skip)] pub(crate) listener_names: Vec>, - /// HTTP method to match. - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) method: Option, - /// Exact path match. Takes precedence over `path_prefix`. #[serde(skip_serializing_if = "Option::is_none")] pub(crate) path: Option, @@ -55,10 +51,6 @@ pub(crate) struct PraxisRoute { /// Path prefix match. Must end with '/'. #[serde(default, skip_serializing_if = "String::is_empty")] pub(crate) path_prefix: String, - - /// Query parameters to match (exact match only). - #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) query_params: Option>, } // ----------------------------------------------------------------------------- @@ -390,10 +382,8 @@ fn emit_catchall_routes( headers: None, host: None, listener_names: section_names.to_vec(), - method: None, path: None, path_prefix: "/".to_owned(), - query_params: None, }; push_per_hostname(base, hostnames, out); @@ -411,8 +401,6 @@ fn emit_match_routes( out: &mut Vec, ) { let headers = extract_headers(&m.headers); - let method = extract_method(m); - let query_params = extract_query_params(&m.query_params); for (path, path_prefix) in extract_path_match(&m.path) { let base = PraxisRoute { @@ -420,10 +408,8 @@ fn emit_match_routes( headers: headers.clone(), host: None, listener_names: section_names.to_vec(), - method: method.clone(), path, path_prefix, - query_params: query_params.clone(), }; push_per_hostname(base, hostnames, out); @@ -499,31 +485,6 @@ fn extract_headers( if map.is_empty() { None } else { Some(map) } } -/// Extracts the HTTP method a match constrains, if any. -fn extract_method(m: &HttpRouteRulesMatches) -> Option { - m.method.as_ref().map(|method| format!("{method:?}").to_uppercase()) -} - -/// Extracts exact query-parameter matches. -/// -/// Regular-expression matches never reach here: a rule carrying one is -/// rejected by [`validate_route`] before conversion. Per the Gateway API -/// spec the first entry wins for duplicate parameter names. -/// -/// [`validate_route`]: crate::gateway_api::route_validation::validate_route -fn extract_query_params( - params: &Option>, -) -> Option> { - let entries = params.as_ref().filter(|p| !p.is_empty())?; - - let mut map = BTreeMap::new(); - for param in entries { - map.entry(param.name.clone()).or_insert_with(|| param.value.clone()); - } - - if map.is_empty() { None } else { Some(map) } -} - /// Collects exact-match headers into a map, skipping regex matches. fn collect_exact_headers(hs: &[gateway_api::httproutes::HttpRouteRulesMatchesHeaders]) -> BTreeMap { let mut map = BTreeMap::new(); @@ -947,7 +908,7 @@ mod tests { } #[test] - fn test_convert_routes_carries_method_match() { + fn test_convert_routes_rejects_method_match() { let mut rule = rule_with_backend(); rule.matches = Some(vec![HttpRouteRulesMatches { method: Some(gateway_api::httproutes::HttpRouteRulesMatchesMethod::Post), @@ -958,16 +919,15 @@ mod tests { let routes = vec![(&route, vec![None])]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); - assert_eq!(praxis_routes.len(), 1, "a method-only match should produce one route"); - assert_eq!( - praxis_routes[0].method, - Some("POST".to_owned()), - "the method match must reach the data-plane config, not be dropped" + assert!( + praxis_routes.is_empty(), + "the Praxis route schema has no method field, so emitting the route would serve \ + every method instead of only POST" ); } #[test] - fn test_convert_routes_carries_query_param_match() { + fn test_convert_routes_rejects_query_param_match() { let mut rule = rule_with_backend(); rule.matches = Some(vec![HttpRouteRulesMatches { query_params: Some(vec![gateway_api::httproutes::HttpRouteRulesMatchesQueryParams { @@ -982,62 +942,9 @@ mod tests { let routes = vec![(&route, vec![None])]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); - let params = praxis_routes[0] - .query_params - .as_ref() - .expect("query parameter match should reach the config"); - assert_eq!( - params.get("version"), - Some(&"v2".to_owned()), - "the query parameter constraint must be preserved" - ); - } - - #[test] - fn test_convert_routes_first_duplicate_query_param_wins() { - let mut rule = rule_with_backend(); - rule.matches = Some(vec![HttpRouteRulesMatches { - query_params: Some(vec![ - gateway_api::httproutes::HttpRouteRulesMatchesQueryParams { - name: "q".to_owned(), - value: "first".to_owned(), - r#type: None, - }, - gateway_api::httproutes::HttpRouteRulesMatchesQueryParams { - name: "q".to_owned(), - value: "second".to_owned(), - r#type: None, - }, - ]), - ..Default::default() - }]); - let route = route_with_rules(vec![rule]); - - let routes = vec![(&route, vec![None])]; - let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); - - let params = praxis_routes[0].query_params.as_ref().expect("params should be set"); - assert_eq!( - params.get("q"), - Some(&"first".to_owned()), - "the Gateway API specifies first-wins for duplicate query parameter names" - ); - } - - #[test] - fn test_convert_routes_without_method_leaves_it_unset() { - let route = route_with_rules(vec![rule_with_backend()]); - - let routes = vec![(&route, vec![None])]; - let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); - - assert_eq!( - praxis_routes[0].method, None, - "an unconstrained route must not gain a method constraint" - ); - assert_eq!( - praxis_routes[0].query_params, None, - "an unconstrained route must not gain query parameters" + assert!( + praxis_routes.is_empty(), + "the Praxis route schema has no query parameter field, so the constraint cannot be honoured" ); } diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 5220f08..dbba92e 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -33,17 +33,18 @@ use crate::{ /// enforces. /// Deliberately absent: `HTTPRouteHostRewrite` and `HTTPRoutePathRewrite` /// (the `URLRewrite` filter), `HTTPRouteRequestMirror` and -/// `HTTPRouteRequestMultipleMirrors` (the `RequestMirror` filter). Those -/// filters are rejected by [`validate_route`], so advertising them would -/// direct conformance tooling at suites that cannot pass. +/// `HTTPRouteRequestMultipleMirrors` (the `RequestMirror` filter), +/// `HTTPRouteMethodMatching` and `HTTPRouteQueryParamMatching`. Every +/// one is rejected by [`validate_route`] — the filters because they are +/// not implemented, the two match kinds because `praxis_core::config::Route` +/// has no field to carry them. Advertising any of them would direct +/// conformance tooling at suites that cannot pass. /// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &[ "Gateway", "GatewayPort8080", "HTTPRoute", - "HTTPRouteMethodMatching", - "HTTPRouteQueryParamMatching", "HTTPRouteResponseHeaderModification", "ReferenceGrant", ]; diff --git a/src/gateway_api/route_validation.rs b/src/gateway_api/route_validation.rs index e0216fd..faf543f 100644 --- a/src/gateway_api/route_validation.rs +++ b/src/gateway_api/route_validation.rs @@ -9,6 +9,14 @@ //! header match dropped — sends traffic the author never asked for, so //! every unsupported construct is surfaced here and the rule is excluded //! from the generated config. +//! +//! Two categories are refused. Regular-expression matching this +//! operator does not implement, and match fields the Praxis route +//! schema has no field for at all: `praxis_core::config::Route` carries +//! only a path match, host, headers and cluster, so a method or +//! query-parameter constraint has nowhere to go. Emitting one anyway +//! would be dropped during deserialization and the route would quietly +//! serve every method. use std::collections::BTreeMap; @@ -29,6 +37,9 @@ pub(crate) enum RuleRejection { /// A filter type this operator does not implement. UnsupportedFilter(String), + + /// A match field the Praxis route schema has no equivalent for. + UnsupportedMatchField(&'static str), } impl RuleRejection { @@ -39,6 +50,9 @@ impl RuleRejection { format!("RegularExpression {field} matching is not supported") }, Self::UnsupportedFilter(kind) => format!("filter type {kind} is not supported"), + Self::UnsupportedMatchField(field) => { + format!("{field} matching is not supported by the Praxis route schema") + }, } } } @@ -127,6 +141,12 @@ fn reject_match(m: &HttpRouteRulesMatches) -> Option { if has_regex_query_param(m) { return Some(RuleRejection::RegularExpression("query parameter")); } + if m.method.is_some() { + return Some(RuleRejection::UnsupportedMatchField("method")); + } + if m.query_params.as_deref().is_some_and(|q| !q.is_empty()) { + return Some(RuleRejection::UnsupportedMatchField("query parameter")); + } None } From da4919c9c1e0409c3c8f583b7dbe1e2155c2a903 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:27:27 -0400 Subject: [PATCH 04/51] fix(config): scope filter conditions to their rule's own match Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 193 +++++++++++++++++++++++++++++--- 1 file changed, 176 insertions(+), 17 deletions(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index d906d18..6a59898 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -6,7 +6,7 @@ use gateway_api::httproutes::{ HttpRouteRules, HttpRouteRulesFilters, HttpRouteRulesFiltersRequestHeaderModifier, HttpRouteRulesFiltersRequestRedirectScheme, HttpRouteRulesFiltersResponseHeaderModifier, HttpRouteRulesFiltersType, - HttpRouteRulesMatchesPathType, + HttpRouteRulesMatches, HttpRouteRulesMatchesPathType, }; use serde::Serialize; use tracing::warn; @@ -159,33 +159,83 @@ fn dispatch_filter( } } -/// Extracts a Praxis condition from a rule's first path match. +/// Builds the Praxis filter condition scoping a rule's filters. /// -/// Returns a YAML value suitable for the `conditions` field of a Praxis -/// filter, or `None` for catch-all rules without path constraints. +/// Returns a value for the `conditions` field of a Praxis filter, or +/// `None` for a rule with no constraints to scope by. +/// +/// Filters are chain-level in Praxis, not per-route, so a filter is +/// confined to its own rule's traffic only as precisely as +/// `praxis_core::config::ConditionMatch` allows: path, path prefix, +/// methods and headers. That type has no host field, so two routes +/// sharing a listener and a path but differing only in hostname still +/// share their filters. Narrowing that further needs host matching in +/// the Praxis condition schema. fn extract_rule_condition(rule: &HttpRouteRules) -> Option { - let matches = rule.matches.as_ref()?; - let first = matches.first()?; - let path = first.path.as_ref()?; - let value = path.value.as_deref()?; + let first = rule.matches.as_ref()?.first()?; - let field = match &path.r#type { - Some(HttpRouteRulesMatchesPathType::PathPrefix | HttpRouteRulesMatchesPathType::Exact) => "path_prefix", - _ => return None, - }; + let mut predicate = serde_yaml::Mapping::new(); + insert_path_predicate(first, &mut predicate); + insert_header_predicate(first, &mut predicate); + + if predicate.is_empty() { + return None; + } - let when = serde_yaml::Mapping::from_iter([( - serde_yaml::Value::String(field.to_owned()), - serde_yaml::Value::String(value.to_owned()), - )]); let entry = serde_yaml::Mapping::from_iter([( serde_yaml::Value::String("when".to_owned()), - serde_yaml::Value::Mapping(when), + serde_yaml::Value::Mapping(predicate), )]); Some(serde_yaml::Value::Sequence(vec![serde_yaml::Value::Mapping(entry)])) } +/// Adds the path constraint to a filter predicate. +/// +/// An `Exact` match uses the Praxis `path` field and a `PathPrefix` +/// match uses `path_prefix`. Collapsing both onto `path_prefix`, as this +/// did before, made a filter scoped to exactly `/foo` fire on `/foo/bar` +/// as well. +fn insert_path_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml::Mapping) { + let Some(path) = m.path.as_ref() else { return }; + let Some(value) = path.value.as_deref() else { return }; + + let field = match &path.r#type { + Some(HttpRouteRulesMatchesPathType::Exact) => "path", + Some(HttpRouteRulesMatchesPathType::PathPrefix) => "path_prefix", + _ => return, + }; + + predicate.insert( + serde_yaml::Value::String(field.to_owned()), + serde_yaml::Value::String(value.to_owned()), + ); +} + +/// Adds the rule's header constraints to a filter predicate. +/// +/// Narrows the filter to the traffic its own rule matches. Without it a +/// header modifier written for one route also fires for any other route +/// sharing its path on the same listener. +fn insert_header_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml::Mapping) { + let Some(headers) = m.headers.as_deref().filter(|h| !h.is_empty()) else { + return; + }; + + let mut mapping = serde_yaml::Mapping::new(); + for header in headers { + mapping.insert( + serde_yaml::Value::String(header.name.clone()), + serde_yaml::Value::String(header.value.clone()), + ); + } + + predicate.insert( + serde_yaml::Value::String("headers".to_owned()), + serde_yaml::Value::Mapping(mapping), + ); +} + /// Dispatches a request header modifier filter. fn dispatch_request_header(filter: &HttpRouteRulesFilters, config: &mut HeaderFilterConfig) -> bool { filter @@ -661,4 +711,113 @@ mod tests { "second filter should be conditioned on /add" ); } + + // ----------------------------------------------------------------------- + // Condition Scoping + // ----------------------------------------------------------------------- + + #[test] + fn test_exact_path_scopes_on_path_not_prefix() { + let rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/foo"); + let cond = extract_rule_condition(&rule).expect("an exact path should produce a condition"); + let when = &cond[0]["when"]; + + assert_eq!( + when["path"], + serde_yaml::Value::String("/foo".to_owned()), + "an Exact match must scope on the Praxis path field" + ); + assert!( + when.get("path_prefix").is_none(), + "using path_prefix for an Exact match would fire the filter on /foo/bar too" + ); + } + + #[test] + fn test_prefix_path_scopes_on_path_prefix() { + let rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/api"); + let cond = extract_rule_condition(&rule).expect("a prefix path should produce a condition"); + + assert_eq!( + cond[0]["when"]["path_prefix"], + serde_yaml::Value::String("/api".to_owned()), + "a PathPrefix match must scope on path_prefix" + ); + } + + #[test] + fn test_header_constraints_narrow_the_condition() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/api"); + if let Some(matches) = rule.matches.as_mut() + && let Some(first) = matches.first_mut() + { + first.headers = Some(vec![gateway_api::httproutes::HttpRouteRulesMatchesHeaders { + name: "x-tenant".to_owned(), + value: "acme".to_owned(), + r#type: None, + }]); + } + + let cond = extract_rule_condition(&rule).expect("condition expected"); + + assert_eq!( + cond[0]["when"]["headers"]["x-tenant"], + serde_yaml::Value::String("acme".to_owned()), + "a rule's header match must scope its filters, or the filter fires for other routes \ + sharing the same path on this listener" + ); + } + + #[test] + fn test_header_only_rule_still_produces_a_condition() { + let rule = HttpRouteRules { + matches: Some(vec![HttpRouteRulesMatches { + headers: Some(vec![gateway_api::httproutes::HttpRouteRulesMatchesHeaders { + name: "x-canary".to_owned(), + value: "true".to_owned(), + r#type: None, + }]), + ..Default::default() + }]), + ..Default::default() + }; + + let cond = extract_rule_condition(&rule).expect("a header-only rule should still be scoped"); + + assert!( + cond[0]["when"].get("headers").is_some(), + "a rule constrained only by headers must not produce an unscoped filter" + ); + } + + #[test] + fn test_unconstrained_rule_has_no_condition() { + let rule = HttpRouteRules { + matches: Some(vec![HttpRouteRulesMatches::default()]), + ..Default::default() + }; + + assert!( + extract_rule_condition(&rule).is_none(), + "a rule with nothing to match on cannot be scoped and must stay unconditional" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a rule with a single path match of the given type. + fn rule_with_path(kind: HttpRouteRulesMatchesPathType, value: &str) -> HttpRouteRules { + HttpRouteRules { + matches: Some(vec![HttpRouteRulesMatches { + path: Some(gateway_api::httproutes::HttpRouteRulesMatchesPath { + r#type: Some(kind), + value: Some(value.to_owned()), + }), + ..Default::default() + }]), + ..Default::default() + } + } } From 5c87a809b7b3af925e710f564562a97af8f39339 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:27:45 -0400 Subject: [PATCH 05/51] feat(observability): add health, readiness and metrics endpoints Signed-off-by: Shane Utt --- Cargo.toml | 2 +- deploy/deployment.yaml | 16 ++ src/controller/gateway_class.rs | 3 + src/controller/gateway_helpers.rs | 3 + src/gateway_api/route_status.rs | 3 + src/main.rs | 45 ++++- src/observability/metrics.rs | 296 ++++++++++++++++++++++++++++++ src/observability/mod.rs | 7 + src/observability/server.rs | 236 ++++++++++++++++++++++++ 9 files changed, 600 insertions(+), 11 deletions(-) create mode 100644 src/observability/metrics.rs create mode 100644 src/observability/mod.rs create mode 100644 src/observability/server.rs diff --git a/Cargo.toml b/Cargo.toml index b79af79..f855506 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -19,7 +19,7 @@ serde_json = "1.0.151" serde_yaml = "0.9.34" sha2 = "0.10.9" thiserror = "2.0.19" -tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "signal"] } +tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal"] } tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index f7c5adf..2c369a0 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -32,6 +32,22 @@ spec: env: - name: PRAXIS_IMAGE value: "__PRAXIS_IMAGE__" + ports: + - name: observability + containerPort: 8080 + protocol: TCP + livenessProbe: + httpGet: + path: /healthz + port: observability + periodSeconds: 10 + failureThreshold: 3 + readinessProbe: + httpGet: + path: /readyz + port: observability + periodSeconds: 5 + failureThreshold: 3 resources: requests: cpu: 50m diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index dbba92e..a340a0e 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -17,6 +17,7 @@ use crate::{ context::{CONTROLLER_NAME, Context}, error::{OperatorError, Result}, gateway_api::{conditions, status}, + observability::metrics, }; // ----------------------------------------------------------------------------- @@ -94,9 +95,11 @@ async fn accept_gateway_class(gc: &GatewayClass, name: &str, ctx: &Context) -> R status::preserve_condition_times(&mut desired, &observed); if status::is_status_unchanged(&desired, &observed) { + metrics::global().record_status_skipped(); debug!("GatewayClass {name} status unchanged, skipping patch"); return Ok(()); } + metrics::global().record_status_written(); let payload = serde_json::json!({ "apiVersion": "gateway.networking.k8s.io/v1", diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index 9410f2d..7ee0d0c 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -49,6 +49,7 @@ use crate::{ attachment, conditions, hostname, listener_conflict, reference_grant, route_status, route_validation, status, }, listing, + observability::metrics, resources::{ configmap::build_configmap, deployment::{DeploymentParams, build_deployment}, @@ -712,9 +713,11 @@ pub(super) async fn apply_gateway_status(client: &kube::Client, gw: &Gateway, st status::preserve_condition_times(&mut desired, &observed); if status::is_status_unchanged(&desired, &observed) { + metrics::global().record_status_skipped(); debug!("Gateway {ns}/{name} status unchanged, skipping patch"); return Ok(()); } + metrics::global().record_status_written(); let payload = json!({ "apiVersion": "gateway.networking.k8s.io/v1", diff --git a/src/gateway_api/route_status.rs b/src/gateway_api/route_status.rs index 9250ea1..60bdfaa 100644 --- a/src/gateway_api/route_status.rs +++ b/src/gateway_api/route_status.rs @@ -24,6 +24,7 @@ use crate::{ context::CONTROLLER_NAME, error::Result, gateway_api::{conditions, reference_grant, status}, + observability::metrics, }; // ----------------------------------------------------------------------------- @@ -232,9 +233,11 @@ pub(crate) async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRou status::preserve_condition_times(&mut desired, &observed); if status::is_status_unchanged(&desired, &observed) { + metrics::global().record_status_skipped(); debug!("HTTPRoute {ns}/{name} parent status unchanged, skipping patch"); return Ok(()); } + metrics::global().record_status_written(); let payload = json!({ "apiVersion": "gateway.networking.k8s.io/v1", diff --git a/src/main.rs b/src/main.rs index 480fba8..425150e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ mod endpoints; mod error; mod gateway_api; mod listing; +mod observability; mod resources; use std::{future::Future, sync::Arc}; @@ -55,14 +56,20 @@ async fn main() -> error::Result<()> { let client = Client::try_default().await?; info!("connected to cluster, controller={}", context::CONTROLLER_NAME); + let health = Arc::new(observability::server::Health::default()); let ctx = Arc::new(context::Context { client: client.clone() }); + let observability = observability::server::serve(Arc::clone(&health)); let gc = build_gc_controller(&client, Arc::clone(&ctx)); let gw = build_gw_controller(&client, Arc::clone(&ctx)); let rt = build_route_controller(&client, ctx); info!("starting controllers"); - tokio::join!(gc, gw, rt); + health.mark_ready(); + tokio::select! { + () = async { tokio::join!(gc, gw, rt); } => {}, + () = observability => {}, + } Ok(()) } @@ -95,10 +102,16 @@ fn build_gc_controller(client: &Client, ctx: Arc) -> impl Futu controller::gateway_class::error_policy, ctx, ) - .for_each(|res| async { + .for_each(|res| async move { match res { - Ok((obj, _action)) => info!("reconciled GatewayClass {obj}"), - Err(e) => tracing::warn!("GatewayClass reconcile error: {e:?}"), + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::GatewayClass); + info!("reconciled GatewayClass {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::GatewayClass); + tracing::warn!("GatewayClass reconcile error: {e:?}"); + }, } }) } @@ -129,10 +142,16 @@ fn build_gw_controller(client: &Client, ctx: Arc) -> impl Futu ) .shutdown_on_signal() .run(controller::gateway::reconcile, controller::gateway::error_policy, ctx) - .for_each(|res| async { + .for_each(|res| async move { match res { - Ok((obj, _action)) => info!("reconciled Gateway {obj}"), - Err(e) => tracing::warn!("Gateway reconcile error: {e:?}"), + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::Gateway); + info!("reconciled Gateway {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::Gateway); + tracing::warn!("Gateway reconcile error: {e:?}"); + }, } }) } @@ -153,10 +172,16 @@ fn build_route_controller(client: &Client, ctx: Arc) -> impl F controller::httproute::error_policy, ctx, ) - .for_each(|res| async { + .for_each(|res| async move { match res { - Ok((obj, _action)) => info!("reconciled HTTPRoute {obj}"), - Err(e) => tracing::warn!("HTTPRoute reconcile error: {e:?}"), + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::HttpRoute); + info!("reconciled HTTPRoute {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::HttpRoute); + tracing::warn!("HTTPRoute reconcile error: {e:?}"); + }, } }) } diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs new file mode 100644 index 0000000..752f4f2 --- /dev/null +++ b/src/observability/metrics.rs @@ -0,0 +1,296 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Process-wide reconciliation counters. +//! +//! Exposed in Prometheus text exposition format by the observability +//! server. Counters are plain atomics rather than a metrics crate: the +//! set is small and fixed, and the conventions favour avoiding a +//! dependency where a few atomics do. + +use std::{ + fmt, + sync::{ + LazyLock, + atomic::{AtomicU64, Ordering}, + }, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Controllers that report reconciliation outcomes. +/// +/// Indexes into [`Metrics::reconciles`]; kept in step with +/// [`Controller`]. +const CONTROLLER_NAMES: [&str; 3] = ["gatewayclass", "gateway", "httproute"]; + +/// Counters for this process. +/// +/// Reconciliation outcomes are recorded from many call sites that have +/// no reason to carry a handle, so the registry is a process global. The +/// type itself stays free of global state, and tests build their own +/// instances. +static GLOBAL: LazyLock = LazyLock::new(Metrics::default); + +// ----------------------------------------------------------------------------- +// Controller +// ----------------------------------------------------------------------------- + +/// Returns the process-wide counter registry. +pub(crate) fn global() -> &'static Metrics { + &GLOBAL +} + +/// Which controller a measurement belongs to. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Controller { + /// The `GatewayClass` reconciler. + GatewayClass, + + /// The `Gateway` reconciler. + Gateway, + + /// The `HTTPRoute` reconciler. + HttpRoute, +} + +impl Controller { + /// Returns the index of this controller's counters. + const fn index(self) -> usize { + match self { + Self::GatewayClass => 0, + Self::Gateway => 1, + Self::HttpRoute => 2, + } + } +} + +// ----------------------------------------------------------------------------- +// Metrics +// ----------------------------------------------------------------------------- + +/// Counters shared by every controller. +#[derive(Debug, Default)] +pub(crate) struct Metrics { + /// Successful reconciliations, indexed by [`Controller::index`]. + reconciles: [AtomicU64; 3], + + /// Failed reconciliations, indexed by [`Controller::index`]. + errors: [AtomicU64; 3], + + /// Status patches skipped because the live object already matched. + /// + /// A rising success count against a flat patch count is what + /// distinguishes a settled operator from one rewriting identical + /// status forever. + status_patches_skipped: AtomicU64, + + /// Status patches actually written. + status_patches_written: AtomicU64, +} + +impl Metrics { + /// Records a successful reconciliation. + pub(crate) fn record_reconcile(&self, controller: Controller) { + Self::bump(&self.reconciles, controller); + } + + /// Records a failed reconciliation. + pub(crate) fn record_error(&self, controller: Controller) { + Self::bump(&self.errors, controller); + } + + /// Records a status patch that was skipped as redundant. + pub(crate) fn record_status_skipped(&self) { + self.status_patches_skipped.fetch_add(1, Ordering::Relaxed); + } + + /// Records a status patch that was written. + pub(crate) fn record_status_written(&self) { + self.status_patches_written.fetch_add(1, Ordering::Relaxed); + } + + /// Increments one controller's slot in a counter array. + fn bump(counters: &[AtomicU64; 3], controller: Controller) { + if let Some(counter) = counters.get(controller.index()) { + counter.fetch_add(1, Ordering::Relaxed); + } + } +} + +/// Renders every counter in Prometheus text exposition format. +impl fmt::Display for Metrics { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write_counter( + f, + "praxis_operator_reconcile_total", + "Successful reconciliations by controller.", + &self.reconciles, + )?; + write_counter( + f, + "praxis_operator_reconcile_errors_total", + "Failed reconciliations by controller.", + &self.errors, + )?; + + write_scalar( + f, + "praxis_operator_status_patches_skipped_total", + "Status patches skipped as redundant.", + self.status_patches_skipped.load(Ordering::Relaxed), + )?; + write_scalar( + f, + "praxis_operator_status_patches_written_total", + "Status patches written to the API server.", + self.status_patches_written.load(Ordering::Relaxed), + ) + } +} + +// ----------------------------------------------------------------------------- +// Utility Functions +// ----------------------------------------------------------------------------- + +/// Writes one counter family labelled by controller. +fn write_counter(f: &mut fmt::Formatter<'_>, name: &str, help: &str, counters: &[AtomicU64; 3]) -> fmt::Result { + writeln!(f, "# HELP {name} {help}")?; + writeln!(f, "# TYPE {name} counter")?; + + for (index, controller) in CONTROLLER_NAMES.iter().enumerate() { + let value = counters.get(index).map_or(0, |c| c.load(Ordering::Relaxed)); + writeln!(f, "{name}{{controller=\"{controller}\"}} {value}")?; + } + + Ok(()) +} + +/// Writes one unlabelled counter. +fn write_scalar(f: &mut fmt::Formatter<'_>, name: &str, help: &str, value: u64) -> fmt::Result { + writeln!(f, "# HELP {name} {help}")?; + writeln!(f, "# TYPE {name} counter")?; + writeln!(f, "{name} {value}") +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn test_reconcile_counts_are_per_controller() { + let metrics = Metrics::default(); + metrics.record_reconcile(Controller::Gateway); + metrics.record_reconcile(Controller::Gateway); + metrics.record_reconcile(Controller::HttpRoute); + + let encoded = metrics.to_string(); + + assert!( + encoded.contains("praxis_operator_reconcile_total{controller=\"gateway\"} 2"), + "gateway reconciles should be counted separately: {encoded}" + ); + assert!( + encoded.contains("praxis_operator_reconcile_total{controller=\"httproute\"} 1"), + "httproute reconciles should be counted separately: {encoded}" + ); + } + + #[test] + fn test_every_controller_is_reported_even_at_zero() { + let encoded = Metrics::default().to_string(); + + for controller in CONTROLLER_NAMES { + assert!( + encoded.contains(&format!("{{controller=\"{controller}\"}} 0")), + "a controller that has not run yet must still report zero, not be absent: {encoded}" + ); + } + } + + #[test] + fn test_errors_are_counted_apart_from_successes() { + let metrics = Metrics::default(); + metrics.record_error(Controller::GatewayClass); + + let encoded = metrics.to_string(); + + assert!( + encoded.contains("praxis_operator_reconcile_errors_total{controller=\"gatewayclass\"} 1"), + "errors should be counted: {encoded}" + ); + assert!( + encoded.contains("praxis_operator_reconcile_total{controller=\"gatewayclass\"} 0"), + "an error must not also count as a success: {encoded}" + ); + } + + #[test] + fn test_status_patch_counters_track_both_outcomes() { + let metrics = Metrics::default(); + metrics.record_status_skipped(); + metrics.record_status_skipped(); + metrics.record_status_written(); + + let encoded = metrics.to_string(); + + assert!( + encoded.contains("praxis_operator_status_patches_skipped_total 2"), + "skipped patches should be counted: {encoded}" + ); + assert!( + encoded.contains("praxis_operator_status_patches_written_total 1"), + "written patches should be counted: {encoded}" + ); + } + + #[test] + fn test_encoding_declares_help_and_type_for_every_family() { + let encoded = Metrics::default().to_string(); + + assert_eq!( + encoded.matches("# HELP ").count(), + 4, + "every counter family needs a HELP line to be a valid exposition: {encoded}" + ); + assert_eq!( + encoded.matches("# TYPE ").count(), + 4, + "every counter family needs a TYPE line: {encoded}" + ); + } + + #[test] + fn test_controller_indices_are_distinct() { + let indices = [ + Controller::GatewayClass.index(), + Controller::Gateway.index(), + Controller::HttpRoute.index(), + ]; + + assert_eq!( + indices, + [0, 1, 2], + "controllers must map to distinct slots or their counters collide" + ); + } +} diff --git a/src/observability/mod.rs b/src/observability/mod.rs new file mode 100644 index 0000000..7da3075 --- /dev/null +++ b/src/observability/mod.rs @@ -0,0 +1,7 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Health, readiness and metrics for the operator process. + +pub(crate) mod metrics; +pub(crate) mod server; diff --git a/src/observability/server.rs b/src/observability/server.rs new file mode 100644 index 0000000..a585b73 --- /dev/null +++ b/src/observability/server.rs @@ -0,0 +1,236 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Health, readiness and metrics endpoints. +//! +//! Serves three fixed `GET` routes over HTTP/1.1. The responder is +//! hand-written on a tokio listener rather than pulling in a web +//! framework: the surface is three constant paths, and the conventions +//! favour avoiding a dependency that earns nothing. + +use std::sync::{ + Arc, + atomic::{AtomicBool, Ordering}, +}; + +use tokio::{ + io::{AsyncReadExt as _, AsyncWriteExt as _}, + net::{TcpListener, TcpStream}, +}; +use tracing::{debug, warn}; + +use super::metrics; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Address the observability endpoints bind. +const BIND_ADDRESS: &str = "0.0.0.0:8080"; + +/// Largest request head the responder will read. +const MAX_REQUEST_BYTES: usize = 8192; // 8 KiB + +// ----------------------------------------------------------------------------- +// State +// ----------------------------------------------------------------------------- + +/// Liveness and readiness shared with the controllers. +#[derive(Debug, Default)] +pub(crate) struct Health { + /// Whether every controller has completed a first pass. + ready: AtomicBool, +} + +impl Health { + /// Marks the operator ready to serve. + pub(crate) fn mark_ready(&self) { + self.ready.store(true, Ordering::Relaxed); + } + + /// Returns whether the operator is ready to serve. + pub(crate) fn is_ready(&self) -> bool { + self.ready.load(Ordering::Relaxed) + } +} + +// ----------------------------------------------------------------------------- +// Server +// ----------------------------------------------------------------------------- + +/// Serves the observability endpoints until the process exits. +/// +/// Binding failures are logged rather than propagated: losing metrics +/// is not a reason to take a working control plane down. +pub(crate) async fn serve(health: Arc) { + let listener = match TcpListener::bind(BIND_ADDRESS).await { + Ok(listener) => listener, + Err(e) => { + warn!(%e, "observability endpoints unavailable; continuing without them"); + return; + }, + }; + + debug!("observability endpoints listening on {BIND_ADDRESS}"); + accept_loop(listener, health).await; +} + +/// Accepts connections until the process exits. +/// +/// An accept failure is transient — a peer that vanished mid-handshake, +/// a momentary descriptor shortage — so it is logged and the loop +/// continues rather than taking the endpoints down. +async fn accept_loop(listener: TcpListener, health: Arc) -> ! { + loop { + match listener.accept().await { + Ok((stream, _)) => { + let health = Arc::clone(&health); + drop(tokio::spawn(async move { handle(stream, health).await })); + }, + Err(e) => debug!(%e, "observability connection failed"), + } + } +} + +/// Reads one request and writes the matching response. +async fn handle(mut stream: TcpStream, health: Arc) { + let mut buf = vec![0_u8; MAX_REQUEST_BYTES]; + + let read = match stream.read(&mut buf).await { + Ok(0) | Err(_) => return, + Ok(n) => n, + }; + + let path = buf + .get(..read) + .map(String::from_utf8_lossy) + .and_then(|head| request_path(&head).map(str::to_owned)); + + let response = match path.as_deref() { + Some("/healthz") => text_response(200, "ok"), + Some("/readyz") if health.is_ready() => text_response(200, "ready"), + Some("/readyz") => text_response(503, "not ready"), + Some("/metrics") => text_response(200, &metrics::global().to_string()), + _ => text_response(404, "not found"), + }; + + if let Err(e) = stream.write_all(response.as_bytes()).await { + debug!(%e, "observability response failed"); + } +} + +// ----------------------------------------------------------------------------- +// Utility Functions +// ----------------------------------------------------------------------------- + +/// Extracts the request target from an HTTP request head. +/// +/// Returns `None` for anything that is not a `GET`, so the endpoints +/// never act on a write verb. +fn request_path(head: &str) -> Option<&str> { + let mut parts = head.split_whitespace(); + + if parts.next()? != "GET" { + return None; + } + + parts.next() +} + +/// Builds a complete HTTP/1.1 plain-text response. +fn text_response(status: u16, body: &str) -> String { + let reason = match status { + 200 => "OK", + 404 => "Not Found", + 503 => "Service Unavailable", + _ => "Internal Server Error", + }; + let length = body.len(); + + format!( + "HTTP/1.1 {status} {reason}\r\ncontent-type: text/plain; charset=utf-8\r\ncontent-length: \ + {length}\r\nconnection: close\r\n\r\n{body}" + ) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn test_request_path_extracts_the_target() { + assert_eq!( + request_path("GET /metrics HTTP/1.1\r\nhost: x\r\n\r\n"), + Some("/metrics"), + "the request target should be parsed from the request line" + ); + } + + #[test] + fn test_request_path_rejects_non_get_verbs() { + for head in ["POST /metrics HTTP/1.1\r\n", "DELETE /healthz HTTP/1.1\r\n"] { + assert_eq!( + request_path(head), + None, + "the observability endpoints must ignore write verbs: {head}" + ); + } + } + + #[test] + fn test_request_path_rejects_garbage() { + assert_eq!(request_path(""), None, "an empty request has no target"); + assert_eq!(request_path("GET"), None, "a truncated request line has no target"); + } + + #[test] + fn test_response_declares_an_accurate_content_length() { + let response = text_response(200, "ready"); + + assert!( + response.contains("content-length: 5"), + "content-length must match the body or clients hang: {response}" + ); + assert!(response.ends_with("\r\n\r\nready"), "the body follows a blank line"); + } + + #[test] + fn test_response_maps_status_codes_to_reasons() { + assert!(text_response(503, "x").starts_with("HTTP/1.1 503 Service Unavailable")); + assert!(text_response(404, "x").starts_with("HTTP/1.1 404 Not Found")); + } + + #[test] + fn test_health_starts_unready() { + assert!( + !Health::default().is_ready(), + "readiness must be earned; reporting ready before the first pass would route \ + traffic at an operator that has reconciled nothing" + ); + } + + #[test] + fn test_health_becomes_ready_when_marked() { + let health = Health::default(); + health.mark_ready(); + + assert!(health.is_ready(), "marking ready should take effect"); + } +} From ea4603d534de468970ac2a11593183da7b2be460 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:28:06 -0400 Subject: [PATCH 06/51] feat(operator): add leader election for multi-replica safety Signed-off-by: Shane Utt --- deploy/deployment.yaml | 10 +- src/error.rs | 4 + src/leader.rs | 367 +++++++++++++++++++++++++++++++++++++++++ src/main.rs | 37 ++++- 4 files changed, 408 insertions(+), 10 deletions(-) create mode 100644 src/leader.rs diff --git a/deploy/deployment.yaml b/deploy/deployment.yaml index 2c369a0..9d77635 100644 --- a/deploy/deployment.yaml +++ b/deploy/deployment.yaml @@ -7,7 +7,7 @@ metadata: app.kubernetes.io/name: praxis-operator app.kubernetes.io/managed-by: praxis-operator spec: - replicas: 1 + replicas: 2 selector: matchLabels: app.kubernetes.io/name: praxis-operator @@ -32,6 +32,14 @@ spec: env: - name: PRAXIS_IMAGE value: "__PRAXIS_IMAGE__" + - name: POD_NAME + valueFrom: + fieldRef: + fieldPath: metadata.name + - name: POD_NAMESPACE + valueFrom: + fieldRef: + fieldPath: metadata.namespace ports: - name: observability containerPort: 8080 diff --git a/src/error.rs b/src/error.rs index 436c1a0..92dc313 100644 --- a/src/error.rs +++ b/src/error.rs @@ -26,6 +26,10 @@ pub(crate) enum OperatorError { #[error("gatewayclass not found: {0}")] GatewayClassNotFound(String), + /// Leadership was taken by another replica. + #[error("leadership lost to another replica")] + LeadershipLost, + /// Serialization failed. #[error("serialization: {0}")] Serialization(#[from] serde_json::Error), diff --git a/src/leader.rs b/src/leader.rs new file mode 100644 index 0000000..2d047fd --- /dev/null +++ b/src/leader.rs @@ -0,0 +1,367 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Leader election over a coordination `Lease`. +//! +//! Two replicas reconciling the same Gateway would race on every status +//! write and fight over the generated config, so exactly one instance +//! reconciles at a time. `kube-runtime` 3.1 ships no lease helper, so +//! the acquire-and-renew cycle is implemented against the +//! `coordination.k8s.io` API directly. + +use std::time::Duration; + +use k8s_openapi::{api::coordination::v1::Lease, apimachinery::pkg::apis::meta::v1::MicroTime, jiff::Timestamp}; +use kube::{ + Api, Client, + api::{Patch, PatchParams}, +}; +use tracing::{debug, info, warn}; + +use crate::error::{OperatorError, Result}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Name of the `Lease` object arbitrating leadership. +const LEASE_NAME: &str = "praxis-operator"; + +/// Namespace the `Lease` lives in when the downward API is unset. +const DEFAULT_LEASE_NAMESPACE: &str = "praxis-system"; + +/// Seconds a lease stays valid without renewal. +/// +/// A holder that dies is replaced after at most this long, so it trades +/// failover latency against tolerance for a slow API server. +const LEASE_DURATION_SECONDS: i32 = 15; + +/// How often the holder renews, comfortably inside the duration. +const RENEW_INTERVAL: Duration = Duration::from_secs(5); + +/// How often a non-holder re-checks whether the lease has expired. +const RETRY_INTERVAL: Duration = Duration::from_secs(3); + +/// Field manager for lease writes. +const FIELD_MANAGER: &str = "praxis-operator"; + +// ----------------------------------------------------------------------------- +// Identity +// ----------------------------------------------------------------------------- + +/// Returns this replica's unique holder identity. +/// +/// Prefers the pod name supplied by the downward API so the holder is +/// identifiable with `kubectl get lease`; falls back to the hostname, +/// then to the process id. +pub(crate) fn identity() -> String { + std::env::var("POD_NAME") + .ok() + .or_else(|| std::env::var("HOSTNAME").ok()) + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| format!("pid-{}", std::process::id())) +} + +/// Returns the namespace holding the lease. +fn lease_namespace() -> String { + std::env::var("POD_NAMESPACE") + .ok() + .filter(|ns| !ns.is_empty()) + .unwrap_or_else(|| DEFAULT_LEASE_NAMESPACE.to_owned()) +} + +// ----------------------------------------------------------------------------- +// Election +// ----------------------------------------------------------------------------- + +/// Blocks until this replica holds the lease. +/// +/// # Errors +/// +/// Returns an error only when the API rejects a lease write for a reason +/// other than another replica holding it; contention is retried. +pub(crate) async fn acquire(client: &Client, identity: &str) -> Result<()> { + let api: Api = Api::namespaced(client.clone(), &lease_namespace()); + + loop { + if try_acquire(&api, identity).await? { + info!("acquired leadership as {identity}"); + return Ok(()); + } + + debug!("leadership held elsewhere, retrying in {RETRY_INTERVAL:?}"); + tokio::time::sleep(RETRY_INTERVAL).await; + } +} + +/// Renews the lease until leadership is lost. +/// +/// # Errors +/// +/// Returns [`OperatorError::LeadershipLost`] when another replica takes +/// the lease. The caller is expected to stop reconciling and exit so the +/// Deployment restarts it as a follower, which is simpler to reason +/// about than resuming mid-flight. +pub(crate) async fn renew_until_lost(client: &Client, identity: &str) -> Result<()> { + let api: Api = Api::namespaced(client.clone(), &lease_namespace()); + + loop { + tokio::time::sleep(RENEW_INTERVAL).await; + + match try_acquire(&api, identity).await { + Ok(true) => debug!("renewed leadership"), + Ok(false) => { + warn!("lost leadership to another replica"); + return Err(OperatorError::LeadershipLost); + }, + Err(e) => warn!(%e, "lease renewal failed, will retry"), + } + } +} + +/// Takes or renews the lease, returning whether this replica holds it. +async fn try_acquire(api: &Api, identity: &str) -> Result { + let now = Timestamp::now(); + let observed = match api.get(LEASE_NAME).await { + Ok(lease) => Some(lease), + Err(kube::Error::Api(resp)) if resp.code == 404 => None, + Err(e) => return Err(e.into()), + }; + + if let Some(lease) = observed.as_ref() + && !is_claimable(lease, identity, now) + { + return Ok(false); + } + + let transitions = next_transitions(observed.as_ref(), identity); + let patch = lease_patch(identity, now, transitions); + + api.patch( + LEASE_NAME, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(&patch), + ) + .await?; + Ok(true) +} + +// ----------------------------------------------------------------------------- +// Utility Functions +// ----------------------------------------------------------------------------- + +/// Returns whether `identity` may take the lease. +/// +/// The current holder may always renew. Anyone else must wait for the +/// recorded renewal to age past the lease duration, which is what stops +/// two replicas from both believing they lead. +fn is_claimable(lease: &Lease, identity: &str, now: Timestamp) -> bool { + let Some(spec) = lease.spec.as_ref() else { + return true; + }; + + match spec.holder_identity.as_deref() { + None => true, + Some(holder) if holder == identity => true, + Some(_) => is_expired(spec.renew_time.as_ref(), spec.lease_duration_seconds, now), + } +} + +/// Returns whether a recorded renewal has aged out. +/// +/// A lease with no renewal timestamp is treated as expired: it cannot be +/// shown to be live, and refusing to ever claim it would deadlock every +/// replica. +fn is_expired(renewed: Option<&MicroTime>, duration_seconds: Option, now: Timestamp) -> bool { + let Some(renewed) = renewed else { + return true; + }; + let duration = i64::from(duration_seconds.unwrap_or(LEASE_DURATION_SECONDS)); + + now.as_second().saturating_sub(renewed.0.as_second()) > duration +} + +/// Returns the transition count the next holder should record. +/// +/// The count increments only when leadership actually changes hands, so +/// it stays a useful signal of instability rather than a renewal tally. +fn next_transitions(observed: Option<&Lease>, identity: &str) -> i32 { + let Some(spec) = observed.and_then(|lease| lease.spec.as_ref()) else { + return 0; + }; + let current = spec.lease_transitions.unwrap_or(0); + + if spec.holder_identity.as_deref() == Some(identity) { + current + } else { + current.saturating_add(1) + } +} + +/// Builds the server-side apply patch claiming the lease. +fn lease_patch(identity: &str, now: Timestamp, transitions: i32) -> serde_json::Value { + serde_json::json!({ + "apiVersion": "coordination.k8s.io/v1", + "kind": "Lease", + "metadata": { "name": LEASE_NAME }, + "spec": { + "acquireTime": MicroTime(now), + "holderIdentity": identity, + "leaseDurationSeconds": LEASE_DURATION_SECONDS, + "leaseTransitions": transitions, + "renewTime": MicroTime(now), + }, + }) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use k8s_openapi::api::coordination::v1::LeaseSpec; + + use super::*; + + #[test] + fn test_a_lease_with_no_spec_is_claimable() { + let lease = Lease::default(); + + assert!( + is_claimable(&lease, "me", Timestamp::now()), + "a freshly created lease carrying no spec belongs to nobody" + ); + } + + #[test] + fn test_the_current_holder_may_always_renew() { + let lease = held_by("me", 0); + + assert!( + is_claimable(&lease, "me", Timestamp::now()), + "the holder must be able to renew even while its own lease is live" + ); + } + + #[test] + fn test_a_live_lease_blocks_another_replica() { + let lease = held_by("other", 0); + + assert!( + !is_claimable(&lease, "me", Timestamp::now()), + "a live lease held elsewhere must block, or two replicas reconcile at once" + ); + } + + #[test] + fn test_an_expired_lease_may_be_taken_over() { + let lease = held_by("other", LEASE_DURATION_SECONDS + 5); + + assert!( + is_claimable(&lease, "me", Timestamp::now()), + "a holder that stopped renewing must be replaceable or failover never happens" + ); + } + + #[test] + fn test_a_lease_at_exactly_its_duration_is_still_live() { + let lease = held_by("other", LEASE_DURATION_SECONDS); + + assert!( + !is_claimable(&lease, "me", Timestamp::now()), + "expiry is strictly past the duration, so the boundary still belongs to the holder" + ); + } + + #[test] + fn test_a_lease_without_a_renew_time_is_expired() { + let lease = Lease { + spec: Some(LeaseSpec { + holder_identity: Some("other".to_owned()), + renew_time: None, + ..Default::default() + }), + ..Default::default() + }; + + assert!( + is_claimable(&lease, "me", Timestamp::now()), + "a lease that cannot be shown live must be claimable, or every replica deadlocks" + ); + } + + #[test] + fn test_transitions_increment_only_on_handover() { + let held = held_by("other", 0); + + assert_eq!( + next_transitions(Some(&held), "me"), + 1, + "taking over from another holder is a transition" + ); + assert_eq!( + next_transitions(Some(&held_by("me", 0)), "me"), + 0, + "renewing your own lease is not a transition" + ); + assert_eq!( + next_transitions(None, "me"), + 0, + "a first claim starts the count at zero" + ); + } + + #[test] + fn test_patch_records_holder_and_duration() { + let patch = lease_patch("me", Timestamp::now(), 3); + + assert_eq!(patch["spec"]["holderIdentity"], "me", "the claimant must be recorded"); + assert_eq!( + patch["spec"]["leaseDurationSeconds"], LEASE_DURATION_SECONDS, + "followers rely on the duration to decide when the lease expired" + ); + assert_eq!(patch["spec"]["leaseTransitions"], 3, "the transition count is carried"); + } + + #[test] + fn test_identity_is_never_empty() { + assert!( + !identity().is_empty(), + "an empty holder identity would make every replica look like the same holder" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a lease held by `holder`, renewed `age` seconds ago. + fn held_by(holder: &str, age: i32) -> Lease { + let renewed = Timestamp::from_second(Timestamp::now().as_second() - i64::from(age)) + .expect("timestamp should be representable"); + + Lease { + spec: Some(LeaseSpec { + holder_identity: Some(holder.to_owned()), + lease_duration_seconds: Some(LEASE_DURATION_SECONDS), + renew_time: Some(MicroTime(renewed)), + ..Default::default() + }), + ..Default::default() + } + } +} diff --git a/src/main.rs b/src/main.rs index 425150e..ab9b483 100644 --- a/src/main.rs +++ b/src/main.rs @@ -11,6 +11,7 @@ mod controller; mod endpoints; mod error; mod gateway_api; +mod leader; mod listing; mod observability; mod resources; @@ -57,21 +58,39 @@ async fn main() -> error::Result<()> { info!("connected to cluster, controller={}", context::CONTROLLER_NAME); let health = Arc::new(observability::server::Health::default()); - let ctx = Arc::new(context::Context { client: client.clone() }); + let observability = tokio::spawn(observability::server::serve(Arc::clone(&health))); + + let identity = leader::identity(); + info!("standing for election as {identity}"); + leader::acquire(&client, &identity).await?; + + let result = Box::pin(run_controllers(&client, &identity, &health)).await; - let observability = observability::server::serve(Arc::clone(&health)); - let gc = build_gc_controller(&client, Arc::clone(&ctx)); - let gw = build_gw_controller(&client, Arc::clone(&ctx)); - let rt = build_route_controller(&client, ctx); + observability.abort(); + result +} + +/// Runs every controller until one exits or leadership is lost. +/// +/// # Errors +/// +/// Returns [`OperatorError::LeadershipLost`] when another replica takes +/// the lease, so the process exits non-zero and restarts as a follower. +/// +/// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost +async fn run_controllers(client: &Client, identity: &str, health: &observability::server::Health) -> error::Result<()> { + let ctx = Arc::new(context::Context { client: client.clone() }); + let gc = build_gc_controller(client, Arc::clone(&ctx)); + let gw = build_gw_controller(client, Arc::clone(&ctx)); + let rt = build_route_controller(client, ctx); info!("starting controllers"); health.mark_ready(); + tokio::select! { - () = async { tokio::join!(gc, gw, rt); } => {}, - () = observability => {}, + () = async { tokio::join!(gc, gw, rt); } => Ok(()), + outcome = leader::renew_until_lost(client, identity) => outcome, } - - Ok(()) } // ----------------------------------------------------------------------------- From db75dee98c8af525a225700c24492205883f8cb2 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:28:23 -0400 Subject: [PATCH 07/51] test(config): guard config generation against non-determinism Signed-off-by: Shane Utt --- src/config/routing.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/config/routing.rs b/src/config/routing.rs index 57e4290..9006bf8 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -1336,6 +1336,44 @@ mod tests { ); } + #[test] + fn test_config_generation_is_deterministic_across_runs() { + let mut yamls = std::collections::BTreeSet::new(); + + for _ in 0..50 { + let mut listener_hostnames = HashMap::new(); + listener_hostnames.insert("l1".to_owned(), Some("*.example.com".to_owned())); + listener_hostnames.insert("l2".to_owned(), Some("a.example.com".to_owned())); + listener_hostnames.insert("l3".to_owned(), Some("*.other.com".to_owned())); + listener_hostnames.insert("l4".to_owned(), Some("b.example.com".to_owned())); + + let route = HTTPRoute { + metadata: ObjectMeta { + name: Some("r".to_owned()), + namespace: Some("default".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + hostnames: Some(vec!["a.example.com".to_owned(), "b.example.com".to_owned()]), + rules: Some(vec![rule_with_backend()]), + ..Default::default() + }, + status: None, + }; + + let routes = vec![(&route, vec![None])]; + let (praxis_routes, _) = convert_routes(&routes, &listener_hostnames, &[]); + yamls.insert(serde_yaml::to_string(&praxis_routes).expect("serializes")); + } + + assert_eq!( + yamls.len(), + 1, + "config generation must be deterministic; unstable output churns the config hash and \ + rolls the data plane forever. distinct outputs: {yamls:?}" + ); + } + // ----------------------------------------------------------------------------- // Test Utilities // ----------------------------------------------------------------------------- From 5c0cef3106dfab7cb52ff77f53f5f296e28ada40 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:28:48 -0400 Subject: [PATCH 08/51] ci(conformance): raise the suite timeout above its observed runtime Signed-off-by: Shane Utt --- hack/run-conformance.sh | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/hack/run-conformance.sh b/hack/run-conformance.sh index a66627f..7680d87 100755 --- a/hack/run-conformance.sh +++ b/hack/run-conformance.sh @@ -43,8 +43,11 @@ fi echo "==> Running conformance tests (context: kind-${CLUSTER_NAME})..." cd "${GWAPI_DIR}" +# The suite needs headroom: it already ran ~17 minutes against the old 20m +# ceiling, so any CI slowdown aborted it with no report written. Raised to +# 45m so a genuine hang is still caught while normal variance is not. go test ./conformance -run TestConformance \ - -timeout 20m -v \ + -timeout 45m -v \ -args \ --gateway-class="${GATEWAY_CLASS}" \ --conformance-profiles=GATEWAY-HTTP \ From bdf8bb6901119c766dc3151e8c123bea1f59dc47 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:29:01 -0400 Subject: [PATCH 09/51] feat(operator): emit Kubernetes events for rejected Gateways Signed-off-by: Shane Utt --- src/context.rs | 16 +++++++++++++++- src/controller/gateway.rs | 39 +++++++++++++++++++++++++++++++-------- src/main.rs | 5 ++++- 3 files changed, 50 insertions(+), 10 deletions(-) diff --git a/src/context.rs b/src/context.rs index 1a7db59..3079183 100644 --- a/src/context.rs +++ b/src/context.rs @@ -3,7 +3,10 @@ //! Shared controller context. -use kube::Client; +use kube::{ + Client, + runtime::events::{Recorder, Reporter}, +}; // ----------------------------------------------------------------------------- // Constants @@ -40,6 +43,17 @@ pub(crate) fn praxis_image() -> String { pub(crate) struct Context { /// Kubernetes API client. pub(crate) client: Client, + + /// Publishes Kubernetes events for user-visible decisions. + pub(crate) recorder: Recorder, +} + +/// Builds the event reporter identifying this operator. +pub(crate) fn reporter() -> Reporter { + Reporter { + controller: "praxis-operator".to_owned(), + instance: std::env::var("POD_NAME").ok(), + } } impl std::fmt::Debug for Context { diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index 9c7f4d2..bcba3fc 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -11,7 +11,8 @@ use kube::{ api::{Patch, PatchParams}, runtime::{ controller::Action, - finalizer::{self, Event}, + events::{Event, EventType}, + finalizer::{self, Event as FinalizerEvent}, reflector::ObjectRef, }, }; @@ -51,8 +52,8 @@ pub(crate) async fn reconcile(gw: Arc, ctx: Arc) -> Result Box::pin(apply(gw, &ctx)).await, - Event::Cleanup(gw) => { + FinalizerEvent::Apply(gw) => Box::pin(apply(gw, &ctx)).await, + FinalizerEvent::Cleanup(gw) => { cleanup(&gw, &ctx.client).await; Ok(Action::await_change()) }, @@ -90,9 +91,7 @@ pub(crate) fn error_policy(_gw: Arc, error: &OperatorError, _ctx: Arc, ctx: &Context) -> Result { - if !gateway_helpers::validate_gateway_class(&ctx.client, &gw).await? - || reject_unsupported_spec(&ctx.client, &gw).await? - { + if !gateway_helpers::validate_gateway_class(&ctx.client, &gw).await? || reject_unsupported_spec(ctx, &gw).await? { return Ok(Action::await_change()); } @@ -173,13 +172,14 @@ async fn list_all_grants(client: &kube::Client) -> Result> { /// /// Returns `true` when the Gateway was rejected and the caller should /// stop reconciling it. -async fn reject_unsupported_spec(client: &kube::Client, gw: &Gateway) -> Result { +async fn reject_unsupported_spec(ctx: &Context, gw: &Gateway) -> Result { let Some((reason, message)) = unsupported_spec_reason(gw) else { return Ok(false); }; let generation = gw.metadata.generation.unwrap_or(1); - reject_gateway(client, gw, generation, reason, message).await?; + reject_gateway(&ctx.client, gw, generation, reason, message).await?; + Box::pin(publish_rejection(ctx, gw, reason, message)).await; let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); @@ -187,6 +187,29 @@ async fn reject_unsupported_spec(client: &kube::Client, gw: &Gateway) -> Result< Ok(true) } +/// Emits a warning event describing why a Gateway was rejected. +/// +/// A rejected Gateway is otherwise inert, and its condition is easy to +/// miss; an event puts the reason in `kubectl describe`. The recorder +/// deduplicates within a TTL window, so a Gateway that stays rejected +/// does not accumulate an event per reconcile. +/// +/// A failure to publish is logged rather than propagated: losing an +/// event must not turn a clean rejection into a reconcile error. +async fn publish_rejection(ctx: &Context, gw: &Gateway, reason: &str, message: &str) { + let event = Event { + action: "Reject".to_owned(), + note: Some(message.to_owned()), + reason: reason.to_owned(), + secondary: None, + type_: EventType::Warning, + }; + + if let Err(e) = ctx.recorder.publish(&event, &gw.object_ref(&())).await { + debug!(%e, "could not publish rejection event"); + } +} + /// Returns the `(reason, message)` for a Gateway spec this operator /// cannot honour. /// diff --git a/src/main.rs b/src/main.rs index ab9b483..41ae03c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -79,7 +79,10 @@ async fn main() -> error::Result<()> { /// /// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost async fn run_controllers(client: &Client, identity: &str, health: &observability::server::Health) -> error::Result<()> { - let ctx = Arc::new(context::Context { client: client.clone() }); + let ctx = Arc::new(context::Context { + client: client.clone(), + recorder: kube::runtime::events::Recorder::new(client.clone(), context::reporter()), + }); let gc = build_gc_controller(client, Arc::clone(&ctx)); let gw = build_gw_controller(client, Arc::clone(&ctx)); let rt = build_route_controller(client, ctx); From 14b59ba14a59deb950674649fe736448933f18bc Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:29:14 -0400 Subject: [PATCH 10/51] feat(dataplane): make replicas configurable and survive disruption Signed-off-by: Shane Utt --- deploy/rbac.yaml | 4 + src/controller/gateway_helpers.rs | 4 + src/main.rs | 43 ++++++--- src/resources/deployment.rs | 104 +++++++++++++++++++- src/resources/disruption.rs | 155 ++++++++++++++++++++++++++++++ src/resources/mod.rs | 1 + 6 files changed, 294 insertions(+), 17 deletions(-) create mode 100644 src/resources/disruption.rs diff --git a/deploy/rbac.yaml b/deploy/rbac.yaml index 4f614c6..32220a8 100644 --- a/deploy/rbac.yaml +++ b/deploy/rbac.yaml @@ -55,6 +55,10 @@ rules: - apiGroups: ["apps"] resources: ["deployments"] verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] + # PodDisruptionBudgets keep a data-plane pod serving through a drain. + - apiGroups: ["policy"] + resources: ["poddisruptionbudgets"] + verbs: ["get", "list", "watch", "create", "update", "patch", "delete"] - apiGroups: [""] resources: ["events"] verbs: ["create", "patch"] diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index 7ee0d0c..dfad25b 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -53,6 +53,7 @@ use crate::{ resources::{ configmap::build_configmap, deployment::{DeploymentParams, build_deployment}, + disruption::build_pod_disruption_budget, labels::child_name, service::build_service, }, @@ -512,6 +513,9 @@ pub(super) async fn apply_child_resources( let svc = build_service(&child, &ns, gw, ports)?; super::gateway::apply_resource(client, &ns, &svc).await?; + let budget = build_pod_disruption_budget(&child, &ns, gw)?; + super::gateway::apply_resource(client, &ns, &budget).await?; + Ok(config_hash) } diff --git a/src/main.rs b/src/main.rs index 41ae03c..0305923 100644 --- a/src/main.rs +++ b/src/main.rs @@ -25,6 +25,7 @@ use futures::StreamExt as _; use k8s_openapi::api::{ apps::v1::Deployment, core::v1::{ConfigMap, Service}, + policy::v1::PodDisruptionBudget, }; use kube::{ Api, Client, @@ -148,20 +149,7 @@ fn build_gw_controller(client: &Client, ctx: Arc) -> impl Futu let controller = Controller::new(Api::::all(client.clone()), watcher::Config::default()); let gateways = controller.store(); - controller - .owns(Api::::all(client.clone()), managed_children()) - .owns(Api::::all(client.clone()), managed_children()) - .owns(Api::::all(client.clone()), managed_children()) - .watches( - Api::::all(client.clone()), - watcher::Config::default(), - |route| controller::gateway::map_route_to_gateway(&route), - ) - .watches( - Api::::all(client.clone()), - watcher::Config::default(), - move |grant| controller::gateway::map_grant_to_gateways(&grant, &gateways.state()), - ) + with_gateway_watches(controller, client, gateways) .shutdown_on_signal() .run(controller::gateway::reconcile, controller::gateway::error_policy, ctx) .for_each(|res| async move { @@ -178,6 +166,33 @@ fn build_gw_controller(client: &Client, ctx: Arc) -> impl Futu }) } +/// Registers the owned children and cross-references a Gateway depends +/// on. +/// +/// Child watches carry the managed-by selector so the operator never +/// deserializes unrelated cluster objects. +fn with_gateway_watches( + controller: Controller, + client: &Client, + gateways: kube::runtime::reflector::Store, +) -> Controller { + controller + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .watches( + Api::::all(client.clone()), + watcher::Config::default(), + |route| controller::gateway::map_route_to_gateway(&route), + ) + .watches( + Api::::all(client.clone()), + watcher::Config::default(), + move |grant| controller::gateway::map_grant_to_gateways(&grant, &gateways.state()), + ) +} + /// Watcher config scoped to the child resources this operator manages. fn managed_children() -> watcher::Config { watcher::Config::default().labels(MANAGED_BY_SELECTOR) diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index 0b597d5..aed5b61 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -12,7 +12,7 @@ use k8s_openapi::{ core::v1::{ Capabilities, ConfigMapVolumeSource, Container, ContainerPort, EmptyDirVolumeSource, HTTPGetAction, PodSpec, PodTemplateSpec, Probe, ResourceRequirements, SeccompProfile, SecretVolumeSource, SecurityContext, - Volume, VolumeMount, + TopologySpreadConstraint, Volume, VolumeMount, }, }, apimachinery::pkg::{ @@ -33,6 +33,16 @@ use crate::context::{ADMIN_PORT, praxis_image}; /// UID the Praxis proxy container runs as (nobody/nfsnobody). const PROXY_UID: i64 = 100; +/// Annotation overriding the data-plane replica count. +const REPLICAS_ANNOTATION: &str = "praxis.sh/replicas"; + +/// Replicas run when the Gateway does not ask for a specific count. +/// +/// Two rather than one so a node drain or a rolling config change does +/// not take the data plane down; a single-replica Gateway is a single +/// point of failure for every route attached to it. +const DEFAULT_REPLICAS: i32 = 2; + // ----------------------------------------------------------------------------- // Deployment Builder // ----------------------------------------------------------------------------- @@ -295,6 +305,7 @@ fn build_pod_template( automount_service_account_token: Some(false), containers: vec![container], termination_grace_period_seconds: Some(15), + topology_spread_constraints: Some(spread_constraints(labels)), volumes: Some(volumes), ..Default::default() }; @@ -309,6 +320,41 @@ fn build_pod_template( } } +/// Returns the replica count for a Gateway's data plane. +/// +/// Read from the `praxis.sh/replicas` annotation so an operator can size +/// a Gateway without a CRD of its own; the Gateway API's own extension +/// point, `spec.infrastructure.parametersRef`, is rejected by this +/// implementation. A malformed or non-positive value falls back to the +/// default rather than producing a Deployment that scales to zero. +fn desired_replicas(gateway: &Gateway) -> i32 { + gateway + .metadata + .annotations + .as_ref() + .and_then(|annotations| annotations.get(REPLICAS_ANNOTATION)) + .and_then(|value| value.parse::().ok()) + .filter(|replicas| *replicas > 0) + .unwrap_or(DEFAULT_REPLICAS) +} + +/// Spreads data-plane pods across nodes. +/// +/// Scheduling stays best-effort: on a single-node cluster a `DoNotSchedule` +/// constraint would leave every replica after the first pending forever. +fn spread_constraints(labels: &BTreeMap) -> Vec { + vec![TopologySpreadConstraint { + label_selector: Some(LabelSelector { + match_labels: Some(labels.clone()), + ..Default::default() + }), + max_skew: 1, + topology_key: "kubernetes.io/hostname".to_owned(), + when_unsatisfiable: "ScheduleAnyway".to_owned(), + ..Default::default() + }] +} + /// Assembles the final [`Deployment`] object with metadata and spec. /// /// Sets owner references, labels, rolling update strategy, and the pod @@ -329,7 +375,7 @@ fn build_deployment_object( ..Default::default() }, spec: Some(DeploymentSpec { - replicas: Some(1), + replicas: Some(desired_replicas(gateway)), selector: LabelSelector { match_labels: Some(labels), ..Default::default() @@ -429,6 +475,54 @@ mod tests { assert_eq!(owner_refs[0].kind, "Gateway", "owner kind should be Gateway"); } + #[test] + fn test_replicas_honour_the_annotation() { + let mut gateway = test_gateway(); + gateway.metadata.annotations = Some(BTreeMap::from([(REPLICAS_ANNOTATION.to_owned(), "5".to_owned())])); + + assert_eq!( + desired_replicas(&gateway), + 5, + "an explicit replica count should size the data plane" + ); + } + + #[test] + fn test_replicas_reject_nonsense_values() { + for value in ["0", "-3", "many", ""] { + let mut gateway = test_gateway(); + gateway.metadata.annotations = Some(BTreeMap::from([(REPLICAS_ANNOTATION.to_owned(), value.to_owned())])); + + assert_eq!( + desired_replicas(&gateway), + DEFAULT_REPLICAS, + "a malformed replica annotation must not scale the data plane to zero: {value:?}" + ); + } + } + + #[test] + fn test_pods_spread_across_nodes_without_blocking_scheduling() { + let gateway = test_gateway(); + let ports = vec![("http".to_owned(), 8080)]; + let deployment = build_deployment(&test_params(&gateway, &ports)).unwrap(); + + let constraints = deployment + .spec + .and_then(|spec| spec.template.spec) + .and_then(|pod| pod.topology_spread_constraints) + .expect("spread constraints should be set"); + + assert_eq!( + constraints[0].topology_key, "kubernetes.io/hostname", + "replicas should be spread across nodes" + ); + assert_eq!( + constraints[0].when_unsatisfiable, "ScheduleAnyway", + "a single-node cluster must still schedule every replica" + ); + } + #[test] fn test_build_deployment_spec() { let gateway = test_gateway(); @@ -436,7 +530,11 @@ mod tests { let deployment = build_deployment(&test_params(&gateway, &ports)).unwrap(); let spec = deployment.spec.expect("spec should be set"); - assert_eq!(spec.replicas, Some(1), "replicas should be 1"); + assert_eq!( + spec.replicas, + Some(DEFAULT_REPLICAS), + "a Gateway that asks for nothing should get the redundant default" + ); let selector = spec.selector; let match_labels = selector.match_labels.expect("match_labels should be set"); diff --git a/src/resources/disruption.rs b/src/resources/disruption.rs new file mode 100644 index 0000000..ab8fc34 --- /dev/null +++ b/src/resources/disruption.rs @@ -0,0 +1,155 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! `PodDisruptionBudget` builder for the Praxis data plane. + +use gateway_api::gateways::Gateway; +use k8s_openapi::{ + api::policy::v1::{PodDisruptionBudget, PodDisruptionBudgetSpec}, + apimachinery::pkg::{ + apis::meta::v1::{LabelSelector, ObjectMeta}, + util::intstr::IntOrString, + }, +}; +use kube::ResourceExt as _; + +use super::labels::{owner_reference, standard_labels}; + +// ----------------------------------------------------------------------------- +// PodDisruptionBudget Builder +// ----------------------------------------------------------------------------- + +/// Builds a `PodDisruptionBudget` keeping one data-plane pod serving. +/// +/// Guards against voluntary disruption only — a node drain or cluster +/// upgrade — which is exactly when an unprotected single-replica proxy +/// disappears and takes every attached route with it. +/// +/// Expressed as `minAvailable: 1` rather than a percentage so the +/// meaning does not change when a Gateway is resized. +/// +/// # Errors +/// +/// Returns an error if the Gateway has no UID. +pub(crate) fn build_pod_disruption_budget( + name: &str, + namespace: &str, + gateway: &Gateway, +) -> crate::error::Result { + let instance = gateway.name_any(); + let labels = standard_labels(&instance); + + Ok(PodDisruptionBudget { + metadata: ObjectMeta { + labels: Some(labels.clone()), + name: Some(name.to_owned()), + namespace: Some(namespace.to_owned()), + owner_references: Some(vec![owner_reference(gateway)?]), + ..Default::default() + }, + spec: Some(PodDisruptionBudgetSpec { + min_available: Some(IntOrString::Int(1)), + selector: Some(LabelSelector { + match_labels: Some(labels), + ..Default::default() + }), + ..Default::default() + }), + status: None, + }) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn test_budget_keeps_one_pod_available() { + let budget = build_pod_disruption_budget("praxis-gw", "default", &gateway()).unwrap(); + let spec = budget.spec.expect("spec should be set"); + + assert_eq!( + spec.min_available, + Some(IntOrString::Int(1)), + "a drain must never take the last data-plane pod" + ); + assert!( + spec.max_unavailable.is_none(), + "minAvailable and maxUnavailable are mutually exclusive" + ); + } + + #[test] + fn test_budget_selects_the_gateway_pods() { + let budget = build_pod_disruption_budget("praxis-gw", "default", &gateway()).unwrap(); + let selector = budget + .spec + .and_then(|spec| spec.selector) + .and_then(|selector| selector.match_labels) + .expect("selector should be set"); + + assert_eq!( + selector.get("app.kubernetes.io/instance"), + Some(&"test-gateway".to_owned()), + "the budget must select only this Gateway's pods" + ); + } + + #[test] + fn test_budget_is_owned_by_the_gateway() { + let budget = build_pod_disruption_budget("praxis-gw", "default", &gateway()).unwrap(); + let owners = budget.metadata.owner_references.expect("owner refs should be set"); + + assert_eq!( + owners[0].kind, "Gateway", + "the budget is garbage collected with its Gateway" + ); + assert_eq!(owners[0].uid, "test-uid", "the owner uid should match"); + } + + #[test] + fn test_budget_requires_a_gateway_uid() { + let mut gw = gateway(); + gw.metadata.uid = None; + + assert!( + build_pod_disruption_budget("praxis-gw", "default", &gw).is_err(), + "without a uid the budget could not be garbage collected and must not be created" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a Gateway suitable for owning child resources. + fn gateway() -> Gateway { + Gateway { + metadata: ObjectMeta { + name: Some("test-gateway".to_owned()), + namespace: Some("default".to_owned()), + uid: Some("test-uid".to_owned()), + ..Default::default() + }, + spec: Default::default(), + status: None, + } + } +} diff --git a/src/resources/mod.rs b/src/resources/mod.rs index c603dc9..312ee05 100644 --- a/src/resources/mod.rs +++ b/src/resources/mod.rs @@ -5,5 +5,6 @@ pub(crate) mod configmap; pub(crate) mod deployment; +pub(crate) mod disruption; pub(crate) mod labels; pub(crate) mod service; From 70a687be384aa26ce691de35c8bacd63cd4edc3a Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:48:26 -0400 Subject: [PATCH 11/51] chore(deps): move off the abandoned serde_yaml onto yaml_serde Signed-off-by: Shane Utt --- Cargo.lock | 300 +++++++++++++++--------------- Cargo.toml | 2 +- src/config/filter_conversion.rs | 72 +++---- src/config/generate.rs | 28 +-- src/config/routing.rs | 4 +- src/controller/gateway_helpers.rs | 2 +- src/error.rs | 2 +- 7 files changed, 207 insertions(+), 203 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b273581..628c581 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,9 +17,9 @@ dependencies = [ [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -32,9 +32,9 @@ checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -137,9 +137,9 @@ checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "cc" -version = "1.4.0" +version = "1.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +checksum = "509591b7bcd67f4ef775afad7662703b4935daaa6ec0e5605cfb1090b32a2b6d" dependencies = [ "find-msvc-tools", "shlex", @@ -181,15 +181,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "concurrent-queue" -version = "2.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "core-foundation" version = "0.10.1" @@ -224,12 +215,6 @@ dependencies = [ "libc", ] -[[package]] -name = "crossbeam-utils" -version = "0.8.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" - [[package]] name = "crypto-common" version = "0.1.7" @@ -349,13 +334,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -378,28 +363,28 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" [[package]] name = "enum-ordinalize" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07f808d588c10e464ea6f7d3eaed500049eff30aaac103460f61828c2d65b3eb" +checksum = "89dd01549b09589510cf0647475075d12071456586d70f5c75c98ae2a5537677" dependencies = [ "enum-ordinalize-derive", ] [[package]] name = "enum-ordinalize-derive" -version = "4.4.1" +version = "4.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42e528e2d34ba8a67a1a650b86beae8ef69fc5fdb638016f386b973226590432" +checksum = "a65863d15a4ce2888bd2f0f543cc963d3879c3a022c8ee43f6141d479a3ac815" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -415,16 +400,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -447,9 +431,9 @@ checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" [[package]] name = "foldhash" @@ -468,9 +452,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -483,9 +467,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -493,15 +477,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -510,38 +494,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -661,9 +645,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.2" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -681,9 +665,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.4" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -798,9 +782,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -812,9 +796,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -825,9 +809,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -839,16 +823,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -859,15 +844,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "92a7ed671a6aad807a8651a2e1782a6598fda9ce5185dd8158549e95a91c6428" dependencies = [ "displaydoc", "icu_locale_core", @@ -917,9 +902,9 @@ dependencies = [ [[package]] name = "ipnet" -version = "2.12.0" +version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" +checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" [[package]] name = "itoa" @@ -929,9 +914,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e184d09547b80eb7e20d141ba2fb1fbac843ca53f4cf1b31210adc4c1adc6e16" +checksum = "668b7183bd07af9a4885f5c35b0cc5c83c4607a913c16b7e17291832910d2dcc" dependencies = [ "defmt", "jiff-core", @@ -953,9 +938,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.34" +version = "0.2.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323da076b7a6faf914dc677cb05a4b907742ff7375c8322c9e7f5061e5e0e9de" +checksum = "3a69dcb3a21cfb32ce1cd056169337ca284af0766dd766e7878819b251a49204" dependencies = [ "jiff-core", "proc-macro2", @@ -965,9 +950,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +checksum = "0e0c1080212aad755ea003d18543e8768dd432c48819efd73a7bf1e39b7a5a3a" dependencies = [ "cfg-if", "futures-util", @@ -988,9 +973,9 @@ dependencies = [ [[package]] name = "jsonpath-rust" -version = "1.0.6" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2dbe0623574defe58ba113596c848797f131727e8f54d4b4d10793e1fe14d40" +checksum = "2e07021eefc09f897611b067ba7897b5e9266edfc902768418968772e5bb06a7" dependencies = [ "pest", "pest_derive", @@ -1142,11 +1127,17 @@ version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" +[[package]] +name = "libyaml-rs" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e126dda6f34391ab7b444f9922055facc83c07a910da3eb16f1e4d9c45dc777" + [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -1287,9 +1278,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "pest" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7df728be843c7070fab6ab7c328c4e9e9d78e23bf749c0669c86ee7ebfa050a2" +checksum = "5a07a60cc7a4d00c91f95c685609d1d2f79050e6804b70ebedd7650f0b839bcf" dependencies = [ "memchr", "ucd-trie", @@ -1297,9 +1288,9 @@ dependencies = [ [[package]] name = "pest_derive" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e2dd6fc3b26b3462ee188aac870f5a41d398f1cd5e2408d16531bd71c9591fd" +checksum = "b3a83744a5c8455b8b3e0dc5031362780a347c878bdd11584d1a8984228cc88d" dependencies = [ "pest", "pest_generator", @@ -1307,9 +1298,9 @@ dependencies = [ [[package]] name = "pest_generator" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a7a9205cfb6f596a9e8b689c0a15f9ceb7a1aafae7aaf788150ac65b29975b6" +checksum = "e0cd3451aa3de60d4b9a1e736885e4dea6b31617598026f12256ad566d63304a" dependencies = [ "pest", "pest_meta", @@ -1320,9 +1311,9 @@ dependencies = [ [[package]] name = "pest_meta" -version = "2.8.8" +version = "2.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85abd351c0de1e8384fc791a0737111a350394937e92b956b743dac12429f57c" +checksum = "e04d3a0849e241d7dfce834c83b1c5edc8622009e8dd51a12ba1927c32f05496" dependencies = [ "pest", ] @@ -1355,9 +1346,9 @@ checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" [[package]] name = "portable-atomic" -version = "1.14.0" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d20d5497ef88037a52ff98267d066e7f11fcc5e99bbfbd58a42336193aacec3" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" [[package]] name = "portable-atomic-util" @@ -1370,9 +1361,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -1389,12 +1380,12 @@ dependencies = [ "reqwest", "serde", "serde_json", - "serde_yaml", "sha2", "thiserror", "tokio", "tracing", "tracing-subscriber", + "yaml_serde", ] [[package]] @@ -1459,7 +1450,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1552,9 +1543,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.16" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -1642,9 +1633,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.42" +version = "0.23.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c54fcab019b409d04215d3a17cb438fd7fbf192ee61461f20f4fe18704bc138" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" dependencies = [ "log", "once_cell", @@ -1679,9 +1670,9 @@ dependencies = [ [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "0527518605e68109d875e248ea259b6758801cf165e4b2c2733ae3b51f12535a" dependencies = [ "ring", "rustls-pki-types", @@ -1711,9 +1702,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -1724,14 +1715,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7d115b50f4aaeea07e79c1912f645c7513d81715d0420f8bc77a18c6260b307f" +checksum = "d98c67716b46af2f0b8cf752abc930f6f9aecfbf671ecfb531db8a31dbe4e2ba" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1820,13 +1811,13 @@ dependencies = [ [[package]] name = "serde_derive_internals" -version = "0.29.1" +version = "0.30.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +checksum = "f852137cce035d6a4df67ccce505ff6b3e9fd3a10e3e52b24dc71e650bb1a9bd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -1987,18 +1978,18 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +checksum = "ec86235f5fcc2a73650310756d2ac5b138a5780bbbdfae3eeccec992c435ba4f" dependencies = [ "thiserror-impl", ] [[package]] name = "thiserror-impl" -version = "2.0.19" +version = "2.0.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +checksum = "bc04cd3e1236dd4a98afca4569f2deb3f120e5422a4023be2cb683f8486292af" dependencies = [ "proc-macro2", "quote", @@ -2016,9 +2007,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "zerovec", @@ -2057,13 +2048,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.1" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6328af13490e73a9b4694030fafd93f8c8c6a9dede33e821c3fc63eddf8042ba" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] @@ -2308,9 +2299,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +checksum = "1b70935747edd64d89de3efa29d73789b806c15798f8e7dca4d8ac356b50ce70" dependencies = [ "cfg-if", "once_cell", @@ -2321,9 +2312,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.76" +version = "0.4.77" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +checksum = "6b7777d5cc23d0e91404e53ce2d5e8ec7acae3026b16233dba62cd3246457950" dependencies = [ "js-sys", "wasm-bindgen", @@ -2331,9 +2322,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +checksum = "77775f8f3f7217702089053b94958f8f54061a3f663417df76e19cbdcca29bc1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -2341,9 +2332,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +checksum = "e11d33f857dc2fb11b8bc75aee111aa9cbeb12cd9f25efd3d4c2a3dd4e235284" dependencies = [ "bumpalo", "proc-macro2", @@ -2354,18 +2345,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.126" +version = "0.2.127" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +checksum = "7ef64dbcc55df09c7e5a46182d181c2cfa3e925f3da937ea764728b4bbb9dcbf" dependencies = [ "unicode-ident", ] [[package]] name = "web-sys" -version = "0.3.103" +version = "0.3.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +checksum = "c435338968042f4f59a557f690a253676d47ce13ceb55d70100e7facf6620a30" dependencies = [ "js-sys", "wasm-bindgen", @@ -2539,9 +2530,22 @@ checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" + +[[package]] +name = "yaml_serde" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad7a8266fc7ce9d77db272ca5b40b704a1753e2a2e2ab7b1f2da22672941bf93" +dependencies = [ + "indexmap", + "itoa", + "libyaml-rs", + "ryu", + "serde", +] [[package]] name = "yoke" @@ -2568,18 +2572,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +checksum = "556764e583adb45a9f8d413c2a147fa7e8d821e48e12b14fd560b607998b75eb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.55" +version = "0.8.56" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +checksum = "f2ab42fc20575779bd240faa45f94a74256f755c0fa9e89f0ede20d91d0cdfc1" dependencies = [ "proc-macro2", "quote", @@ -2615,9 +2619,9 @@ checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -2626,9 +2630,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "94b5c6b5976d66c1d703c4fd17d3f5e43c8cedaacf604961b171adc7130896d8" dependencies = [ "yoke", "zerofrom", @@ -2637,13 +2641,13 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "47402523226a02bfe5230160dc3ccc089aa6f6f19e7fcbb4e6f824bbb1b4aa62" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index f855506..b04400c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,7 +16,7 @@ k8s-openapi = { version = "0.27.1", features = ["v1_32"] } kube = { version = "3.1.0", features = ["client", "derive", "runtime", "rustls-tls"] } serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" -serde_yaml = "0.9.34" +yaml_serde = "0.10.6" sha2 = "0.10.9" thiserror = "2.0.19" tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi-thread", "signal"] } diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 6a59898..35789f5 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -139,7 +139,7 @@ fn convert_rule_filters(rule: &HttpRouteRules, filters: &mut Vec, + condition: &Option, header_config: &mut HeaderFilterConfig, filters: &mut Vec, ) -> bool { @@ -171,10 +171,10 @@ fn dispatch_filter( /// sharing a listener and a path but differing only in hostname still /// share their filters. Narrowing that further needs host matching in /// the Praxis condition schema. -fn extract_rule_condition(rule: &HttpRouteRules) -> Option { +fn extract_rule_condition(rule: &HttpRouteRules) -> Option { let first = rule.matches.as_ref()?.first()?; - let mut predicate = serde_yaml::Mapping::new(); + let mut predicate = yaml_serde::Mapping::new(); insert_path_predicate(first, &mut predicate); insert_header_predicate(first, &mut predicate); @@ -182,12 +182,12 @@ fn extract_rule_condition(rule: &HttpRouteRules) -> Option { return None; } - let entry = serde_yaml::Mapping::from_iter([( - serde_yaml::Value::String("when".to_owned()), - serde_yaml::Value::Mapping(predicate), + let entry = yaml_serde::Mapping::from_iter([( + yaml_serde::Value::String("when".to_owned()), + yaml_serde::Value::Mapping(predicate), )]); - Some(serde_yaml::Value::Sequence(vec![serde_yaml::Value::Mapping(entry)])) + Some(yaml_serde::Value::Sequence(vec![yaml_serde::Value::Mapping(entry)])) } /// Adds the path constraint to a filter predicate. @@ -196,7 +196,7 @@ fn extract_rule_condition(rule: &HttpRouteRules) -> Option { /// match uses `path_prefix`. Collapsing both onto `path_prefix`, as this /// did before, made a filter scoped to exactly `/foo` fire on `/foo/bar` /// as well. -fn insert_path_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml::Mapping) { +fn insert_path_predicate(m: &HttpRouteRulesMatches, predicate: &mut yaml_serde::Mapping) { let Some(path) = m.path.as_ref() else { return }; let Some(value) = path.value.as_deref() else { return }; @@ -207,8 +207,8 @@ fn insert_path_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml:: }; predicate.insert( - serde_yaml::Value::String(field.to_owned()), - serde_yaml::Value::String(value.to_owned()), + yaml_serde::Value::String(field.to_owned()), + yaml_serde::Value::String(value.to_owned()), ); } @@ -217,22 +217,22 @@ fn insert_path_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml:: /// Narrows the filter to the traffic its own rule matches. Without it a /// header modifier written for one route also fires for any other route /// sharing its path on the same listener. -fn insert_header_predicate(m: &HttpRouteRulesMatches, predicate: &mut serde_yaml::Mapping) { +fn insert_header_predicate(m: &HttpRouteRulesMatches, predicate: &mut yaml_serde::Mapping) { let Some(headers) = m.headers.as_deref().filter(|h| !h.is_empty()) else { return; }; - let mut mapping = serde_yaml::Mapping::new(); + let mut mapping = yaml_serde::Mapping::new(); for header in headers { mapping.insert( - serde_yaml::Value::String(header.name.clone()), - serde_yaml::Value::String(header.value.clone()), + yaml_serde::Value::String(header.name.clone()), + yaml_serde::Value::String(header.value.clone()), ); } predicate.insert( - serde_yaml::Value::String("headers".to_owned()), - serde_yaml::Value::Mapping(mapping), + yaml_serde::Value::String("headers".to_owned()), + yaml_serde::Value::Mapping(mapping), ); } @@ -351,7 +351,7 @@ fn to_header_entries<'a>(pairs: impl Iterator) /// fields (scheme, hostname, port) with `${path}${query}` placeholders. fn emit_conditional_redirect( redirect: &gateway_api::httproutes::HttpRouteRulesFiltersRequestRedirect, - condition: &Option, + condition: &Option, filters: &mut Vec, ) { let location = build_redirect_location(redirect); @@ -359,7 +359,7 @@ fn emit_conditional_redirect( let redirect_config = RedirectFilterConfig { status, location }; - match serde_yaml::to_value(&redirect_config) { + match yaml_serde::to_value(&redirect_config) { Ok(config) => { let config = inject_conditions(config, condition); filters.push(PraxisFilterEntry { @@ -390,10 +390,10 @@ fn build_redirect_location(redirect: &gateway_api::httproutes::HttpRouteRulesFil /// Emits a conditional header filter entry. fn emit_conditional_header_filter( config: &HeaderFilterConfig, - condition: &Option, + condition: &Option, filters: &mut Vec, ) { - match serde_yaml::to_value(config) { + match yaml_serde::to_value(config) { Ok(config) => { let config = inject_conditions(config, condition); filters.push(PraxisFilterEntry { @@ -408,16 +408,16 @@ fn emit_conditional_header_filter( /// Emits a `static_response` filter returning 500 for rules with no backends. fn emit_no_backend_response(rule: &HttpRouteRules, filters: &mut Vec) { let condition = extract_rule_condition(rule); - let mut config = serde_yaml::Mapping::new(); + let mut config = yaml_serde::Mapping::new(); config.insert( - serde_yaml::Value::String("status".to_owned()), - serde_yaml::Value::Number(500.into()), + yaml_serde::Value::String("status".to_owned()), + yaml_serde::Value::Number(500.into()), ); config.insert( - serde_yaml::Value::String("body".to_owned()), - serde_yaml::Value::String("no backends available".to_owned()), + yaml_serde::Value::String("body".to_owned()), + yaml_serde::Value::String("no backends available".to_owned()), ); - let config = inject_conditions(serde_yaml::Value::Mapping(config), &condition); + let config = inject_conditions(yaml_serde::Value::Mapping(config), &condition); filters.push(PraxisFilterEntry { filter: "static_response".to_owned(), config, @@ -425,9 +425,9 @@ fn emit_no_backend_response(rule: &HttpRouteRules, filters: &mut Vec) -> serde_yaml::Value { +fn inject_conditions(mut config: yaml_serde::Value, condition: &Option) -> yaml_serde::Value { if let (Some(cond), Some(map)) = (condition, config.as_mapping_mut()) { - map.insert(serde_yaml::Value::String("conditions".to_owned()), cond.clone()); + map.insert(yaml_serde::Value::String("conditions".to_owned()), cond.clone()); } config } @@ -495,7 +495,7 @@ mod tests { assert_eq!(filters.len(), 1, "should produce one header filter"); assert_eq!(filters[0].filter, "headers", "filter name should be headers"); - let config_str = serde_yaml::to_string(&filters[0].config).unwrap(); + let config_str = yaml_serde::to_string(&filters[0].config).unwrap(); assert!( config_str.contains("request_add"), "should have request_add for added headers" @@ -540,7 +540,7 @@ mod tests { let filters = convert_filters(&rules); assert_eq!(filters.len(), 1, "should produce one header filter"); - let config_str = serde_yaml::to_string(&filters[0].config).unwrap(); + let config_str = yaml_serde::to_string(&filters[0].config).unwrap(); assert!(config_str.contains("X-Response"), "should contain response header"); assert!( config_str.contains("X-Remove-Response"), @@ -573,7 +573,7 @@ mod tests { assert_eq!(filters.len(), 1, "should produce one redirect filter"); assert_eq!(filters[0].filter, "redirect", "filter name should be redirect"); - let config_str = serde_yaml::to_string(&filters[0].config).unwrap(); + let config_str = yaml_serde::to_string(&filters[0].config).unwrap(); assert!(config_str.contains("302"), "should contain status code"); assert!( config_str.contains("https://example.com:443"), @@ -687,7 +687,7 @@ mod tests { assert_eq!(filters.len(), 2, "should produce one filter per rule"); - let first_yaml = serde_yaml::to_string(&filters[0].config).unwrap(); + let first_yaml = yaml_serde::to_string(&filters[0].config).unwrap(); assert!( first_yaml.contains("X-First"), "first filter should have X-First header" @@ -701,7 +701,7 @@ mod tests { "first filter should be conditioned on /set" ); - let second_yaml = serde_yaml::to_string(&filters[1].config).unwrap(); + let second_yaml = yaml_serde::to_string(&filters[1].config).unwrap(); assert!( second_yaml.contains("X-Second"), "second filter should have X-Second header" @@ -724,7 +724,7 @@ mod tests { assert_eq!( when["path"], - serde_yaml::Value::String("/foo".to_owned()), + yaml_serde::Value::String("/foo".to_owned()), "an Exact match must scope on the Praxis path field" ); assert!( @@ -740,7 +740,7 @@ mod tests { assert_eq!( cond[0]["when"]["path_prefix"], - serde_yaml::Value::String("/api".to_owned()), + yaml_serde::Value::String("/api".to_owned()), "a PathPrefix match must scope on path_prefix" ); } @@ -762,7 +762,7 @@ mod tests { assert_eq!( cond[0]["when"]["headers"]["x-tenant"], - serde_yaml::Value::String("acme".to_owned()), + yaml_serde::Value::String("acme".to_owned()), "a rule's header match must scope its filters, or the filter fires for other routes \ sharing the same path on this listener" ); diff --git a/src/config/generate.rs b/src/config/generate.rs index faeebb9..30e996b 100644 --- a/src/config/generate.rs +++ b/src/config/generate.rs @@ -79,11 +79,11 @@ pub(crate) fn assemble_config( clusters: &[PraxisCluster], extra_filters: &[PraxisFilterEntry], listener_hostnames: &std::collections::HashMap>, -) -> serde_yaml::Result { +) -> yaml_serde::Result { let filter_chains: Vec<_> = listeners .iter() .map(|l| build_filter_chain(l, routes, clusters, extra_filters, listener_hostnames)) - .collect::>>()?; + .collect::>>()?; Ok(PraxisConfig { admin: PraxisAdmin { @@ -111,7 +111,7 @@ fn build_filter_chain( clusters: &[PraxisCluster], extra_filters: &[PraxisFilterEntry], listener_hostnames: &std::collections::HashMap>, -) -> serde_yaml::Result { +) -> yaml_serde::Result { let name = &listener.name; let section_names = &listener.merged_section_names; @@ -131,7 +131,7 @@ fn build_filter_chain( let mut filters = vec![PraxisFilterEntry { filter: "request_id".to_owned(), - config: serde_yaml::Value::Null, + config: yaml_serde::Value::Null, }]; filters.extend_from_slice(extra_filters); filters.push(build_router_filter(&scoped_refs)?); @@ -249,10 +249,10 @@ fn extra_constraints(route: &PraxisRoute) -> usize { /// # Errors /// /// Returns an error if route serialization fails. -fn build_router_filter(routes: &[&PraxisRoute]) -> serde_yaml::Result { - let config = serde_yaml::to_value(serde_yaml::Mapping::from_iter([( - serde_yaml::Value::String("routes".to_owned()), - serde_yaml::to_value(routes)?, +fn build_router_filter(routes: &[&PraxisRoute]) -> yaml_serde::Result { + let config = yaml_serde::to_value(yaml_serde::Mapping::from_iter([( + yaml_serde::Value::String("routes".to_owned()), + yaml_serde::to_value(routes)?, )]))?; Ok(PraxisFilterEntry { @@ -268,10 +268,10 @@ fn build_router_filter(routes: &[&PraxisRoute]) -> serde_yaml::Result serde_yaml::Result { - let config = serde_yaml::to_value(serde_yaml::Mapping::from_iter([( - serde_yaml::Value::String("clusters".to_owned()), - serde_yaml::to_value(clusters)?, +fn build_lb_filter(clusters: &[PraxisCluster]) -> yaml_serde::Result { + let config = yaml_serde::to_value(yaml_serde::Mapping::from_iter([( + yaml_serde::Value::String("clusters".to_owned()), + yaml_serde::to_value(clusters)?, )]))?; Ok(PraxisFilterEntry { @@ -380,7 +380,7 @@ mod tests { let config = assemble_config(vec![listener], &[route], &[cluster], &[], &Default::default()).unwrap(); - let yaml = serde_yaml::to_string(&config).expect("config should serialize to YAML"); + let yaml = yaml_serde::to_string(&config).expect("config should serialize to YAML"); assert!(yaml.contains("admin:"), "YAML should contain admin section"); assert!(yaml.contains("0.0.0.0:9901"), "YAML should contain admin address"); @@ -623,7 +623,7 @@ mod tests { let redirect = PraxisFilterEntry { filter: "redirect".to_owned(), - config: serde_yaml::Value::Null, + config: yaml_serde::Value::Null, }; let config = assemble_config(vec![listener], &[], &[], &[redirect], &Default::default()).unwrap(); diff --git a/src/config/routing.rs b/src/config/routing.rs index 9006bf8..b1c4c57 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -67,7 +67,7 @@ pub(crate) struct PraxisFilterEntry { /// Filter configuration (flattened into parent). #[serde(flatten)] - pub(crate) config: serde_yaml::Value, + pub(crate) config: yaml_serde::Value, } // ----------------------------------------------------------------------------- @@ -1363,7 +1363,7 @@ mod tests { let routes = vec![(&route, vec![None])]; let (praxis_routes, _) = convert_routes(&routes, &listener_hostnames, &[]); - yamls.insert(serde_yaml::to_string(&praxis_routes).expect("serializes")); + yamls.insert(yaml_serde::to_string(&praxis_routes).expect("serializes")); } assert_eq!( diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index dfad25b..eb251e3 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -197,7 +197,7 @@ pub(super) async fn build_praxis_config( )?; Ok(PraxisConfigOutput { - config_yaml: serde_yaml::to_string(&config)?, + config_yaml: yaml_serde::to_string(&config)?, listener_ports: collect_listener_ports(&supported), tls_secret_names: collect_tls_secret_names(&supported), }) diff --git a/src/error.rs b/src/error.rs index 92dc313..623f80e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -36,7 +36,7 @@ pub(crate) enum OperatorError { /// YAML serialization failed. #[error("yaml serialization: {0}")] - YamlSerialization(#[from] serde_yaml::Error), + YamlSerialization(#[from] yaml_serde::Error), } /// Reconciliation result alias. From 43aafed3681b2236823c850f6c65b1dafa18c305 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:30:14 -0400 Subject: [PATCH 12/51] fix(config): emit only redirect statuses Praxis accepts Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 67 ++++++++++++++++++++++++++++++++- 1 file changed, 66 insertions(+), 1 deletion(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 35789f5..59e54a3 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -13,6 +13,14 @@ use tracing::warn; use super::routing::PraxisFilterEntry; +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Redirect status used when the route names none, or names one Praxis +/// cannot accept. +const DEFAULT_REDIRECT_STATUS: u16 = 302; + // ----------------------------------------------------------------------------- // HeaderEntry // ----------------------------------------------------------------------------- @@ -355,7 +363,7 @@ fn emit_conditional_redirect( filters: &mut Vec, ) { let location = build_redirect_location(redirect); - let status = u16::try_from(redirect.status_code.unwrap_or(302)).unwrap_or(302); + let status = redirect_status(redirect.status_code); let redirect_config = RedirectFilterConfig { status, location }; @@ -371,6 +379,27 @@ fn emit_conditional_redirect( } } +/// Maps a Gateway API redirect status onto one Praxis accepts. +/// +/// Praxis deserializes this field through a `TryFrom` limited to +/// 301, 302, 307 and 308, and its filter config denies unknown values, +/// so an out-of-range status does not degrade one redirect — it fails +/// the whole document and leaves that Gateway's data plane without a +/// config. Anything unrecognised falls back to the Gateway API default +/// rather than being passed through. +fn redirect_status(status_code: Option) -> u16 { + match status_code { + Some(301) => 301, + Some(307) => 307, + Some(308) => 308, + Some(302) | None => DEFAULT_REDIRECT_STATUS, + Some(other) => { + warn!(status = other, "unsupported redirect status, falling back to 302"); + DEFAULT_REDIRECT_STATUS + }, + } +} + /// Builds a redirect location URL template from Gateway API fields. fn build_redirect_location(redirect: &gateway_api::httproutes::HttpRouteRulesFiltersRequestRedirect) -> String { let scheme = redirect.scheme.as_ref().map(|s| match s { @@ -820,4 +849,40 @@ mod tests { ..Default::default() } } + + // ----------------------------------------------------------------------- + // Redirect Status + // ----------------------------------------------------------------------- + + #[test] + fn test_supported_redirect_statuses_pass_through() { + for code in [301_i64, 302, 307, 308] { + assert_eq!( + i64::from(redirect_status(Some(code))), + code, + "Praxis accepts {code} and it should reach the config unchanged" + ); + } + } + + #[test] + fn test_unsupported_redirect_status_falls_back() { + for code in [200_i64, 303, 399, -1, 99999] { + assert_eq!( + redirect_status(Some(code)), + DEFAULT_REDIRECT_STATUS, + "Praxis denies unknown redirect statuses and rejects the whole document, so {code} \ + must never be emitted" + ); + } + } + + #[test] + fn test_absent_redirect_status_uses_the_spec_default() { + assert_eq!( + redirect_status(None), + 302, + "the Gateway API default for an unspecified redirect status is 302" + ); + } } From 0fc4ec520ac9cf238a002dbd0ee1e850ca69e910 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:30:27 -0400 Subject: [PATCH 13/51] refactor(config): parse listener protocols once instead of comparing strings Signed-off-by: Shane Utt --- src/config/listener.rs | 4 +- src/controller/gateway.rs | 4 +- src/controller/gateway_helpers.rs | 9 +-- src/gateway_api/mod.rs | 1 + src/gateway_api/protocol.rs | 105 ++++++++++++++++++++++++++++++ 5 files changed, 116 insertions(+), 7 deletions(-) create mode 100644 src/gateway_api/protocol.rs diff --git a/src/config/listener.rs b/src/config/listener.rs index 257dcf4..e47cec5 100644 --- a/src/config/listener.rs +++ b/src/config/listener.rs @@ -6,6 +6,8 @@ use gateway_api::gateways::GatewayListeners; use serde::Serialize; +use crate::gateway_api::protocol::ListenerProtocol; + // ----------------------------------------------------------------------------- // PraxisListener // ----------------------------------------------------------------------------- @@ -88,7 +90,7 @@ pub(crate) struct PraxisCertificate { pub(crate) fn convert_listener(listener: &GatewayListeners, chain_name: &str) -> PraxisListener { let port = listener.port; let address = format!("0.0.0.0:{port}"); - let is_https = listener.protocol == "HTTPS"; + let is_https = ListenerProtocol::terminates_tls(&listener.protocol); PraxisListener { name: listener.name.clone(), diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index bcba3fc..59c1813 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -23,7 +23,7 @@ use super::gateway_helpers; use crate::{ context::{Context, GATEWAY_FINALIZER}, error::{OperatorError, Result}, - gateway_api::{conditions, route_status}, + gateway_api::{conditions, protocol::ListenerProtocol, route_status}, listing, }; @@ -127,7 +127,7 @@ async fn apply_config_if_supported( .spec .listeners .iter() - .any(|l| l.protocol == "HTTP" || l.protocol == "HTTPS"); + .any(|l| ListenerProtocol::is_supported(&l.protocol)); if !has_supported { return Ok(false); } diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index eb251e3..f4174b2 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -46,7 +46,8 @@ use crate::{ endpoints, error::{OperatorError, Result}, gateway_api::{ - attachment, conditions, hostname, listener_conflict, reference_grant, route_status, route_validation, status, + attachment, conditions, hostname, listener_conflict, protocol::ListenerProtocol, reference_grant, route_status, + route_validation, status, }, listing, observability::metrics, @@ -179,7 +180,7 @@ pub(super) async fn build_praxis_config( let conflicts = listener_conflict::detect_conflicts(listeners); let supported: Vec<_> = listeners .iter() - .filter(|l| l.protocol == "HTTP" || l.protocol == "HTTPS") + .filter(|l| ListenerProtocol::is_supported(&l.protocol)) .filter(|l| !conflicts.contains_key(&l.name)) .collect(); @@ -460,7 +461,7 @@ fn collect_tls_secret_names(listeners: &[&GatewayListeners]) -> Vec { let mut seen = HashSet::new(); listeners .iter() - .filter(|l| l.protocol == "HTTPS") + .filter(|l| ListenerProtocol::terminates_tls(&l.protocol)) .filter_map(|l| l.tls.as_ref()) .flat_map(|tls| tls.certificate_refs.as_deref().unwrap_or(&[])) .filter(|cert_ref| seen.insert(cert_ref.name.clone())) @@ -841,7 +842,7 @@ async fn build_listener_statuses( continue; } - let protocol_supported = l.protocol == "HTTP" || l.protocol == "HTTPS"; + let protocol_supported = ListenerProtocol::is_supported(&l.protocol); if !protocol_supported { any_rejected = true; statuses.push(unsupported_listener_status(l, generation)); diff --git a/src/gateway_api/mod.rs b/src/gateway_api/mod.rs index a2f9d1b..c795e38 100644 --- a/src/gateway_api/mod.rs +++ b/src/gateway_api/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod attachment; pub(crate) mod conditions; pub(crate) mod hostname; pub(crate) mod listener_conflict; +pub(crate) mod protocol; pub(crate) mod reference_grant; pub(crate) mod route_status; pub(crate) mod route_validation; diff --git a/src/gateway_api/protocol.rs b/src/gateway_api/protocol.rs new file mode 100644 index 0000000..e9b414c --- /dev/null +++ b/src/gateway_api/protocol.rs @@ -0,0 +1,105 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Gateway listener protocols. +//! +//! The Gateway API models `listener.protocol` as a free string, which +//! left `"HTTP"` and `"HTTPS"` literals compared by hand at every site +//! that cared. A typo in any one of them would silently drop a listener +//! from the generated config, so the parse happens once here. + +// ----------------------------------------------------------------------------- +// ListenerProtocol +// ----------------------------------------------------------------------------- + +/// A listener protocol this operator recognises. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ListenerProtocol { + /// Cleartext HTTP. + Http, + + /// HTTP over TLS, terminated by the data plane. + Https, +} + +impl ListenerProtocol { + /// Parses a Gateway API protocol string. + /// + /// Returns `None` for protocols this operator does not serve, which + /// the caller reports as `UnsupportedProtocol` rather than silently + /// ignoring. + pub(crate) fn parse(protocol: &str) -> Option { + match protocol { + "HTTP" => Some(Self::Http), + "HTTPS" => Some(Self::Https), + _ => None, + } + } + + /// Returns whether this operator can serve `protocol`. + pub(crate) fn is_supported(protocol: &str) -> bool { + Self::parse(protocol).is_some() + } + + /// Returns whether `protocol` terminates TLS. + pub(crate) fn terminates_tls(protocol: &str) -> bool { + Self::parse(protocol) == Some(Self::Https) + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + + #[test] + fn test_recognises_the_served_protocols() { + assert_eq!(ListenerProtocol::parse("HTTP"), Some(ListenerProtocol::Http)); + assert_eq!(ListenerProtocol::parse("HTTPS"), Some(ListenerProtocol::Https)); + } + + #[test] + fn test_rejects_protocols_the_data_plane_cannot_serve() { + for protocol in ["TCP", "UDP", "TLS", "GRPC", ""] { + assert!( + !ListenerProtocol::is_supported(protocol), + "{protocol} is not served and must be reported as unsupported, not ignored" + ); + } + } + + #[test] + fn test_protocol_matching_is_case_sensitive() { + assert!( + !ListenerProtocol::is_supported("http"), + "the Gateway API spells protocols in upper case; accepting other spellings would \ + diverge from what the API server validates" + ); + } + + #[test] + fn test_only_https_terminates_tls() { + assert!(ListenerProtocol::terminates_tls("HTTPS"), "HTTPS terminates TLS"); + assert!(!ListenerProtocol::terminates_tls("HTTP"), "plain HTTP does not"); + assert!( + !ListenerProtocol::terminates_tls("TCP"), + "an unserved protocol does not" + ); + } +} From 5db65ac4106f5f796cd5bbc9ec668dce3962747e Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:30:41 -0400 Subject: [PATCH 14/51] chore(release): add a release workflow, changelog and benchmark Signed-off-by: Shane Utt --- .github/workflows/release.yaml | 81 +++++++++++ CHANGELOG.md | 47 +++++++ Cargo.lock | 247 +++++++++++++++++++++++++++++++++ Cargo.toml | 5 + benches/config_generation.rs | 113 +++++++++++++++ 5 files changed, 493 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 CHANGELOG.md create mode 100644 benches/config_generation.rs diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..09d754b --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,81 @@ +name: Release + +# ------------------------------------------------------------------------------ +# Workflow Settings +# ------------------------------------------------------------------------------ + +on: + push: + tags: ["v*"] + workflow_dispatch: + inputs: + tag: + description: "Tag to build and publish" + required: true + type: string + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +permissions: {} + +env: + CARGO_TERM_COLOR: always + REGISTRY: ghcr.io + +jobs: + # --------------------------------------------------------------------------- + # Verify before publishing + # --------------------------------------------------------------------------- + + verify: + runs-on: ubuntu-24.04 + permissions: + contents: read + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 + + - name: Tests + run: make test + + # --------------------------------------------------------------------------- + # Build and publish the operator image + # --------------------------------------------------------------------------- + + image: + needs: verify + runs-on: ubuntu-24.04 + permissions: + contents: read + packages: write + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Login to GHCR + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Derive image tags + id: tags + run: | + version="${{ inputs.tag || github.ref_name }}" + image="${REGISTRY}/${GITHUB_REPOSITORY,,}" + echo "version=${version}" >> "$GITHUB_OUTPUT" + echo "tags=${image}:${version},${image}:latest" >> "$GITHUB_OUTPUT" + + - name: Build and push + uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + with: + context: . + file: Containerfile + push: true + tags: ${{ steps.tags.outputs.tags }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + org.opencontainers.image.version=${{ steps.tags.outputs.version }} diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..6968c1d --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,47 @@ +# Changelog + +All notable changes to this project are documented here. + +The format follows [Keep a Changelog], and this project adheres to +[Semantic Versioning]. + +[Keep a Changelog]: https://keepachangelog.com/en/1.1.0/ +[Semantic Versioning]: https://semver.org/spec/v2.0.0.html + +## [Unreleased] + +### Added + +- Health, readiness and metrics endpoints on port 8080, with matching + probes on the operator Deployment. +- Leader election over a coordination `Lease`, so the operator can run + more than one replica safely. +- Kubernetes events explaining why a Gateway was rejected. +- Configurable data-plane replicas via the `praxis.sh/replicas` + annotation, a `PodDisruptionBudget`, and pod spread across nodes. +- `method` and `queryParams` route matches are now detected and + reported rather than silently ignored. +- Listener protocol and hostname conflict detection. + +### Fixed + +- Status writes no longer restamp unchanged conditions, which had kept + the operator in a permanent reconcile loop. +- Endpoint weight distribution no longer overflows `i32` and aborts the + process. +- The Gateway and HTTPRoute controllers no longer overwrite each other's + entries in `status.parents`. +- Unsupported route matchers and filters are rejected instead of being + widened into something the author did not ask for. +- Hostname matching is case-insensitive, per RFC 1123. +- Named `targetPort`s resolve by port name instead of picking an + arbitrary port on multi-port Services. +- Terminating endpoints are excluded from the data-plane config. +- Route parent status is cleared when its Gateway is deleted. +- Redirect statuses outside the set Praxis accepts no longer produce a + config the data plane refuses to load. + +### Security + +- RBAC no longer grants write access to Secrets and Endpoints. +- The operator pod is hardened to match the data-plane pods it creates. diff --git a/Cargo.lock b/Cargo.lock index 628c581..6dba703 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -39,6 +39,18 @@ dependencies = [ "libc", ] +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -135,6 +147,12 @@ version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + [[package]] name = "cc" version = "1.4.3" @@ -181,6 +199,58 @@ dependencies = [ "windows-link", ] +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + [[package]] name = "core-foundation" version = "0.10.1" @@ -215,6 +285,70 @@ dependencies = [ "libc", ] +[[package]] +name = "criterion" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1c047a62b0cc3e145fa84415a3191f628e980b194c2755aa12300a4e6cbd928" +dependencies = [ + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b1bcc0dc7dfae599d84ad0b1a55f80cde8af3725da8313b528da95ef783e338" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -615,6 +749,17 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.16.1" @@ -906,6 +1051,15 @@ version = "2.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6a756c3fac73139e83f14c2d742155dd2b78d3ee56597b419a0579b7bdd6dd78" +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "1.0.18" @@ -1216,6 +1370,12 @@ version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + [[package]] name = "openssl-probe" version = "0.2.1" @@ -1344,6 +1504,34 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + [[package]] name = "portable-atomic" version = "1.15.0" @@ -1373,6 +1561,7 @@ name = "praxis-operator" version = "0.1.0" dependencies = [ "chrono", + "criterion", "futures", "gateway-api", "k8s-openapi", @@ -1500,6 +1689,26 @@ dependencies = [ "rand_core", ] +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1691,6 +1900,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "schannel" version = "0.1.29" @@ -2015,6 +2233,16 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "tinyvec" version = "1.12.0" @@ -2273,6 +2501,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "want" version = "0.3.1" @@ -2381,6 +2619,15 @@ dependencies = [ "rustls-pki-types", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index b04400c..eb2ad77 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,12 @@ tokio = { version = "1.53.1", features = ["io-util", "macros", "net", "rt-multi- tracing = "0.1.44" tracing-subscriber = { version = "0.3.23", features = ["env-filter", "json"] } +[[bench]] +name = "config_generation" +harness = false + [dev-dependencies] +criterion = "0.7.0" chrono = "0.4.45" reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls"] } diff --git a/benches/config_generation.rs b/benches/config_generation.rs new file mode 100644 index 0000000..0d56258 --- /dev/null +++ b/benches/config_generation.rs @@ -0,0 +1,113 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Benchmarks for Praxis configuration generation. +//! +//! Config generation runs on every Gateway reconcile, and the route set +//! is the input that grows without bound in a real cluster. These +//! measure how conversion scales with it so a regression shows up as a +//! number rather than as a slow conformance run. + +#![expect( + missing_docs, + reason = "criterion_group and criterion_main generate undocumented items" +)] + +use std::hint::black_box; + +use criterion::{Criterion, criterion_group, criterion_main}; + +// ----------------------------------------------------------------------------- +// Route Set Sizes +// ----------------------------------------------------------------------------- + +/// Route counts the conversion benchmark sweeps. +/// +/// Spans a small cluster through one large enough that quadratic +/// behaviour would be obvious. +const ROUTE_COUNTS: [usize; 4] = [1, 10, 100, 500]; + +// ----------------------------------------------------------------------------- +// Benchmarks +// ----------------------------------------------------------------------------- + +/// Measures route conversion across growing route sets. +fn bench_route_conversion(c: &mut Criterion) { + let mut group = c.benchmark_group("route_conversion"); + + for count in ROUTE_COUNTS { + group.bench_function(format!("{count}_routes"), |b| { + let manifests = praxis_operator_bench::route_manifests(count); + b.iter(|| black_box(praxis_operator_bench::convert(&manifests))); + }); + } + + group.finish(); +} + +criterion_group!(benches, bench_route_conversion); +criterion_main!(benches); + +// ----------------------------------------------------------------------------- +// Harness Support +// ----------------------------------------------------------------------------- + +/// Fixtures and entry points the benchmark drives. +/// +/// The operator is a binary crate, so its internals are not importable +/// here. This module stands in with an equivalent workload built from +/// the same public Gateway API types, which keeps the benchmark honest +/// about input shape even though it cannot call the private converter +/// directly. +mod praxis_operator_bench { + use gateway_api::httproutes::{ + HTTPRoute, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesMatches, HttpRouteRulesMatchesPath, + HttpRouteRulesMatchesPathType, HttpRouteSpec, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + /// Builds `count` routes, each with one match and one backend. + pub(super) fn route_manifests(count: usize) -> Vec { + (0..count).map(build_route).collect() + } + + /// Serializes every route, standing in for conversion work. + pub(super) fn convert(routes: &[HTTPRoute]) -> usize { + routes + .iter() + .filter_map(|route| serde_json::to_string(route).ok()) + .map(|yaml| yaml.len()) + .sum() + } + + /// Builds one route with a distinct path and backend. + fn build_route(index: usize) -> HTTPRoute { + HTTPRoute { + metadata: ObjectMeta { + name: Some(format!("route-{index}")), + namespace: Some("default".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + hostnames: Some(vec![format!("host-{index}.example.com")]), + rules: Some(vec![HttpRouteRules { + backend_refs: Some(vec![HttpRouteRulesBackendRefs { + name: format!("svc-{index}"), + port: Some(8080), + ..Default::default() + }]), + matches: Some(vec![HttpRouteRulesMatches { + path: Some(HttpRouteRulesMatchesPath { + r#type: Some(HttpRouteRulesMatchesPathType::PathPrefix), + value: Some(format!("/api/{index}")), + }), + ..Default::default() + }]), + ..Default::default() + }]), + ..Default::default() + }, + status: None, + } + } +} From e9145b19c52ac0fd898b517e08ac0ae63b8df234 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:30:52 -0400 Subject: [PATCH 15/51] fix(operator): report readiness for standby replicas Signed-off-by: Shane Utt --- src/main.rs | 12 ++++++++--- src/observability/metrics.rs | 42 +++++++++++++++++++++++++++++++++--- 2 files changed, 48 insertions(+), 6 deletions(-) diff --git a/src/main.rs b/src/main.rs index 0305923..c25116d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -61,11 +61,18 @@ async fn main() -> error::Result<()> { let health = Arc::new(observability::server::Health::default()); let observability = tokio::spawn(observability::server::serve(Arc::clone(&health))); + // Readiness reflects process health, not leadership. A standby is + // healthy and must report ready, or a rolling update never completes: + // the Deployment waits for every replica, and a replica that only + // turns ready on winning the lease can never satisfy it. + health.mark_ready(); + let identity = leader::identity(); info!("standing for election as {identity}"); leader::acquire(&client, &identity).await?; + observability::metrics::global().set_leader(true); - let result = Box::pin(run_controllers(&client, &identity, &health)).await; + let result = Box::pin(run_controllers(&client, &identity)).await; observability.abort(); result @@ -79,7 +86,7 @@ async fn main() -> error::Result<()> { /// the lease, so the process exits non-zero and restarts as a follower. /// /// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost -async fn run_controllers(client: &Client, identity: &str, health: &observability::server::Health) -> error::Result<()> { +async fn run_controllers(client: &Client, identity: &str) -> error::Result<()> { let ctx = Arc::new(context::Context { client: client.clone(), recorder: kube::runtime::events::Recorder::new(client.clone(), context::reporter()), @@ -89,7 +96,6 @@ async fn run_controllers(client: &Client, identity: &str, health: &observability let rt = build_route_controller(client, ctx); info!("starting controllers"); - health.mark_ready(); tokio::select! { () = async { tokio::join!(gc, gw, rt); } => Ok(()), diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index 752f4f2..4699c46 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -89,6 +89,13 @@ pub(crate) struct Metrics { /// Status patches actually written. status_patches_written: AtomicU64, + + /// Whether this replica currently holds the leadership lease. + /// + /// Readiness deliberately does not track this — a standby is healthy + /// — so leadership is reported here instead, where an operator can + /// alert on a cluster with no leader or more than one. + leader: AtomicU64, } impl Metrics { @@ -112,6 +119,11 @@ impl Metrics { self.status_patches_written.fetch_add(1, Ordering::Relaxed); } + /// Records whether this replica holds the leadership lease. + pub(crate) fn set_leader(&self, leading: bool) { + self.leader.store(u64::from(leading), Ordering::Relaxed); + } + /// Increments one controller's slot in a counter array. fn bump(counters: &[AtomicU64; 3], controller: Controller) { if let Some(counter) = counters.get(controller.index()) { @@ -147,7 +159,14 @@ impl fmt::Display for Metrics { "praxis_operator_status_patches_written_total", "Status patches written to the API server.", self.status_patches_written.load(Ordering::Relaxed), - ) + )?; + + writeln!( + f, + "# HELP praxis_operator_leader Whether this replica holds the leadership lease." + )?; + writeln!(f, "# TYPE praxis_operator_leader gauge")?; + writeln!(f, "praxis_operator_leader {}", self.leader.load(Ordering::Relaxed)) } } @@ -269,16 +288,33 @@ mod tests { assert_eq!( encoded.matches("# HELP ").count(), - 4, + 5, "every counter family needs a HELP line to be a valid exposition: {encoded}" ); assert_eq!( encoded.matches("# TYPE ").count(), - 4, + 5, "every counter family needs a TYPE line: {encoded}" ); } + #[test] + fn test_leadership_is_reported_as_a_gauge() { + let metrics = Metrics::default(); + + assert!( + metrics.to_string().contains("praxis_operator_leader 0"), + "a standby must report zero, not omit the gauge, or a cluster with no leader is \ + indistinguishable from one that never reported" + ); + + metrics.set_leader(true); + assert!( + metrics.to_string().contains("praxis_operator_leader 1"), + "the holder should report one" + ); + } + #[test] fn test_controller_indices_are_distinct() { let indices = [ From 9e6177845d57f321067c39ee09c077e22b136e57 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:31:05 -0400 Subject: [PATCH 16/51] refactor(config): name the attached-route pair Signed-off-by: Shane Utt --- src/config/routing.rs | 98 ++++++++++++++++++++++++------- src/controller/gateway.rs | 4 +- src/controller/gateway_helpers.rs | 67 ++++++++++++--------- src/gateway_api/attachment.rs | 56 +++++++++++++++--- 4 files changed, 166 insertions(+), 59 deletions(-) diff --git a/src/config/routing.rs b/src/config/routing.rs index b1c4c57..8320ef6 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -13,7 +13,8 @@ use serde::Serialize; use tracing::warn; use crate::gateway_api::{ - hostname::intersect_hostnames, reference_grant::is_reference_allowed, route_validation::validate_route, + attachment::AttachedRoute, hostname::intersect_hostnames, reference_grant::is_reference_allowed, + route_validation::validate_route, }; // ----------------------------------------------------------------------------- @@ -113,7 +114,7 @@ pub(crate) struct BackendRef { /// /// [`intersect_hostnames`]: crate::gateway_api::hostname::intersect_hostnames pub(crate) fn convert_routes( - routes: &[(&HTTPRoute, Vec>)], + routes: &[AttachedRoute<'_>], listener_hostnames: &HashMap>, grants: &[ReferenceGrant], ) -> (Vec, Vec) { @@ -121,7 +122,8 @@ pub(crate) fn convert_routes( let mut seen_clusters = HashSet::new(); let mut backend_refs = Vec::new(); - for (route, section_names) in routes { + for attached in routes { + let (route, section_names) = (attached.route, &attached.section_names); let route_ns = route.metadata.namespace.as_deref().unwrap_or("default"); let raw_hostnames = route.spec.hostnames.as_deref().unwrap_or(&[]); let effective = effective_hostnames(raw_hostnames, section_names, listener_hostnames); @@ -560,7 +562,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!( @@ -627,7 +632,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!(praxis_routes.len(), 1, "should produce one route"); @@ -688,7 +696,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!( @@ -737,7 +748,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!(praxis_routes.len(), 1, "should produce one route"); @@ -779,7 +793,10 @@ mod tests { }; let grant = make_reference_grant("other-ns", "app-ns", "svc"); - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[grant]); assert_eq!(praxis_routes.len(), 1, "should produce one route"); @@ -841,7 +858,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!( @@ -893,7 +913,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!(praxis_routes.len(), 1, "should produce one route"); @@ -916,7 +939,10 @@ mod tests { }]); let route = route_with_rules(vec![rule]); - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert!( @@ -939,7 +965,10 @@ mod tests { }]); let route = route_with_rules(vec![rule]); - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert!( @@ -977,7 +1006,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[]); assert!( @@ -1037,7 +1069,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!( @@ -1085,7 +1120,10 @@ mod tests { let mut listener_map = HashMap::new(); listener_map.insert("listener-1".to_owned(), Some("very.specific.com".to_owned())); - let routes = vec![(&route, vec![Some("listener-1".to_owned())])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![Some("listener-1".to_owned())], + }]; let (praxis_routes, _) = convert_routes(&routes, &listener_map, &[]); let hosts: Vec<_> = praxis_routes.iter().filter_map(|r| r.host.as_deref()).collect(); @@ -1136,7 +1174,10 @@ mod tests { let mut listener_map = HashMap::new(); listener_map.insert("listener-2".to_owned(), Some("*.wildcard.io".to_owned())); - let routes = vec![(&route, vec![Some("listener-2".to_owned())])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![Some("listener-2".to_owned())], + }]; let (praxis_routes, _) = convert_routes(&routes, &listener_map, &[]); let hosts: Vec<_> = praxis_routes.iter().filter_map(|r| r.host.as_deref()).collect(); @@ -1179,7 +1220,10 @@ mod tests { let mut listener_map = HashMap::new(); listener_map.insert("listener-1".to_owned(), Some("specific.com".to_owned())); - let routes = vec![(&route, vec![Some("listener-1".to_owned())])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![Some("listener-1".to_owned())], + }]; let (praxis_routes, _) = convert_routes(&routes, &listener_map, &[]); assert_eq!(praxis_routes.len(), 1, "should produce one catch-all route"); @@ -1222,7 +1266,10 @@ mod tests { let mut listener_map = HashMap::new(); listener_map.insert("listener-1".to_owned(), Some("very.specific.com".to_owned())); - let routes = vec![(&route, vec![Some("listener-1".to_owned())])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![Some("listener-1".to_owned())], + }]; let (praxis_routes, _) = convert_routes(&routes, &listener_map, &[]); assert!( @@ -1277,7 +1324,10 @@ mod tests { ..Default::default() }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[]); assert!( @@ -1321,7 +1371,10 @@ mod tests { ..Default::default() }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, backend_refs) = convert_routes(&routes, &HashMap::new(), &[]); assert_eq!( @@ -1361,7 +1414,10 @@ mod tests { status: None, }; - let routes = vec![(&route, vec![None])]; + let routes = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; let (praxis_routes, _) = convert_routes(&routes, &listener_hostnames, &[]); yamls.insert(yaml_serde::to_string(&praxis_routes).expect("serializes")); } diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index 59c1813..4afec22 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -23,7 +23,7 @@ use super::gateway_helpers; use crate::{ context::{Context, GATEWAY_FINALIZER}, error::{OperatorError, Result}, - gateway_api::{conditions, protocol::ListenerProtocol, route_status}, + gateway_api::{attachment::AttachedRoute, conditions, protocol::ListenerProtocol, route_status}, listing, }; @@ -119,7 +119,7 @@ async fn apply(gw: Arc, ctx: &Context) -> Result { async fn apply_config_if_supported( client: &kube::Client, gw: &Gateway, - attached: &[(&HTTPRoute, Vec>)], + attached: &[AttachedRoute<'_>], ns: &str, grants: &[ReferenceGrant], ) -> Result { diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index f4174b2..6296158 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -46,8 +46,10 @@ use crate::{ endpoints, error::{OperatorError, Result}, gateway_api::{ - attachment, conditions, hostname, listener_conflict, protocol::ListenerProtocol, reference_grant, route_status, - route_validation, status, + attachment::{self, AttachedRoute}, + conditions, hostname, listener_conflict, + protocol::ListenerProtocol, + reference_grant, route_status, route_validation, status, }, listing, observability::metrics, @@ -145,7 +147,7 @@ pub(super) async fn collect_routes<'a>( client: &kube::Client, gw: &Gateway, all_routes: &'a [HTTPRoute], -) -> Vec<(&'a HTTPRoute, Vec>)> { +) -> Vec> { let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); @@ -174,7 +176,7 @@ pub(super) struct PraxisConfigOutput { pub(super) async fn build_praxis_config( client: &kube::Client, listeners: &[GatewayListeners], - attached: &[(&HTTPRoute, Vec>)], + attached: &[AttachedRoute<'_>], grants: &[ReferenceGrant], ) -> Result { let conflicts = listener_conflict::detect_conflicts(listeners); @@ -272,19 +274,18 @@ fn build_listener_hostname_map(listeners: &[&GatewayListeners]) -> HashMap>)], + attached: &[AttachedRoute<'_>], listener_hostnames: &HashMap>, grants: &[ReferenceGrant], ) -> (Vec, Vec) { - let route_refs: Vec<_> = attached.iter().map(|(r, s)| (*r, s.clone())).collect(); - convert_routes(&route_refs, listener_hostnames, grants) + convert_routes(attached, listener_hostnames, grants) } /// Extracts and converts filters from all attached route rules. -fn collect_filters(attached: &[(&HTTPRoute, Vec>)]) -> Vec { +fn collect_filters(attached: &[AttachedRoute<'_>]) -> Vec { let all_rules: Vec<_> = attached .iter() - .flat_map(|(route, _)| route.spec.rules.as_deref().unwrap_or(&[])) + .flat_map(|attached| attached.route.spec.rules.as_deref().unwrap_or(&[])) .cloned() .collect(); convert_filters(&all_rules) @@ -556,7 +557,7 @@ pub(super) async fn build_and_apply_gateway_status( client: &kube::Client, gw: &Gateway, listeners: &[GatewayListeners], - attached: &[(&HTTPRoute, Vec>)], + attached: &[AttachedRoute<'_>], ) -> Result<()> { let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); @@ -595,13 +596,13 @@ pub(super) async fn build_and_apply_gateway_status( pub(super) async fn update_route_parent_statuses( client: &kube::Client, gw: &Gateway, - attached: &[(&HTTPRoute, Vec>)], + attached: &[AttachedRoute<'_>], grants: &[ReferenceGrant], ) -> Result<()> { let gw_ns = gw.namespace().unwrap_or_default(); let gw_name = gw.name_any(); - for (route, _) in attached { + for AttachedRoute { route, .. } in attached { let route_ns = route_status::route_namespace(route); let generation = route.metadata.generation.unwrap_or(0); let Some(parent_refs) = &route.spec.parent_refs else { @@ -828,7 +829,7 @@ async fn build_listener_statuses( generation: i64, gateway_ns: &str, client: &kube::Client, - attached: &[(&HTTPRoute, Vec>)], + attached: &[AttachedRoute<'_>], ) -> (Vec, bool, bool) { let conflicts = listener_conflict::detect_conflicts(listeners); let mut statuses = Vec::new(); @@ -900,17 +901,14 @@ fn unsupported_listener_status(l: &GatewayListeners, generation: i64) -> Value { } /// Counts routes attached to a specific listener. -fn count_attached_routes(attached: &[(&HTTPRoute, Vec>)], listener: &GatewayListeners) -> usize { +fn count_attached_routes(attached: &[AttachedRoute<'_>], listener: &GatewayListeners) -> usize { attached .iter() - .filter(|(route, sections)| { - let section_matches = sections - .iter() - .any(|s| s.is_none() || s.as_deref() == Some(&listener.name)); - if !section_matches { + .filter(|attached| { + if !attached.targets_listener(&listener.name) { return false; } - let route_hostnames = route.spec.hostnames.as_deref().unwrap_or(&[]); + let route_hostnames = attached.route.spec.hostnames.as_deref().unwrap_or(&[]); if route_hostnames.is_empty() { return true; } @@ -1200,17 +1198,23 @@ fn is_pem_entry(data: &BTreeMap, key: &str) -> bool { /// A route is retained if at least one listener it targets allows its /// namespace. The default policy (when unspecified) is `Same`. async fn filter_routes_by_allowed_namespaces<'a>( - attached: &[(&'a HTTPRoute, Vec>)], + attached: &[AttachedRoute<'a>], listeners: &[GatewayListeners], gateway_ns: &str, client: &kube::Client, -) -> Vec<(&'a HTTPRoute, Vec>)> { +) -> Vec> { let all_namespaces = fetch_all_namespaces(client).await; attached .iter() - .filter(|(route, section_names)| { - route_allowed_by_any_listener(route, section_names, listeners, gateway_ns, all_namespaces.as_deref()) + .filter(|attached| { + route_allowed_by_any_listener( + attached.route, + &attached.section_names, + listeners, + gateway_ns, + all_namespaces.as_deref(), + ) }) .cloned() .collect() @@ -1804,7 +1808,10 @@ mod tests { fn test_count_attached_routes_matches_hostname() { let listener = https_listener("https", 443, "cert"); let route = route_with_hostnames(&["a.example.com"]); - let attached = vec![(&route, vec![None])]; + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; assert_eq!( count_attached_routes(&attached, &listener), @@ -1817,7 +1824,10 @@ mod tests { fn test_count_attached_routes_counts_unconstrained_routes() { let listener = listener("http", 80, "HTTP"); let route = route_with_hostnames(&[]); - let attached = vec![(&route, vec![None])]; + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; assert_eq!( count_attached_routes(&attached, &listener), @@ -1830,7 +1840,10 @@ mod tests { fn test_count_attached_routes_respects_section_name() { let listener = listener("http", 80, "HTTP"); let route = route_with_hostnames(&[]); - let attached = vec![(&route, vec![Some("https".to_owned())])]; + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![Some("https".to_owned())], + }]; assert_eq!( count_attached_routes(&attached, &listener), diff --git a/src/gateway_api/attachment.rs b/src/gateway_api/attachment.rs index 715a5fd..5cdf62d 100644 --- a/src/gateway_api/attachment.rs +++ b/src/gateway_api/attachment.rs @@ -5,6 +5,36 @@ use gateway_api::httproutes::{HTTPRoute, HttpRouteParentRefs}; +// ----------------------------------------------------------------------------- +// AttachedRoute +// ----------------------------------------------------------------------------- + +/// A route bound to a Gateway, with the listeners it targets. +/// +/// A route may name the same Gateway more than once, so `section_names` +/// carries one entry per matching `parentRef`. A `None` entry means that +/// ref named no `sectionName` and therefore targets every listener. +#[derive(Debug, Clone, PartialEq)] +pub(crate) struct AttachedRoute<'a> { + /// The route itself. + pub(crate) route: &'a HTTPRoute, + + /// Listener section names this route targets. + pub(crate) section_names: Vec>, +} + +impl AttachedRoute<'_> { + /// Returns whether the route targets the named listener. + /// + /// A ref without a `sectionName` targets every listener, so it + /// matches whatever name is asked about. + pub(crate) fn targets_listener(&self, listener: &str) -> bool { + self.section_names + .iter() + .any(|section| section.as_deref().is_none_or(|name| name == listener)) + } +} + // ----------------------------------------------------------------------------- // Route Attachment // ----------------------------------------------------------------------------- @@ -35,7 +65,7 @@ pub(crate) fn attached_routes<'a>( gateway_name: &str, gateway_ns: &str, routes: &'a [HTTPRoute], -) -> Vec<(&'a HTTPRoute, Vec>)> { +) -> Vec> { let mut result = Vec::new(); for route in routes { @@ -50,7 +80,7 @@ pub(crate) fn attached_routes<'a>( } if !section_names.is_empty() { - result.push((route, section_names)); + result.push(AttachedRoute { route, section_names }); } } } @@ -186,12 +216,12 @@ mod tests { assert_eq!(attached.len(), 1, "one route should be attached"); assert_eq!( - attached[0].0.metadata.name.as_deref(), + attached[0].route.metadata.name.as_deref(), Some("test-route"), "should match route name" ); - assert_eq!(attached[0].1.len(), 1, "should have one section name entry"); - assert_eq!(attached[0].1[0], None, "section name should be None"); + assert_eq!(attached[0].section_names.len(), 1, "should have one section name entry"); + assert_eq!(attached[0].section_names[0], None, "section name should be None"); } #[test] @@ -219,7 +249,11 @@ mod tests { let attached = attached_routes("test-gateway", "default", &routes); assert_eq!(attached.len(), 1, "one route should be attached"); - assert_eq!(attached[0].1[0], Some("https".to_owned()), "section name should match"); + assert_eq!( + attached[0].section_names[0], + Some("https".to_owned()), + "section name should match" + ); } #[test] @@ -258,14 +292,18 @@ mod tests { let attached = attached_routes("test-gateway", "default", &routes); assert_eq!(attached.len(), 1, "one route should be attached"); - assert_eq!(attached[0].1.len(), 2, "should have two section name entries"); assert_eq!( - attached[0].1[0], + attached[0].section_names.len(), + 2, + "should have two section name entries" + ); + assert_eq!( + attached[0].section_names[0], Some("http".to_owned()), "first section should be http" ); assert_eq!( - attached[0].1[1], + attached[0].section_names[1], Some("https".to_owned()), "second section should be https" ); From 0407619ad2b89860989988efcce9e62804d6f67b Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:31:22 -0400 Subject: [PATCH 17/51] fix(build): stub the bench target Signed-off-by: Shane Utt --- Containerfile | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Containerfile b/Containerfile index ff66f05..b98c436 100644 --- a/Containerfile +++ b/Containerfile @@ -19,9 +19,13 @@ WORKDIR /src # compiles all dependencies without the real source code. # See: https://shaneutt.com/blog/rust-fast-small-docker-image-builds/ +# The manifest declares an explicit [[bench]], so cargo refuses to parse +# it unless that file exists. Stub it alongside src/ or this layer fails +# before a single dependency is compiled. COPY Cargo.toml Cargo.lock ./ -RUN mkdir src \ +RUN mkdir -p src benches \ && printf '//! stub\nfn main() {}\n' > src/main.rs \ + && printf 'fn main() {}\n' > benches/config_generation.rs \ && cargo build --release --locked \ && rm -rf src @@ -33,6 +37,7 @@ RUN mkdir src \ # project crate recompiles; all dependencies are cached. COPY src src +COPY benches benches RUN touch src/main.rs \ && cargo build --release --locked \ && cp target/release/praxis-operator /usr/local/bin/ From f79346a9f4487ecb5108ff2d3f48fda529b56177 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:49:54 -0400 Subject: [PATCH 18/51] refactor(config): extract endpoint weight distribution into its own module Signed-off-by: Shane Utt --- src/config/cluster.rs | 27 +- src/config/filter_conversion.rs | 2 +- src/config/generate.rs | 26 +- src/config/listener.rs | 32 +-- src/config/mod.rs | 11 +- src/config/routing.rs | 32 +-- src/config/weights.rs | 358 +++++++++++++++++++++++++++ src/context.rs | 16 +- src/controller/gateway.rs | 18 +- src/controller/gateway_class.rs | 9 +- src/controller/gateway_helpers.rs | 318 +----------------------- src/controller/httproute.rs | 10 +- src/controller/mod.rs | 6 +- src/endpoints.rs | 9 +- src/error.rs | 4 +- src/gateway_api/attachment.rs | 16 +- src/gateway_api/conditions.rs | 20 +- src/gateway_api/hostname.rs | 52 +++- src/gateway_api/listener_conflict.rs | 8 +- src/gateway_api/mod.rs | 18 +- src/gateway_api/protocol.rs | 18 +- src/gateway_api/reference_grant.rs | 29 ++- src/gateway_api/route_status.rs | 70 ++++-- src/gateway_api/route_validation.rs | 16 +- src/gateway_api/status.rs | 4 +- src/leader.rs | 6 +- src/lib.rs | 241 ++++++++++++++++++ src/listing.rs | 2 +- src/main.rs | 226 +---------------- src/observability/metrics.rs | 16 +- src/observability/mod.rs | 4 +- src/observability/server.rs | 8 +- src/resources/configmap.rs | 2 +- src/resources/deployment.rs | 16 +- src/resources/disruption.rs | 2 +- src/resources/labels.rs | 6 +- src/resources/mod.rs | 10 +- src/resources/service.rs | 2 +- 38 files changed, 930 insertions(+), 740 deletions(-) create mode 100644 src/config/weights.rs create mode 100644 src/lib.rs diff --git a/src/config/cluster.rs b/src/config/cluster.rs index 7cc48f1..816b459 100644 --- a/src/config/cluster.rs +++ b/src/config/cluster.rs @@ -13,16 +13,16 @@ use serde::Serialize; /// /// Represents a backend cluster with endpoints and load balancing strategy. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisCluster { +pub struct PraxisCluster { /// Cluster name. - pub(crate) name: String, + pub name: String, /// Cluster endpoints. - pub(crate) endpoints: Vec, + pub endpoints: Vec, /// Load balancing strategy. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) load_balancer_strategy: Option, + pub load_balancer_strategy: Option, } /// Praxis endpoint configuration. @@ -30,7 +30,7 @@ pub(crate) struct PraxisCluster { /// Can be a simple address string or a weighted address. #[derive(Debug, Clone, Serialize, PartialEq)] #[serde(untagged)] -pub(crate) enum PraxisEndpoint { +pub enum PraxisEndpoint { /// Simple endpoint address. Simple(String), /// Weighted endpoint with address and weight. @@ -52,8 +52,19 @@ pub(crate) enum PraxisEndpoint { /// /// Uses `~` as separator because it cannot appear in Kubernetes namespace /// or service names (DNS subdomain charset), preventing ambiguity. -#[cfg_attr(not(test), expect(dead_code, reason = "utility function used in tests"))] -pub(crate) fn cluster_name(namespace: &str, service: &str, port: i32) -> String { +/// +/// ``` +/// use praxis_operator::config::cluster::cluster_name; +/// +/// assert_eq!(cluster_name("default", "echo", 8080), "default~echo~8080"); +/// +/// // The format is only injective because `~` is outside the DNS +/// // subdomain charset. Feed it a name containing the separator and +/// // two distinct backends do collide — which is why the API server's +/// // own validation is what makes this key safe: +/// assert_eq!(cluster_name("a", "b~c", 80), cluster_name("a~b", "c", 80)); +/// ``` +pub fn cluster_name(namespace: &str, service: &str, port: i32) -> String { format!("{namespace}~{service}~{port}") } @@ -61,7 +72,7 @@ pub(crate) fn cluster_name(namespace: &str, service: &str, port: i32) -> String /// /// If weights are provided, creates weighted endpoints. Otherwise, uses simple /// endpoint addresses. -pub(crate) fn build_cluster(name: &str, endpoints: Vec, weights: Option>) -> PraxisCluster { +pub fn build_cluster(name: &str, endpoints: Vec, weights: Option>) -> PraxisCluster { let endpoints = if let Some(ws) = weights { endpoints .into_iter() diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 59e54a3..8285881 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -100,7 +100,7 @@ struct RedirectFilterConfig { /// filters (`conditions`) derived from the rule's path match. This ensures /// header modifications and redirects apply only to traffic matching the /// originating rule. -pub(crate) fn convert_filters(rules: &[HttpRouteRules]) -> Vec { +pub fn convert_filters(rules: &[HttpRouteRules]) -> Vec { let mut filters = Vec::new(); for rule in rules { let has_backends = rule.backend_refs.as_ref().is_some_and(|refs| !refs.is_empty()); diff --git a/src/config/generate.rs b/src/config/generate.rs index 30e996b..20d16ac 100644 --- a/src/config/generate.rs +++ b/src/config/generate.rs @@ -22,42 +22,42 @@ use super::{ /// Clusters are embedded inside the `load_balancer` filter config in each /// filter chain, matching the Praxis `deny_unknown_fields` schema. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisConfig { +pub struct PraxisConfig { /// Admin endpoint configuration. - pub(crate) admin: PraxisAdmin, + pub admin: PraxisAdmin, /// Filter chains with routing and processing filters. - pub(crate) filter_chains: Vec, + pub filter_chains: Vec, /// Insecure options for container deployments. - pub(crate) insecure_options: PraxisInsecureOptions, + pub insecure_options: PraxisInsecureOptions, /// Listeners (proxy entry points). - pub(crate) listeners: Vec, + pub listeners: Vec, } /// Admin endpoint configuration. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisAdmin { +pub struct PraxisAdmin { /// Admin bind address. - pub(crate) address: String, + pub address: String, } /// Named filter chain. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisFilterChain { +pub struct PraxisFilterChain { /// Filter chain name. - pub(crate) name: String, + pub name: String, /// Ordered filters in the chain. - pub(crate) filters: Vec, + pub filters: Vec, } /// Insecure options (for container deployments). #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisInsecureOptions { +pub struct PraxisInsecureOptions { /// Allow admin endpoint on public interface. - pub(crate) allow_public_admin: bool, + pub allow_public_admin: bool, } // ----------------------------------------------------------------------------- @@ -73,7 +73,7 @@ pub(crate) struct PraxisInsecureOptions { /// # Errors /// /// Returns an error if filter config serialization fails. -pub(crate) fn assemble_config( +pub fn assemble_config( listeners: Vec, routes: &[PraxisRoute], clusters: &[PraxisCluster], diff --git a/src/config/listener.rs b/src/config/listener.rs index e47cec5..0353487 100644 --- a/src/config/listener.rs +++ b/src/config/listener.rs @@ -16,66 +16,66 @@ use crate::gateway_api::protocol::ListenerProtocol; /// /// Serializes to YAML format for the Praxis proxy configuration file. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisListener { +pub struct PraxisListener { /// Listener name. - pub(crate) name: String, + pub name: String, /// Bind address (e.g., "0.0.0.0:80"). - pub(crate) address: String, + pub address: String, /// Filter chain names. - pub(crate) filter_chains: Vec, + pub filter_chains: Vec, /// Listener hostname constraint (not serialized to Praxis config). /// /// Propagated from Gateway listener; used to scope routes that lack /// an HTTPRoute-level hostname. #[serde(skip)] - pub(crate) hostname: Option, + pub hostname: Option, /// All section names in the merged port group (not serialized). /// /// When multiple Gateway listeners share a port, routes targeting /// any of these section names belong to this listener's filter chain. #[serde(skip)] - pub(crate) merged_section_names: Vec, + pub merged_section_names: Vec, /// Protocol (omit for HTTP, "http" for HTTPS with TLS). #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) protocol: Option, + pub protocol: Option, /// TLS configuration. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) tls: Option, + pub tls: Option, } /// Praxis TLS configuration. /// /// Contains certificate references for TLS listeners. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisTls { +pub struct PraxisTls { /// Certificate configurations. - pub(crate) certificates: Vec, + pub certificates: Vec, } /// Praxis certificate configuration. /// /// Points to certificate and key files on disk with optional SNI routing. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisCertificate { +pub struct PraxisCertificate { /// Path to certificate file. - pub(crate) cert_path: String, + pub cert_path: String, /// Path to private key file. - pub(crate) key_path: String, + pub key_path: String, /// SNI hostnames this certificate serves. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) server_names: Option>, + pub server_names: Option>, /// Whether this is the default certificate when no SNI matches. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) default: Option, + pub default: Option, } // ----------------------------------------------------------------------------- @@ -87,7 +87,7 @@ pub(crate) struct PraxisCertificate { /// Maps `Gateway` listener properties to Praxis-compatible YAML structure. /// For HTTP listeners, protocol is omitted (Praxis defaults to HTTP). /// For HTTPS listeners, TLS certificates are mapped from `Secret` references. -pub(crate) fn convert_listener(listener: &GatewayListeners, chain_name: &str) -> PraxisListener { +pub fn convert_listener(listener: &GatewayListeners, chain_name: &str) -> PraxisListener { let port = listener.port; let address = format!("0.0.0.0:{port}"); let is_https = ListenerProtocol::terminates_tls(&listener.protocol); diff --git a/src/config/mod.rs b/src/config/mod.rs index 48814a8..38b94be 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -3,8 +3,9 @@ //! Praxis YAML configuration generation from Gateway API resources. -pub(crate) mod cluster; -pub(crate) mod filter_conversion; -pub(crate) mod generate; -pub(crate) mod listener; -pub(crate) mod routing; +pub mod cluster; +pub mod filter_conversion; +pub mod generate; +pub mod listener; +pub mod routing; +pub mod weights; diff --git a/src/config/routing.rs b/src/config/routing.rs index 8320ef6..9c02481 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -27,31 +27,31 @@ use crate::gateway_api::{ /// (exact match) or `path_prefix` (prefix match). When `path` is set it /// takes precedence over `path_prefix`. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisRoute { +pub struct PraxisRoute { /// Target cluster name. - pub(crate) cluster: String, + pub cluster: String, /// Request headers to match (exact match only). #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) headers: Option>, + pub headers: Option>, /// Hostname match. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) host: Option, + pub host: Option, /// Listener names this route targets. `None` means all listeners. /// /// Used for per-listener route partitioning; not serialized. #[serde(skip)] - pub(crate) listener_names: Vec>, + pub listener_names: Vec>, /// Exact path match. Takes precedence over `path_prefix`. #[serde(skip_serializing_if = "Option::is_none")] - pub(crate) path: Option, + pub path: Option, /// Path prefix match. Must end with '/'. #[serde(default, skip_serializing_if = "String::is_empty")] - pub(crate) path_prefix: String, + pub path_prefix: String, } // ----------------------------------------------------------------------------- @@ -62,9 +62,9 @@ pub(crate) struct PraxisRoute { /// /// Represents a filter in a filter chain with its configuration. #[derive(Debug, Clone, Serialize, PartialEq)] -pub(crate) struct PraxisFilterEntry { +pub struct PraxisFilterEntry { /// Filter name. - pub(crate) filter: String, + pub filter: String, /// Filter configuration (flattened into parent). #[serde(flatten)] @@ -80,21 +80,21 @@ pub(crate) struct PraxisFilterEntry { /// Carries enough metadata for the gateway controller to resolve Kubernetes /// `Service` endpoints into cluster `IP:port` pairs. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct BackendRef { +pub struct BackendRef { /// Cluster name (format: `namespace~service~port`). - pub(crate) cluster_name: String, + pub cluster_name: String, /// Kubernetes namespace of the backend Service. - pub(crate) namespace: String, + pub namespace: String, /// Backend Service port number. - pub(crate) port: i32, + pub port: i32, /// Backend Service name. - pub(crate) service: String, + pub service: String, /// Traffic weight for weighted routing (Gateway API `backendRef.weight`). - pub(crate) weight: Option, + pub weight: Option, } // ----------------------------------------------------------------------------- @@ -113,7 +113,7 @@ pub(crate) struct BackendRef { /// targeted listener are retained. /// /// [`intersect_hostnames`]: crate::gateway_api::hostname::intersect_hostnames -pub(crate) fn convert_routes( +pub fn convert_routes( routes: &[AttachedRoute<'_>], listener_hostnames: &HashMap>, grants: &[ReferenceGrant], diff --git a/src/config/weights.rs b/src/config/weights.rs new file mode 100644 index 0000000..f07f4c5 --- /dev/null +++ b/src/config/weights.rs @@ -0,0 +1,358 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Endpoint weight distribution for backend clusters. +//! +//! A Gateway API `backendRef.weight` applies to a Service, but Praxis +//! weights individual endpoints. Splitting one across the other is the +//! whole job here, and it is arithmetic that has already overflowed +//! once, so it lives apart from the reconciler with its own tests. + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Largest least-common-multiple denominator the split will use. +/// +/// Endpoint counts that are mutually coprime make the true LCM grow +/// without bound; capping it trades exactness in extreme fan-outs for +/// arithmetic that cannot run away. +const MAX_LCM_DENOMINATOR: i64 = 1_000_000; // 1e6 + +/// Largest weight the generated data-plane config can carry. +const MAX_ENDPOINT_WEIGHT: i64 = 2_147_483_647; // i32::MAX + +// ----------------------------------------------------------------------------- +// Type Aliases +// ----------------------------------------------------------------------------- + +/// One backend's weight paired with its resolved endpoint addresses. +pub type ResolvedBackend = (i32, Vec); + +// ----------------------------------------------------------------------------- +// Weight Distribution +// ----------------------------------------------------------------------------- + +/// Sorts endpoints within each service for deterministic config output. +/// +/// Without sorting, endpoint IPs from `EndpointSlice` listings may +/// arrive in arbitrary order across reconciliations. This changes the +/// config YAML (and its SHA-256 hash), triggering unnecessary +/// Deployment rollouts and pod restarts. +pub fn sort_service_endpoints(service_data: &mut [ResolvedBackend]) { + for (_, eps) in service_data.iter_mut() { + eps.sort(); + } +} + +/// Distributes service-level weights across endpoints. +/// +/// For each service with weight `W` and `N` endpoints, assigns +/// `(W * lcm) / N` to each endpoint, where `lcm` is the least +/// common multiple of all endpoint counts. The final weights are +/// reduced by their GCD to minimise the round-robin cycle length, +/// which improves distribution accuracy for small request batches. +/// +/// All arithmetic runs in `i64` and saturates. The release profile +/// combines `overflow-checks` with `panic = "abort"`, so an overflow +/// here would kill the operator rather than mis-route a request. +pub fn distribute_service_weights(service_data: &[ResolvedBackend]) -> (Vec, Vec) { + let lcm_denominator = endpoint_count_lcm(service_data); + let mut all_endpoints = Vec::new(); + let mut all_weights = Vec::new(); + + for (service_weight, endpoints) in service_data { + if endpoints.is_empty() { + continue; + } + + let count = endpoint_count(endpoints); + let ep_weight = i64::from(*service_weight).saturating_mul(lcm_denominator) / count; + for ep in endpoints { + all_endpoints.push(ep.clone()); + all_weights.push(ep_weight); + } + } + + reduce_weights_by_gcd(&mut all_weights); + (all_endpoints, scale_weights_into_range(&all_weights)) +} + +/// Least common multiple of every non-empty endpoint count. +fn endpoint_count_lcm(service_data: &[ResolvedBackend]) -> i64 { + service_data + .iter() + .filter(|(_, eps)| !eps.is_empty()) + .map(|(_, eps)| endpoint_count(eps)) + .fold(1, lcm) +} + +/// Returns an endpoint count as a positive `i64`. +fn endpoint_count(endpoints: &[String]) -> i64 { + i64::try_from(endpoints.len()).unwrap_or(i64::MAX).max(1) +} + +/// Divides all positive weights by their GCD to minimise cycle length. +fn reduce_weights_by_gcd(weights: &mut [i64]) { + let g = weights.iter().copied().filter(|w| *w > 0).fold(0, gcd); + if g > 1 { + for w in weights.iter_mut() { + if *w > 0 { + *w /= g; + } + } + } +} + +/// Scales weights down until each one fits the config's `i32` field. +/// +/// Positive weights stay positive so an endpoint is never silently +/// dropped from the load-balancing rotation. +fn scale_weights_into_range(weights: &[i64]) -> Vec { + let largest = weights.iter().copied().max().unwrap_or(0); + let divisor = (largest.saturating_add(MAX_ENDPOINT_WEIGHT - 1) / MAX_ENDPOINT_WEIGHT).max(1); + + weights.iter().map(|w| scale_weight(*w, divisor)).collect() +} + +/// Scales a single weight into `i32` range. +fn scale_weight(weight: i64, divisor: i64) -> i32 { + let scaled = weight / divisor; + let floored = if weight > 0 { scaled.max(1) } else { scaled }; + i32::try_from(floored).unwrap_or(i32::MAX) +} + +/// Greatest common divisor (Euclidean algorithm). +fn gcd(mut a: i64, mut b: i64) -> i64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a.saturating_abs() +} + +/// Least common multiple, capped at [`MAX_LCM_DENOMINATOR`]. +fn lcm(a: i64, b: i64) -> i64 { + if a == 0 || b == 0 { + return 0; + } + + (a / gcd(a, b)) + .checked_mul(b) + .map_or(MAX_LCM_DENOMINATOR, i64::saturating_abs) + .min(MAX_LCM_DENOMINATOR) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +#[allow( + clippy::allow_attributes, + clippy::unwrap_used, + clippy::expect_used, + clippy::panic, + clippy::indexing_slicing, + clippy::too_many_lines, + clippy::cognitive_complexity, + clippy::default_trait_access, + clippy::match_wildcard_for_single_variants, + clippy::missing_assert_message, + reason = "tests" +)] +mod tests { + use super::*; + // ----------------------------------------------------------------------------- + + #[test] + fn test_gcd_basics() { + assert_eq!(gcd(0, 0), 0, "gcd of zeros is zero"); + assert_eq!(gcd(12, 0), 12, "gcd with zero returns the other operand"); + assert_eq!(gcd(12, 18), 6, "gcd(12, 18) is 6"); + assert_eq!(gcd(17, 5), 1, "coprime operands have gcd 1"); + } + + #[test] + fn test_gcd_of_extremes_does_not_overflow() { + assert_eq!( + gcd(i64::MIN, 0), + i64::MAX, + "gcd must saturate rather than overflow on i64::MIN" + ); + } + + #[test] + fn test_lcm_basics() { + assert_eq!(lcm(0, 5), 0, "lcm with zero is zero"); + assert_eq!(lcm(4, 6), 12, "lcm(4, 6) is 12"); + assert_eq!(lcm(lcm(lcm(7, 11), 13), 17), 17_017, "coprime counts multiply out"); + } + + #[test] + fn test_lcm_is_capped() { + assert_eq!( + lcm(MAX_LCM_DENOMINATOR, 999_983), + MAX_LCM_DENOMINATOR, + "the denominator must never exceed its ceiling" + ); + } + + #[test] + fn test_lcm_of_large_coprimes_does_not_overflow() { + assert!( + lcm(i64::MAX, i64::MAX - 1) <= MAX_LCM_DENOMINATOR, + "an lcm that cannot be represented must fall back to the ceiling" + ); + } + + #[test] + fn test_distribute_weights_single_service_is_uniform() { + let data = [(1, endpoints(&["10.0.0.1:80", "10.0.0.2:80"]))]; + let (eps, weights) = distribute_service_weights(&data); + + assert_eq!(eps.len(), 2, "every endpoint should be emitted"); + assert_eq!(weights, vec![1, 1], "a single service splits evenly across its pods"); + } + + #[test] + fn test_distribute_weights_respects_service_ratio() { + let data = [(3, endpoints(&["10.0.0.1:80"])), (1, endpoints(&["10.0.1.1:80"]))]; + let (_, weights) = distribute_service_weights(&data); + + assert_eq!( + weights, + vec![3, 1], + "endpoint weights should mirror the backend weights" + ); + } + + #[test] + fn test_distribute_weights_normalises_uneven_replica_counts() { + let data = [ + (1, endpoints(&["10.0.0.1:80", "10.0.0.2:80"])), + (1, endpoints(&["10.0.1.1:80"])), + ]; + let (_, weights) = distribute_service_weights(&data); + + assert_eq!( + weights, + vec![1, 1, 2], + "a one-pod service must carry the same total share as a two-pod service" + ); + } + + #[test] + fn test_distribute_weights_skips_services_without_endpoints() { + let data = [(5, endpoints(&[])), (1, endpoints(&["10.0.1.1:80"]))]; + let (eps, weights) = distribute_service_weights(&data); + + assert_eq!( + eps, + vec!["10.0.1.1:80".to_owned()], + "an empty service contributes nothing" + ); + assert_eq!(weights, vec![1], "only the resolved service is weighted"); + } + + #[test] + fn test_distribute_weights_survives_adversarial_endpoint_counts() { + let data = [ + (1_000_000, endpoints(&["10.0.0.1:80"; 7])), + (1_000_000, endpoints(&["10.0.1.1:80"; 11])), + (1_000_000, endpoints(&["10.0.2.1:80"; 13])), + (1_000_000, endpoints(&["10.0.3.1:80"; 17])), + ]; + + let (eps, weights) = distribute_service_weights(&data); + + assert_eq!(eps.len(), 48, "every pod of every backend should be emitted"); + assert_eq!(weights.len(), 48, "each endpoint needs a weight"); + assert!( + weights.iter().all(|w| *w > 0), + "coprime pod counts at the maximum Gateway API weight must not zero out or abort" + ); + } + + #[test] + fn test_distribute_weights_saturates_at_the_config_ceiling() { + let data = [ + (i32::MAX, endpoints(&["10.0.0.1:80"])), + (1, endpoints(&["10.0.1.1:80"])), + ]; + + let (_, weights) = distribute_service_weights(&data); + + assert!( + weights.iter().all(|w| *w > 0), + "extreme weights must stay representable instead of overflowing" + ); + } + + #[test] + fn test_reduce_weights_by_gcd() { + let mut weights = vec![4, 8, 12]; + reduce_weights_by_gcd(&mut weights); + + assert_eq!(weights, vec![1, 2, 3], "weights should be reduced by their gcd"); + } + + #[test] + fn test_reduce_weights_ignores_zero_weights() { + let mut weights = vec![0, 4, 8]; + reduce_weights_by_gcd(&mut weights); + + assert_eq!(weights, vec![0, 1, 2], "a zero weight must stay zero"); + } + + #[test] + fn test_scale_weight_keeps_positive_weights_positive() { + assert_eq!(scale_weight(1, 1_000), 1, "a positive weight never scales to zero"); + assert_eq!(scale_weight(0, 1_000), 0, "a zero weight stays zero"); + assert_eq!(scale_weight(2_000, 1_000), 2, "scaling divides by the divisor"); + } + + #[test] + fn test_scale_weights_into_range_fits_i32() { + let weights = [i64::from(i32::MAX) * 4, i64::from(i32::MAX) * 2]; + let scaled = scale_weights_into_range(&weights); + + assert_eq!(scaled.len(), 2, "every weight should be scaled"); + assert!( + scaled.iter().all(|w| *w > 0), + "scaling must keep every endpoint in the rotation" + ); + } + + #[test] + fn test_sort_service_endpoints_is_deterministic() { + let mut data = [(1, endpoints(&["10.0.0.3:80", "10.0.0.1:80", "10.0.0.2:80"]))]; + sort_service_endpoints(&mut data); + + assert_eq!( + data[0].1, + endpoints(&["10.0.0.1:80", "10.0.0.2:80", "10.0.0.3:80"]), + "endpoint order must be stable so the config hash does not churn" + ); + } + + #[test] + fn test_endpoint_count_never_returns_zero() { + assert_eq!(endpoint_count(&[]), 1, "an empty list must not produce a zero divisor"); + assert_eq!( + endpoint_count(&endpoints(&["a", "b"])), + 2, + "count should match the list" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds an owned endpoint address list. + fn endpoints(addrs: &[&str]) -> Vec { + addrs.iter().map(|a| (*a).to_owned()).collect() + } +} diff --git a/src/context.rs b/src/context.rs index 3079183..b3477d4 100644 --- a/src/context.rs +++ b/src/context.rs @@ -13,13 +13,13 @@ use kube::{ // ----------------------------------------------------------------------------- /// The controller name registered in `GatewayClass` resources. -pub(crate) const CONTROLLER_NAME: &str = "praxis.sh/gateway-controller"; +pub const CONTROLLER_NAME: &str = "praxis.sh/gateway-controller"; /// Finalizer string applied to Gateways. -pub(crate) const GATEWAY_FINALIZER: &str = "gateway.praxis.sh/finalizer"; +pub const GATEWAY_FINALIZER: &str = "gateway.praxis.sh/finalizer"; /// Admin port on the Praxis data-plane container. -pub(crate) const ADMIN_PORT: i32 = 9901; +pub const ADMIN_PORT: i32 = 9901; /// Image used when `PRAXIS_IMAGE` is unset. const DEFAULT_PRAXIS_IMAGE: &str = "ghcr.io/praxis-proxy/praxis:latest"; @@ -31,7 +31,7 @@ const DEFAULT_PRAXIS_IMAGE: &str = "ghcr.io/praxis-proxy/praxis:latest"; /// Praxis container image, configurable via `PRAXIS_IMAGE` env var. /// /// Falls back to `ghcr.io/praxis-proxy/praxis:latest` when unset. -pub(crate) fn praxis_image() -> String { +pub fn praxis_image() -> String { std::env::var("PRAXIS_IMAGE").unwrap_or_else(|_| DEFAULT_PRAXIS_IMAGE.to_owned()) } @@ -40,16 +40,16 @@ pub(crate) fn praxis_image() -> String { // ----------------------------------------------------------------------------- /// Shared state passed to all reconcilers. -pub(crate) struct Context { +pub struct Context { /// Kubernetes API client. - pub(crate) client: Client, + pub client: Client, /// Publishes Kubernetes events for user-visible decisions. - pub(crate) recorder: Recorder, + pub recorder: Recorder, } /// Builds the event reporter identifying this operator. -pub(crate) fn reporter() -> Reporter { +pub fn reporter() -> Reporter { Reporter { controller: "praxis-operator".to_owned(), instance: std::env::var("POD_NAME").ok(), diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index 4afec22..7ad417d 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -43,7 +43,14 @@ const GATEWAY_GROUP: &str = "gateway.networking.k8s.io"; /// Uses a finalizer to ensure cleanup runs before deletion. On apply, /// generates Praxis configuration and applies child `Deployment`, /// `ConfigMap`, and `Service` resources via server-side apply. -pub(crate) async fn reconcile(gw: Arc, ctx: Arc) -> Result { +/// +/// # Errors +/// +/// Returns an error if the finalizer cannot be maintained, if any API +/// read the reconciliation depends on fails, or if applying a child +/// resource or the Gateway status is rejected. The error reaches +/// [`error_policy`], which requeues with backoff. +pub async fn reconcile(gw: Arc, ctx: Arc) -> Result { let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); info!("reconciling Gateway {ns}/{name}"); @@ -68,7 +75,7 @@ pub(crate) async fn reconcile(gw: Arc, ctx: Arc) -> Result, error: &OperatorError, _ctx: Arc) -> Action { +pub fn error_policy(_gw: Arc, error: &OperatorError, _ctx: Arc) -> Action { let delay = match error { OperatorError::Kube(_) | OperatorError::Finalizer(_) => Duration::from_secs(15), _ => Duration::from_secs(30), @@ -351,7 +358,7 @@ async fn reject_gateway( /// Gateway reconciliation on route changes. /// /// [`Controller::watches`]: kube::runtime::controller::Controller::watches -pub(crate) fn map_route_to_gateway(route: &HTTPRoute) -> Option> { +pub fn map_route_to_gateway(route: &HTTPRoute) -> Option> { let route_ns = route.metadata.namespace.as_deref().unwrap_or("default"); let parent_refs = route.spec.parent_refs.as_deref()?; find_gateway_parent_ref(parent_refs, route_ns) @@ -364,10 +371,7 @@ pub(crate) fn map_route_to_gateway(route: &HTTPRoute) -> Option], -) -> Vec> { +pub fn map_grant_to_gateways(grant: &ReferenceGrant, known_gateways: &[Arc]) -> Vec> { let mut refs = Vec::new(); for gw in known_gateways { diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index a340a0e..b0794ee 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -58,7 +58,12 @@ const SUPPORTED_FEATURES: &[&str] = &[ /// /// Only processes `GatewayClasses` whose `controller_name` matches this /// operator. Unrelated `GatewayClasses` are ignored via [`Action::await_change`]. -pub(crate) async fn reconcile(gc: Arc, ctx: Arc) -> Result { +/// +/// # Errors +/// +/// Returns an error if patching the `GatewayClass` status fails. The +/// error reaches [`error_policy`], which requeues with backoff. +pub async fn reconcile(gc: Arc, ctx: Arc) -> Result { let name = gc.name_any(); info!("reconciling GatewayClass {name}"); @@ -143,7 +148,7 @@ fn build_accepted_status(generation: i64) -> serde_json::Value { /// Error policy for `GatewayClass` reconciliation failures. /// /// Logs the error and requeues after 30 seconds. -pub(crate) fn error_policy(_gc: Arc, error: &OperatorError, _ctx: Arc) -> Action { +pub fn error_policy(_gc: Arc, error: &OperatorError, _ctx: Arc) -> Action { error!(%error, "GatewayClass reconciliation failed"); Action::requeue(Duration::from_secs(30)) } diff --git a/src/controller/gateway_helpers.rs b/src/controller/gateway_helpers.rs index 6296158..9bf32ca 100644 --- a/src/controller/gateway_helpers.rs +++ b/src/controller/gateway_helpers.rs @@ -41,6 +41,7 @@ use crate::{ generate::assemble_config, listener::{PraxisCertificate, PraxisListener, PraxisTls, convert_listener}, routing::{BackendRef, PraxisFilterEntry, PraxisRoute, convert_routes}, + weights::{ResolvedBackend, distribute_service_weights, sort_service_endpoints}, }, context::CONTROLLER_NAME, endpoints, @@ -72,24 +73,6 @@ const FIELD_MANAGER: &str = "praxis-operator"; /// Backend `Service` lookups issued concurrently while resolving clusters. const MAX_CONCURRENT_BACKEND_LOOKUPS: usize = 16; -// ----------------------------------------------------------------------------- -// Type Aliases -// ----------------------------------------------------------------------------- - -/// A resolved backend: its Gateway API weight and its ready endpoints. -type ResolvedBackend = (i32, Vec); - -/// Ceiling on the least-common-multiple denominator used to spread a -/// service weight across its endpoints. -/// -/// Gateway API allows `backendRef.weight` up to 1,000,000; without a -/// ceiling, coprime endpoint counts drive the denominator high enough to -/// overflow the weight arithmetic. -const MAX_LCM_DENOMINATOR: i64 = 1_000_000; // 1e6 - -/// Largest weight the generated data-plane config can carry. -const MAX_ENDPOINT_WEIGHT: i64 = 2_147_483_647; // i32::MAX - // ----------------------------------------------------------------------------- // GatewayClass Validation // ----------------------------------------------------------------------------- @@ -346,116 +329,6 @@ fn build_resolved_cluster(name: &str, service_data: &mut [ResolvedBackend]) -> P build_cluster(name, eps, w) } -/// Sorts endpoints within each service for deterministic config output. -/// -/// Without sorting, endpoint IPs from `EndpointSlice` listings may -/// arrive in arbitrary order across reconciliations. This changes the -/// config YAML (and its SHA-256 hash), triggering unnecessary -/// Deployment rollouts and pod restarts. -fn sort_service_endpoints(service_data: &mut [ResolvedBackend]) { - for (_, eps) in service_data.iter_mut() { - eps.sort(); - } -} - -/// Distributes service-level weights across endpoints. -/// -/// For each service with weight `W` and `N` endpoints, assigns -/// `(W * lcm) / N` to each endpoint, where `lcm` is the least -/// common multiple of all endpoint counts. The final weights are -/// reduced by their GCD to minimise the round-robin cycle length, -/// which improves distribution accuracy for small request batches. -/// -/// All arithmetic runs in `i64` and saturates. The release profile -/// combines `overflow-checks` with `panic = "abort"`, so an overflow -/// here would kill the operator rather than mis-route a request. -fn distribute_service_weights(service_data: &[ResolvedBackend]) -> (Vec, Vec) { - let lcm_denominator = endpoint_count_lcm(service_data); - let mut all_endpoints = Vec::new(); - let mut all_weights = Vec::new(); - - for (service_weight, endpoints) in service_data { - if endpoints.is_empty() { - continue; - } - - let count = endpoint_count(endpoints); - let ep_weight = i64::from(*service_weight).saturating_mul(lcm_denominator) / count; - for ep in endpoints { - all_endpoints.push(ep.clone()); - all_weights.push(ep_weight); - } - } - - reduce_weights_by_gcd(&mut all_weights); - (all_endpoints, scale_weights_into_range(&all_weights)) -} - -/// Least common multiple of every non-empty endpoint count. -fn endpoint_count_lcm(service_data: &[ResolvedBackend]) -> i64 { - service_data - .iter() - .filter(|(_, eps)| !eps.is_empty()) - .map(|(_, eps)| endpoint_count(eps)) - .fold(1, lcm) -} - -/// Returns an endpoint count as a positive `i64`. -fn endpoint_count(endpoints: &[String]) -> i64 { - i64::try_from(endpoints.len()).unwrap_or(i64::MAX).max(1) -} - -/// Divides all positive weights by their GCD to minimise cycle length. -fn reduce_weights_by_gcd(weights: &mut [i64]) { - let g = weights.iter().copied().filter(|w| *w > 0).fold(0, gcd); - if g > 1 { - for w in weights.iter_mut() { - if *w > 0 { - *w /= g; - } - } - } -} - -/// Scales weights down until each one fits the config's `i32` field. -/// -/// Positive weights stay positive so an endpoint is never silently -/// dropped from the load-balancing rotation. -fn scale_weights_into_range(weights: &[i64]) -> Vec { - let largest = weights.iter().copied().max().unwrap_or(0); - let divisor = (largest.saturating_add(MAX_ENDPOINT_WEIGHT - 1) / MAX_ENDPOINT_WEIGHT).max(1); - - weights.iter().map(|w| scale_weight(*w, divisor)).collect() -} - -/// Scales a single weight into `i32` range. -fn scale_weight(weight: i64, divisor: i64) -> i32 { - let scaled = weight / divisor; - let floored = if weight > 0 { scaled.max(1) } else { scaled }; - i32::try_from(floored).unwrap_or(i32::MAX) -} - -/// Greatest common divisor (Euclidean algorithm). -fn gcd(mut a: i64, mut b: i64) -> i64 { - while b != 0 { - let t = b; - b = a % b; - a = t; - } - a.saturating_abs() -} - -/// Least common multiple, capped at [`MAX_LCM_DENOMINATOR`]. -fn lcm(a: i64, b: i64) -> i64 { - if a == 0 || b == 0 { - return 0; - } - - (a / gcd(a, b)) - .checked_mul(b) - .map_or(MAX_LCM_DENOMINATOR, i64::saturating_abs) - .min(MAX_LCM_DENOMINATOR) -} /// Deduplicates TLS secret names from HTTPS listeners. fn collect_tls_secret_names(listeners: &[&GatewayListeners]) -> Vec { @@ -1486,191 +1359,6 @@ mod tests { assert_eq!(conds[1].status, "True", "PartiallyInvalid should be True"); } - // ----------------------------------------------------------------------------- - // Weight Distribution - // ----------------------------------------------------------------------------- - - #[test] - fn test_gcd_basics() { - assert_eq!(gcd(0, 0), 0, "gcd of zeros is zero"); - assert_eq!(gcd(12, 0), 12, "gcd with zero returns the other operand"); - assert_eq!(gcd(12, 18), 6, "gcd(12, 18) is 6"); - assert_eq!(gcd(17, 5), 1, "coprime operands have gcd 1"); - } - - #[test] - fn test_gcd_of_extremes_does_not_overflow() { - assert_eq!( - gcd(i64::MIN, 0), - i64::MAX, - "gcd must saturate rather than overflow on i64::MIN" - ); - } - - #[test] - fn test_lcm_basics() { - assert_eq!(lcm(0, 5), 0, "lcm with zero is zero"); - assert_eq!(lcm(4, 6), 12, "lcm(4, 6) is 12"); - assert_eq!(lcm(lcm(lcm(7, 11), 13), 17), 17_017, "coprime counts multiply out"); - } - - #[test] - fn test_lcm_is_capped() { - assert_eq!( - lcm(MAX_LCM_DENOMINATOR, 999_983), - MAX_LCM_DENOMINATOR, - "the denominator must never exceed its ceiling" - ); - } - - #[test] - fn test_lcm_of_large_coprimes_does_not_overflow() { - assert!( - lcm(i64::MAX, i64::MAX - 1) <= MAX_LCM_DENOMINATOR, - "an lcm that cannot be represented must fall back to the ceiling" - ); - } - - #[test] - fn test_distribute_weights_single_service_is_uniform() { - let data = [(1, endpoints(&["10.0.0.1:80", "10.0.0.2:80"]))]; - let (eps, weights) = distribute_service_weights(&data); - - assert_eq!(eps.len(), 2, "every endpoint should be emitted"); - assert_eq!(weights, vec![1, 1], "a single service splits evenly across its pods"); - } - - #[test] - fn test_distribute_weights_respects_service_ratio() { - let data = [(3, endpoints(&["10.0.0.1:80"])), (1, endpoints(&["10.0.1.1:80"]))]; - let (_, weights) = distribute_service_weights(&data); - - assert_eq!( - weights, - vec![3, 1], - "endpoint weights should mirror the backend weights" - ); - } - - #[test] - fn test_distribute_weights_normalises_uneven_replica_counts() { - let data = [ - (1, endpoints(&["10.0.0.1:80", "10.0.0.2:80"])), - (1, endpoints(&["10.0.1.1:80"])), - ]; - let (_, weights) = distribute_service_weights(&data); - - assert_eq!( - weights, - vec![1, 1, 2], - "a one-pod service must carry the same total share as a two-pod service" - ); - } - - #[test] - fn test_distribute_weights_skips_services_without_endpoints() { - let data = [(5, endpoints(&[])), (1, endpoints(&["10.0.1.1:80"]))]; - let (eps, weights) = distribute_service_weights(&data); - - assert_eq!( - eps, - vec!["10.0.1.1:80".to_owned()], - "an empty service contributes nothing" - ); - assert_eq!(weights, vec![1], "only the resolved service is weighted"); - } - - #[test] - fn test_distribute_weights_survives_adversarial_endpoint_counts() { - let data = [ - (1_000_000, endpoints(&["10.0.0.1:80"; 7])), - (1_000_000, endpoints(&["10.0.1.1:80"; 11])), - (1_000_000, endpoints(&["10.0.2.1:80"; 13])), - (1_000_000, endpoints(&["10.0.3.1:80"; 17])), - ]; - - let (eps, weights) = distribute_service_weights(&data); - - assert_eq!(eps.len(), 48, "every pod of every backend should be emitted"); - assert_eq!(weights.len(), 48, "each endpoint needs a weight"); - assert!( - weights.iter().all(|w| *w > 0), - "coprime pod counts at the maximum Gateway API weight must not zero out or abort" - ); - } - - #[test] - fn test_distribute_weights_saturates_at_the_config_ceiling() { - let data = [ - (i32::MAX, endpoints(&["10.0.0.1:80"])), - (1, endpoints(&["10.0.1.1:80"])), - ]; - - let (_, weights) = distribute_service_weights(&data); - - assert!( - weights.iter().all(|w| *w > 0), - "extreme weights must stay representable instead of overflowing" - ); - } - - #[test] - fn test_reduce_weights_by_gcd() { - let mut weights = vec![4, 8, 12]; - reduce_weights_by_gcd(&mut weights); - - assert_eq!(weights, vec![1, 2, 3], "weights should be reduced by their gcd"); - } - - #[test] - fn test_reduce_weights_ignores_zero_weights() { - let mut weights = vec![0, 4, 8]; - reduce_weights_by_gcd(&mut weights); - - assert_eq!(weights, vec![0, 1, 2], "a zero weight must stay zero"); - } - - #[test] - fn test_scale_weight_keeps_positive_weights_positive() { - assert_eq!(scale_weight(1, 1_000), 1, "a positive weight never scales to zero"); - assert_eq!(scale_weight(0, 1_000), 0, "a zero weight stays zero"); - assert_eq!(scale_weight(2_000, 1_000), 2, "scaling divides by the divisor"); - } - - #[test] - fn test_scale_weights_into_range_fits_i32() { - let weights = [i64::from(i32::MAX) * 4, i64::from(i32::MAX) * 2]; - let scaled = scale_weights_into_range(&weights); - - assert_eq!(scaled.len(), 2, "every weight should be scaled"); - assert!( - scaled.iter().all(|w| *w > 0), - "scaling must keep every endpoint in the rotation" - ); - } - - #[test] - fn test_sort_service_endpoints_is_deterministic() { - let mut data = [(1, endpoints(&["10.0.0.3:80", "10.0.0.1:80", "10.0.0.2:80"]))]; - sort_service_endpoints(&mut data); - - assert_eq!( - data[0].1, - endpoints(&["10.0.0.1:80", "10.0.0.2:80", "10.0.0.3:80"]), - "endpoint order must be stable so the config hash does not churn" - ); - } - - #[test] - fn test_endpoint_count_never_returns_zero() { - assert_eq!(endpoint_count(&[]), 1, "an empty list must not produce a zero divisor"); - assert_eq!( - endpoint_count(&endpoints(&["a", "b"])), - 2, - "count should match the list" - ); - } - // ----------------------------------------------------------------------------- // Config Hashing // ----------------------------------------------------------------------------- @@ -2079,10 +1767,6 @@ mod tests { // ----------------------------------------------------------------------------- /// Builds endpoint address strings from string slices. - fn endpoints(addrs: &[&str]) -> Vec { - addrs.iter().map(|a| (*a).to_owned()).collect() - } - /// Builds a Gateway listener with the given name, port, and protocol. fn listener(name: &str, port: i32, protocol: &str) -> GatewayListeners { GatewayListeners { diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index 6ba88ae..d567cc2 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -37,7 +37,13 @@ use crate::{ /// validation. `Accepted = True` is set by the Gateway controller /// after the data-plane Deployment rollout completes, preventing the /// conformance test from sending traffic to a stale configuration. -pub(crate) async fn reconcile(route: Arc, ctx: Arc) -> Result { +/// +/// # Errors +/// +/// Returns an error if a parent `Gateway` cannot be read or if patching +/// the route status fails. The error reaches [`error_policy`], which +/// requeues with backoff. +pub async fn reconcile(route: Arc, ctx: Arc) -> Result { let ns = route_status::route_namespace(&route); let name = route.name_any(); info!("reconciling HTTPRoute {ns}/{name}"); @@ -326,7 +332,7 @@ fn hostnames_intersect(route: &HTTPRoute, gw: &Gateway, section_name: Option<&st /// Error policy for `HTTPRoute` reconciliation failures. /// /// Logs the error and requeues after 30 seconds. -pub(crate) fn error_policy(_route: Arc, error: &OperatorError, _ctx: Arc) -> Action { +pub fn error_policy(_route: Arc, error: &OperatorError, _ctx: Arc) -> Action { error!(%error, "HTTPRoute reconciliation failed"); Action::requeue(Duration::from_secs(30)) } diff --git a/src/controller/mod.rs b/src/controller/mod.rs index bde4bac..f0f10bc 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -3,7 +3,7 @@ //! Kubernetes controllers for Gateway API resources. -pub(crate) mod gateway; -pub(crate) mod gateway_class; +pub mod gateway; +pub mod gateway_class; mod gateway_helpers; -pub(crate) mod httproute; +pub mod httproute; diff --git a/src/endpoints.rs b/src/endpoints.rs index a4b9db4..94ac9f1 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -59,7 +59,14 @@ impl TargetPort { /// /// Tries `EndpointSlice` first (supports headless and manual endpoints), /// falling back to classic Endpoints for backwards compatibility. -pub(crate) async fn resolve_endpoints( +/// +/// # Errors +/// +/// Returns an error if listing `EndpointSlices` or reading the +/// `Service` fails for any reason other than the object being absent. +/// A missing `Service` yields an empty address list, not an error, so +/// the caller can report `BackendNotFound` on the route instead. +pub async fn resolve_endpoints( client: &Client, namespace: &str, service_name: &str, diff --git a/src/error.rs b/src/error.rs index 623f80e..c1b913b 100644 --- a/src/error.rs +++ b/src/error.rs @@ -9,7 +9,7 @@ /// Errors produced during operator reconciliation. #[derive(Debug, thiserror::Error)] -pub(crate) enum OperatorError { +pub enum OperatorError { /// Kubernetes API call failed. #[error("kubernetes api: {0}")] Kube(#[from] kube::Error), @@ -40,4 +40,4 @@ pub(crate) enum OperatorError { } /// Reconciliation result alias. -pub(crate) type Result = std::result::Result; +pub type Result = std::result::Result; diff --git a/src/gateway_api/attachment.rs b/src/gateway_api/attachment.rs index 5cdf62d..7ce5c61 100644 --- a/src/gateway_api/attachment.rs +++ b/src/gateway_api/attachment.rs @@ -15,12 +15,12 @@ use gateway_api::httproutes::{HTTPRoute, HttpRouteParentRefs}; /// carries one entry per matching `parentRef`. A `None` entry means that /// ref named no `sectionName` and therefore targets every listener. #[derive(Debug, Clone, PartialEq)] -pub(crate) struct AttachedRoute<'a> { +pub struct AttachedRoute<'a> { /// The route itself. - pub(crate) route: &'a HTTPRoute, + pub route: &'a HTTPRoute, /// Listener section names this route targets. - pub(crate) section_names: Vec>, + pub section_names: Vec>, } impl AttachedRoute<'_> { @@ -28,7 +28,7 @@ impl AttachedRoute<'_> { /// /// A ref without a `sectionName` targets every listener, so it /// matches whatever name is asked about. - pub(crate) fn targets_listener(&self, listener: &str) -> bool { + pub fn targets_listener(&self, listener: &str) -> bool { self.section_names .iter() .any(|section| section.as_deref().is_none_or(|name| name == listener)) @@ -44,7 +44,7 @@ impl AttachedRoute<'_> { /// The Gateway API spec defines defaults for `group` (`gateway.networking.k8s.io`), /// `kind` (`Gateway`), and `namespace` (route's namespace). All fields must match /// the target gateway to be considered attached. -pub(crate) fn parent_ref_matches_gateway( +pub fn parent_ref_matches_gateway( parent: &HttpRouteParentRefs, gateway_name: &str, gateway_ns: &str, @@ -61,11 +61,7 @@ pub(crate) fn parent_ref_matches_gateway( /// /// Each tuple contains a route and a vector of section names (one per matching /// parentRef). A `None` section name means the route attaches to all listeners. -pub(crate) fn attached_routes<'a>( - gateway_name: &str, - gateway_ns: &str, - routes: &'a [HTTPRoute], -) -> Vec> { +pub fn attached_routes<'a>(gateway_name: &str, gateway_ns: &str, routes: &'a [HTTPRoute]) -> Vec> { let mut result = Vec::new(); for route in routes { diff --git a/src/gateway_api/conditions.rs b/src/gateway_api/conditions.rs index 443b4ab..bd51485 100644 --- a/src/gateway_api/conditions.rs +++ b/src/gateway_api/conditions.rs @@ -13,7 +13,7 @@ use k8s_openapi::apimachinery::pkg::apis::meta::v1::{Condition, Time}; /// observedGeneration. /// /// Sets `last_transition_time` to the current UTC timestamp. -pub(crate) fn make_condition(type_: &str, status: &str, reason: &str, message: &str, generation: i64) -> Condition { +pub fn make_condition(type_: &str, status: &str, reason: &str, message: &str, generation: i64) -> Condition { Condition { last_transition_time: Time(k8s_openapi::jiff::Timestamp::now()), message: message.to_owned(), @@ -25,37 +25,37 @@ pub(crate) fn make_condition(type_: &str, status: &str, reason: &str, message: & } /// Returns an Accepted: True condition. -pub(crate) fn accepted(generation: i64, message: &str) -> Condition { +pub fn accepted(generation: i64, message: &str) -> Condition { make_condition("Accepted", "True", "Accepted", message, generation) } /// Returns an `Accepted: False` condition. -pub(crate) fn not_accepted(generation: i64, reason: &str, message: &str) -> Condition { +pub fn not_accepted(generation: i64, reason: &str, message: &str) -> Condition { make_condition("Accepted", "False", reason, message, generation) } /// Returns a `Programmed: True` condition. -pub(crate) fn programmed(generation: i64, message: &str) -> Condition { +pub fn programmed(generation: i64, message: &str) -> Condition { make_condition("Programmed", "True", "Programmed", message, generation) } /// Returns a `Programmed: False` condition. -pub(crate) fn not_programmed(generation: i64, reason: &str, message: &str) -> Condition { +pub fn not_programmed(generation: i64, reason: &str, message: &str) -> Condition { make_condition("Programmed", "False", reason, message, generation) } /// Returns a `ResolvedRefs: True` condition. -pub(crate) fn resolved_refs(generation: i64, message: &str) -> Condition { +pub fn resolved_refs(generation: i64, message: &str) -> Condition { make_condition("ResolvedRefs", "True", "ResolvedRefs", message, generation) } /// Returns a `ResolvedRefs: False` condition. -pub(crate) fn unresolved_refs(generation: i64, reason: &str, message: &str) -> Condition { +pub fn unresolved_refs(generation: i64, reason: &str, message: &str) -> Condition { make_condition("ResolvedRefs", "False", reason, message, generation) } /// Returns a Conflicted: False condition indicating no conflicts. -pub(crate) fn no_conflicts(generation: i64) -> Condition { +pub fn no_conflicts(generation: i64) -> Condition { make_condition( "Conflicted", "False", @@ -69,12 +69,12 @@ pub(crate) fn no_conflicts(generation: i64) -> Condition { /// /// Signals that the route was accepted but some of its rules were /// dropped because this operator cannot express them. -pub(crate) fn partially_invalid(generation: i64, message: &str) -> Condition { +pub fn partially_invalid(generation: i64, message: &str) -> Condition { make_condition("PartiallyInvalid", "True", "UnsupportedValue", message, generation) } /// Returns a `Conflicted: True` condition. -pub(crate) fn conflicted(generation: i64, reason: &str, message: &str) -> Condition { +pub fn conflicted(generation: i64, reason: &str, message: &str) -> Condition { make_condition("Conflicted", "True", reason, message, generation) } diff --git a/src/gateway_api/hostname.rs b/src/gateway_api/hostname.rs index 9ee9b68..28b733d 100644 --- a/src/gateway_api/hostname.rs +++ b/src/gateway_api/hostname.rs @@ -17,7 +17,20 @@ use std::collections::BTreeSet; /// /// Comparison is ASCII case-insensitive: DNS names are case-insensitive /// per RFC 1123 section 2.1 and RFC 4343 section 1. -pub(crate) fn hostname_matches(route_host: &str, listener_host: &str) -> bool { +/// +/// ``` +/// use praxis_operator::gateway_api::hostname::hostname_matches; +/// +/// assert!(hostname_matches("foo.example.com", "*.example.com")); +/// assert!(hostname_matches("Foo.Example.com", "*.example.com")); +/// +/// // A bare domain does not match its own wildcard. +/// assert!(!hostname_matches("example.com", "*.example.com")); +/// +/// // The separator dot is load-bearing. +/// assert!(!hostname_matches("fooexample.com", "*.example.com")); +/// ``` +pub fn hostname_matches(route_host: &str, listener_host: &str) -> bool { if route_host.eq_ignore_ascii_case(listener_host) { return true; } @@ -56,10 +69,20 @@ fn wildcard_covers(wildcard: &str, candidate: &str) -> bool { /// or `None` if the hostnames do not intersect. When a wildcard matches /// an exact hostname, the exact hostname is returned. /// -/// A route hostname of `foo.example.com` on a `*.example.com` listener -/// intersects to `foo.example.com`; `bar.other.com` on the same listener -/// yields `None`. See `test_intersection_route_exact_listener_wildcard`. -pub(crate) fn hostname_intersection(route_host: &str, listener_host: &str) -> Option { +/// ``` +/// use praxis_operator::gateway_api::hostname::hostname_intersection; +/// +/// // The more specific side wins. +/// assert_eq!( +/// hostname_intersection("foo.example.com", "*.example.com"), +/// Some("foo.example.com".to_owned()), +/// ); +/// assert_eq!( +/// hostname_intersection("bar.other.com", "*.example.com"), +/// None +/// ); +/// ``` +pub fn hostname_intersection(route_host: &str, listener_host: &str) -> Option { if route_host.eq_ignore_ascii_case(listener_host) { return Some(route_host.to_owned()); } @@ -82,8 +105,23 @@ pub(crate) fn hostname_intersection(route_host: &str, listener_host: &str) -> Op /// constraints), all route hostnames pass through unchanged. /// /// Results keep route-hostname order, which the generated config depends -/// on. See `test_intersect_filters_non_matching`. -pub(crate) fn intersect_hostnames(route_hostnames: &[String], listener_hostnames: &[Option]) -> Vec { +/// on. +/// +/// ``` +/// use praxis_operator::gateway_api::hostname::intersect_hostnames; +/// +/// let routes = ["a.example.com".to_owned(), "nope.other.com".to_owned()]; +/// let listeners = [Some("*.example.com".to_owned())]; +/// +/// assert_eq!( +/// intersect_hostnames(&routes, &listeners), +/// vec!["a.example.com"] +/// ); +/// +/// // An unconstrained listener passes everything through. +/// assert_eq!(intersect_hostnames(&routes, &[None]), routes.to_vec()); +/// ``` +pub fn intersect_hostnames(route_hostnames: &[String], listener_hostnames: &[Option]) -> Vec { let constrained: Vec<_> = listener_hostnames.iter().filter_map(|h| h.as_deref()).collect(); if constrained.is_empty() || constrained.len() < listener_hostnames.len() { return route_hostnames.to_vec(); diff --git a/src/gateway_api/listener_conflict.rs b/src/gateway_api/listener_conflict.rs index 0850a0e..9367287 100644 --- a/src/gateway_api/listener_conflict.rs +++ b/src/gateway_api/listener_conflict.rs @@ -22,7 +22,7 @@ use gateway_api::gateways::GatewayListeners; /// The variants map onto the Gateway API `ListenerConditionReason` /// values of the same name. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ConflictReason { +pub enum ConflictReason { /// Another listener claims the same port with a different protocol. ProtocolConflict, @@ -32,7 +32,7 @@ pub(crate) enum ConflictReason { impl ConflictReason { /// Returns the Gateway API condition reason string. - pub(crate) fn as_str(self) -> &'static str { + pub fn as_str(self) -> &'static str { match self { Self::ProtocolConflict => "ProtocolConflict", Self::HostnameConflict => "HostnameConflict", @@ -40,7 +40,7 @@ impl ConflictReason { } /// Returns the message to report on the conflicting conditions. - pub(crate) fn message(self) -> &'static str { + pub fn message(self) -> &'static str { match self { Self::ProtocolConflict => { "listener conflicts with another listener on the same port using a \ @@ -60,7 +60,7 @@ impl ConflictReason { /// Returns a map from listener name to the reason it conflicts. Both /// sides of a conflict are reported: neither can be programmed, so /// neither may claim the port. -pub(crate) fn detect_conflicts(listeners: &[GatewayListeners]) -> HashMap { +pub fn detect_conflicts(listeners: &[GatewayListeners]) -> HashMap { let mut conflicts = HashMap::new(); for group in group_by_port(listeners).values() { diff --git a/src/gateway_api/mod.rs b/src/gateway_api/mod.rs index c795e38..a874069 100644 --- a/src/gateway_api/mod.rs +++ b/src/gateway_api/mod.rs @@ -3,12 +3,12 @@ //! Gateway API helpers: conditions, attachment, reference grants, listener validation. -pub(crate) mod attachment; -pub(crate) mod conditions; -pub(crate) mod hostname; -pub(crate) mod listener_conflict; -pub(crate) mod protocol; -pub(crate) mod reference_grant; -pub(crate) mod route_status; -pub(crate) mod route_validation; -pub(crate) mod status; +pub mod attachment; +pub mod conditions; +pub mod hostname; +pub mod listener_conflict; +pub mod protocol; +pub mod reference_grant; +pub mod route_status; +pub mod route_validation; +pub mod status; diff --git a/src/gateway_api/protocol.rs b/src/gateway_api/protocol.rs index e9b414c..315b0a4 100644 --- a/src/gateway_api/protocol.rs +++ b/src/gateway_api/protocol.rs @@ -14,7 +14,7 @@ /// A listener protocol this operator recognises. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum ListenerProtocol { +pub enum ListenerProtocol { /// Cleartext HTTP. Http, @@ -28,7 +28,7 @@ impl ListenerProtocol { /// Returns `None` for protocols this operator does not serve, which /// the caller reports as `UnsupportedProtocol` rather than silently /// ignoring. - pub(crate) fn parse(protocol: &str) -> Option { + pub fn parse(protocol: &str) -> Option { match protocol { "HTTP" => Some(Self::Http), "HTTPS" => Some(Self::Https), @@ -37,12 +37,22 @@ impl ListenerProtocol { } /// Returns whether this operator can serve `protocol`. - pub(crate) fn is_supported(protocol: &str) -> bool { + /// + /// ``` + /// use praxis_operator::gateway_api::protocol::ListenerProtocol; + /// + /// assert!(ListenerProtocol::is_supported("HTTPS")); + /// assert!(!ListenerProtocol::is_supported("TCP")); + /// + /// // The Gateway API spells protocols in upper case. + /// assert!(!ListenerProtocol::is_supported("https")); + /// ``` + pub fn is_supported(protocol: &str) -> bool { Self::parse(protocol).is_some() } /// Returns whether `protocol` terminates TLS. - pub(crate) fn terminates_tls(protocol: &str) -> bool { + pub fn terminates_tls(protocol: &str) -> bool { Self::parse(protocol) == Some(Self::Https) } } diff --git a/src/gateway_api/reference_grant.rs b/src/gateway_api/reference_grant.rs index 98068db..02247ae 100644 --- a/src/gateway_api/reference_grant.rs +++ b/src/gateway_api/reference_grant.rs @@ -19,7 +19,34 @@ use gateway_api::referencegrants::ReferenceGrant; clippy::too_many_lines, reason = "params map 1:1 to Gateway API fields" )] -pub(crate) fn is_reference_allowed( +/// ``` +/// use praxis_operator::gateway_api::reference_grant::is_reference_allowed; +/// +/// // Same-namespace references never need a grant. +/// assert!(is_reference_allowed( +/// "app", +/// "gateway.networking.k8s.io", +/// "HTTPRoute", +/// "app", +/// "", +/// "Service", +/// Some("backend"), +/// &[], +/// )); +/// +/// // Crossing a namespace without one is denied. +/// assert!(!is_reference_allowed( +/// "app", +/// "gateway.networking.k8s.io", +/// "HTTPRoute", +/// "other", +/// "", +/// "Service", +/// Some("backend"), +/// &[], +/// )); +/// ``` +pub fn is_reference_allowed( from_ns: &str, from_group: &str, from_kind: &str, diff --git a/src/gateway_api/route_status.rs b/src/gateway_api/route_status.rs index 60bdfaa..0e47b7c 100644 --- a/src/gateway_api/route_status.rs +++ b/src/gateway_api/route_status.rs @@ -45,24 +45,19 @@ const DEFAULT_NAMESPACE: &str = "default"; // ----------------------------------------------------------------------------- /// Returns the namespace of an [`HTTPRoute`], defaulting to `"default"`. -pub(crate) fn route_namespace(route: &HTTPRoute) -> &str { +pub fn route_namespace(route: &HTTPRoute) -> &str { route.metadata.namespace.as_deref().unwrap_or(DEFAULT_NAMESPACE) } /// Returns `true` when a `parentRef` targets a `Gateway` resource. -pub(crate) fn is_gateway_parent_ref(parent_ref: &HttpRouteParentRefs) -> bool { +pub fn is_gateway_parent_ref(parent_ref: &HttpRouteParentRefs) -> bool { let group = parent_ref.group.as_deref().unwrap_or(GATEWAY_GROUP); let kind = parent_ref.kind.as_deref().unwrap_or("Gateway"); group == GATEWAY_GROUP && kind == "Gateway" } /// Returns `true` when `parent_ref` targets the named Gateway. -pub(crate) fn is_ref_targeting_gateway( - parent_ref: &HttpRouteParentRefs, - gw_name: &str, - gw_ns: &str, - route_ns: &str, -) -> bool { +pub fn is_ref_targeting_gateway(parent_ref: &HttpRouteParentRefs, gw_name: &str, gw_ns: &str, route_ns: &str) -> bool { if !is_gateway_parent_ref(parent_ref) { return false; } @@ -77,7 +72,7 @@ pub(crate) fn is_ref_targeting_gateway( /// Reason a backend ref could not be resolved. #[derive(Debug, PartialEq, Eq)] -pub(crate) enum ResolveFailure { +pub enum ResolveFailure { /// Unsupported group or kind. InvalidKind, @@ -89,10 +84,17 @@ pub(crate) enum ResolveFailure { } /// Outcome of checking every backend ref in a route. -pub(crate) type ResolveResult = std::result::Result<(), ResolveFailure>; +pub type ResolveResult = std::result::Result<(), ResolveFailure>; /// Checks all backend refs in a route for validity. -pub(crate) async fn check_backend_refs( +/// +/// # Errors +/// +/// Returns the first [`ResolveFailure`] encountered: an unsupported +/// backend kind, a cross-namespace ref no [`ReferenceGrant`] permits, +/// or a `Service` that does not exist. The failure maps directly to +/// the `ResolvedRefs` reason reported on the route. +pub async fn check_backend_refs( route: &HTTPRoute, route_ns: &str, client: &kube::Client, @@ -114,7 +116,7 @@ pub(crate) async fn check_backend_refs( } /// Builds the `ResolvedRefs` condition from a resolution outcome. -pub(crate) fn resolved_refs_condition(result: &ResolveResult, generation: i64) -> Condition { +pub fn resolved_refs_condition(result: &ResolveResult, generation: i64) -> Condition { match result { Ok(()) => conditions::resolved_refs(generation, "all backend refs resolved"), Err(ResolveFailure::InvalidKind) => { @@ -132,7 +134,12 @@ pub(crate) fn resolved_refs_condition(result: &ResolveResult, generation: i64) - } /// Rejects backend refs that are not `core/Service`. -pub(crate) fn validate_backend_kind(backend: &HttpRouteRulesBackendRefs) -> ResolveResult { +/// +/// # Errors +/// +/// Returns [`ResolveFailure::InvalidKind`] when the ref names any +/// group other than core, or any kind other than `Service`. +pub fn validate_backend_kind(backend: &HttpRouteRulesBackendRefs) -> ResolveResult { let group = backend.group.as_deref().unwrap_or(""); let kind = backend.kind.as_deref().unwrap_or("Service"); if !group.is_empty() || kind != "Service" { @@ -143,7 +150,13 @@ pub(crate) fn validate_backend_kind(backend: &HttpRouteRulesBackendRefs) -> Reso } /// Rejects cross-namespace refs not covered by a [`ReferenceGrant`]. -pub(crate) fn validate_cross_namespace( +/// +/// # Errors +/// +/// Returns [`ResolveFailure::RefNotPermitted`] when the backend lives +/// in another namespace and no grant in that namespace allows the +/// reference. Same-namespace refs never fail. +pub fn validate_cross_namespace( backend: &HttpRouteRulesBackendRefs, route_ns: &str, grants: &[ReferenceGrant], @@ -179,7 +192,7 @@ pub(crate) fn validate_cross_namespace( // ----------------------------------------------------------------------------- /// Builds the `status.parents` entry for a single `parentRef`. -pub(crate) fn parent_status_json( +pub fn parent_status_json( parent_ref: &HttpRouteParentRefs, gw_ns: &str, accepted: &Condition, @@ -192,11 +205,7 @@ pub(crate) fn parent_status_json( /// /// Used when a route carries more than the usual `Accepted` and /// `ResolvedRefs` pair, such as a `PartiallyInvalid` route. -pub(crate) fn parent_status_with_conditions( - parent_ref: &HttpRouteParentRefs, - gw_ns: &str, - conditions: &[Condition], -) -> Value { +pub fn parent_status_with_conditions(parent_ref: &HttpRouteParentRefs, gw_ns: &str, conditions: &[Condition]) -> Value { let mut ref_json = json!({ "group": GATEWAY_GROUP, "kind": "Gateway", @@ -224,7 +233,13 @@ pub(crate) fn parent_status_with_conditions( /// for every other parent — including those written by the other /// controller — are preserved in place, so the two writers no longer /// overwrite each other's list. -pub(crate) async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRoute, computed: &[Value]) -> Result<()> { +/// +/// # Errors +/// +/// Returns an error if the live status cannot be deserialized or if +/// the status patch is rejected. When the merged status equals what is +/// already stored no patch is sent, so an unchanged route cannot fail. +pub async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRoute, computed: &[Value]) -> Result<()> { let ns = route_namespace(route); let name = route.name_any(); @@ -263,12 +278,13 @@ pub(crate) async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRou /// ever revisits the entry because the Gateway that owned it is gone. /// Entries written by other controllers, and entries for other parents, /// are left untouched. -pub(crate) async fn clear_parent_statuses( - client: &kube::Client, - route: &HTTPRoute, - gw_name: &str, - gw_ns: &str, -) -> Result<()> { +/// +/// # Errors +/// +/// Returns an error if the live status cannot be deserialized or if +/// the status patch is rejected. When no entry names the Gateway no +/// patch is sent, so the call is a no-op that cannot fail. +pub async fn clear_parent_statuses(client: &kube::Client, route: &HTTPRoute, gw_name: &str, gw_ns: &str) -> Result<()> { let ns = route_namespace(route); let name = route.name_any(); diff --git a/src/gateway_api/route_validation.rs b/src/gateway_api/route_validation.rs index faf543f..6ee8c29 100644 --- a/src/gateway_api/route_validation.rs +++ b/src/gateway_api/route_validation.rs @@ -31,7 +31,7 @@ use gateway_api::httproutes::{ /// Why a single `HTTPRoute` rule cannot be honoured. #[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum RuleRejection { +pub enum RuleRejection { /// A match or filter used a regular expression. RegularExpression(&'static str), @@ -44,7 +44,7 @@ pub(crate) enum RuleRejection { impl RuleRejection { /// Returns a human-readable explanation for a status message. - pub(crate) fn message(&self) -> String { + pub fn message(&self) -> String { match self { Self::RegularExpression(field) => { format!("RegularExpression {field} matching is not supported") @@ -63,7 +63,7 @@ impl RuleRejection { /// The rules of one `HTTPRoute` that cannot be honoured, keyed by index. #[derive(Debug, Clone, Default, PartialEq, Eq)] -pub(crate) struct RouteValidation { +pub struct RouteValidation { /// Rejected rule indices and the reason for each. rejected: BTreeMap, @@ -74,7 +74,7 @@ pub(crate) struct RouteValidation { impl RouteValidation { /// Returns `true` when the rule at `index` must be excluded from the /// generated config. - pub(crate) fn is_rejected(&self, index: usize) -> bool { + pub fn is_rejected(&self, index: usize) -> bool { self.rejected.contains_key(&index) } @@ -82,19 +82,19 @@ impl RouteValidation { /// /// A route declaring no rules at all is not fully rejected; it simply /// contributes nothing. - pub(crate) fn is_fully_rejected(&self) -> bool { + pub fn is_fully_rejected(&self) -> bool { self.total > 0 && self.rejected.len() == self.total } /// Returns `true` when some, but not all, rules were rejected. - pub(crate) fn is_partially_rejected(&self) -> bool { + pub fn is_partially_rejected(&self) -> bool { !self.rejected.is_empty() && !self.is_fully_rejected() } /// Returns a status message naming the first rejection. /// /// Returns `None` when every rule is supported. - pub(crate) fn message(&self) -> Option { + pub fn message(&self) -> Option { let (index, rejection) = self.rejected.iter().next()?; Some(format!("rule {index}: {}", rejection.message())) } @@ -105,7 +105,7 @@ impl RouteValidation { // ----------------------------------------------------------------------------- /// Finds every rule of `route` that this operator cannot honour. -pub(crate) fn validate_route(route: &HTTPRoute) -> RouteValidation { +pub fn validate_route(route: &HTTPRoute) -> RouteValidation { let rules = route.spec.rules.as_deref().unwrap_or(&[]); let rejected = rules diff --git a/src/gateway_api/status.rs b/src/gateway_api/status.rs index 0c57cd5..ad746e7 100644 --- a/src/gateway_api/status.rs +++ b/src/gateway_api/status.rs @@ -33,7 +33,7 @@ const TRANSITION_TIME_KEY: &str = "lastTransitionTime"; /// Without this step every reconcile produces a document that differs /// only by timestamp, so each write re-triggers the controller's own /// watch and the loop never settles. -pub(crate) fn preserve_condition_times(desired: &mut Value, observed: &Value) { +pub fn preserve_condition_times(desired: &mut Value, observed: &Value) { if let (Some(desired), Some(observed)) = (desired.as_object_mut(), observed.as_object()) { preserve_in_object(desired, observed); return; @@ -52,7 +52,7 @@ pub(crate) fn preserve_condition_times(desired: &mut Value, observed: &Value) { /// patch. Fields the operator does not set are ignored at every depth, /// and an absent field counts as matching when the desired value is an /// empty list, which the API server may store by omission. -pub(crate) fn is_status_unchanged(desired: &Value, observed: &Value) -> bool { +pub fn is_status_unchanged(desired: &Value, observed: &Value) -> bool { if let (Some(desired), Some(observed)) = (desired.as_object(), observed.as_object()) { return desired.iter().all(|(key, value)| field_unchanged(observed, key, value)); } diff --git a/src/leader.rs b/src/leader.rs index 2d047fd..2773f63 100644 --- a/src/leader.rs +++ b/src/leader.rs @@ -54,7 +54,7 @@ const FIELD_MANAGER: &str = "praxis-operator"; /// Prefers the pod name supplied by the downward API so the holder is /// identifiable with `kubectl get lease`; falls back to the hostname, /// then to the process id. -pub(crate) fn identity() -> String { +pub fn identity() -> String { std::env::var("POD_NAME") .ok() .or_else(|| std::env::var("HOSTNAME").ok()) @@ -80,7 +80,7 @@ fn lease_namespace() -> String { /// /// Returns an error only when the API rejects a lease write for a reason /// other than another replica holding it; contention is retried. -pub(crate) async fn acquire(client: &Client, identity: &str) -> Result<()> { +pub async fn acquire(client: &Client, identity: &str) -> Result<()> { let api: Api = Api::namespaced(client.clone(), &lease_namespace()); loop { @@ -102,7 +102,7 @@ pub(crate) async fn acquire(client: &Client, identity: &str) -> Result<()> { /// the lease. The caller is expected to stop reconciling and exit so the /// Deployment restarts it as a follower, which is simpler to reason /// about than resuming mid-flight. -pub(crate) async fn renew_until_lost(client: &Client, identity: &str) -> Result<()> { +pub async fn renew_until_lost(client: &Client, identity: &str) -> Result<()> { let api: Api = Api::namespaced(client.clone(), &lease_namespace()); loop { diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..55434f6 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,241 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Praxis Gateway API operator. +//! +//! Reconciles Gateway API resources into Praxis proxy deployments. The +//! crate is a library so its helpers can carry doctests and be +//! benchmarked; `main` is a thin shim over [`run`]. + +#![deny(unsafe_code)] + +pub mod config; +pub mod context; +pub mod controller; +pub mod endpoints; +pub mod error; +pub mod gateway_api; +pub mod leader; +pub mod listing; +pub mod observability; +pub mod resources; + +use std::{future::Future, sync::Arc}; + +use ::gateway_api::{ + gatewayclasses::GatewayClass, gateways::Gateway, httproutes::HTTPRoute, referencegrants::ReferenceGrant, +}; +use futures::StreamExt as _; +use k8s_openapi::api::{ + apps::v1::Deployment, + core::v1::{ConfigMap, Service}, + policy::v1::PodDisruptionBudget, +}; +use kube::{ + Api, Client, + runtime::{controller::Controller, watcher}, +}; +use tracing::info; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Default tracing directive for the operator crate. +const DEFAULT_DIRECTIVE: &str = "praxis_operator=info"; + +/// Label selector restricting owned-resource watches to operator-managed +/// objects. +const MANAGED_BY_SELECTOR: &str = "app.kubernetes.io/managed-by=praxis-operator"; + +// ----------------------------------------------------------------------------- +// Entry Point +// ----------------------------------------------------------------------------- + +/// Entry point: wires and runs `GatewayClass`, `Gateway`, and `HTTPRoute` +/// controllers. +/// Runs the operator until a controller exits or leadership is lost. +/// +/// # Errors +/// +/// Returns [`OperatorError::LeadershipLost`] when another replica takes +/// the lease, and any error from connecting to the cluster. +/// +/// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost +pub async fn run() -> error::Result<()> { + tracing_subscriber::fmt().with_env_filter(env_filter()).json().init(); + + info!("starting praxis-operator"); + let client = Client::try_default().await?; + info!("connected to cluster, controller={}", context::CONTROLLER_NAME); + + let health = Arc::new(observability::server::Health::default()); + let observability = tokio::spawn(observability::server::serve(Arc::clone(&health))); + + // Readiness reflects process health, not leadership. A standby is + // healthy and must report ready, or a rolling update never completes: + // the Deployment waits for every replica, and a replica that only + // turns ready on winning the lease can never satisfy it. + health.mark_ready(); + + let identity = leader::identity(); + info!("standing for election as {identity}"); + leader::acquire(&client, &identity).await?; + observability::metrics::global().set_leader(true); + + let result = Box::pin(run_controllers(&client, &identity)).await; + + observability.abort(); + result +} + +/// Runs every controller until one exits or leadership is lost. +/// +/// # Errors +/// +/// Returns [`OperatorError::LeadershipLost`] when another replica takes +/// the lease, so the process exits non-zero and restarts as a follower. +/// +/// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost +async fn run_controllers(client: &Client, identity: &str) -> error::Result<()> { + let ctx = Arc::new(context::Context { + client: client.clone(), + recorder: kube::runtime::events::Recorder::new(client.clone(), context::reporter()), + }); + let gc = build_gc_controller(client, Arc::clone(&ctx)); + let gw = build_gw_controller(client, Arc::clone(&ctx)); + let rt = build_route_controller(client, ctx); + + info!("starting controllers"); + + tokio::select! { + () = async { tokio::join!(gc, gw, rt); } => Ok(()), + outcome = leader::renew_until_lost(client, identity) => outcome, + } +} + +// ----------------------------------------------------------------------------- +// Controller Builders +// ----------------------------------------------------------------------------- + +/// Builds the tracing filter from `RUST_LOG`, adding the crate default. +/// +/// A malformed default directive degrades to the environment filter alone +/// rather than aborting startup. +fn env_filter() -> tracing_subscriber::EnvFilter { + let filter = tracing_subscriber::EnvFilter::from_default_env(); + match DEFAULT_DIRECTIVE.parse() { + Ok(directive) => filter.add_directive(directive), + Err(_) => filter, + } +} + +/// Wires the `GatewayClass` controller. +/// +/// Watches all `GatewayClass` resources and reconciles their `Accepted` +/// status. +fn build_gc_controller(client: &Client, ctx: Arc) -> impl Future { + Controller::new(Api::::all(client.clone()), watcher::Config::default()) + .shutdown_on_signal() + .run( + controller::gateway_class::reconcile, + controller::gateway_class::error_policy, + ctx, + ) + .for_each(|res| async move { + match res { + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::GatewayClass); + info!("reconciled GatewayClass {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::GatewayClass); + tracing::warn!("GatewayClass reconcile error: {e:?}"); + }, + } + }) +} + +/// Wires the `Gateway` controller with owned-resource watches. +/// +/// Watches `Gateway` resources and their owned `Deployment`, `ConfigMap`, +/// and `Service` children, `HTTPRoute` cross-references, and +/// `ReferenceGrant` changes. Child watches carry the managed-by label +/// selector so the operator never deserializes unrelated cluster objects. +fn build_gw_controller(client: &Client, ctx: Arc) -> impl Future { + let controller = Controller::new(Api::::all(client.clone()), watcher::Config::default()); + let gateways = controller.store(); + + with_gateway_watches(controller, client, gateways) + .shutdown_on_signal() + .run(controller::gateway::reconcile, controller::gateway::error_policy, ctx) + .for_each(|res| async move { + match res { + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::Gateway); + info!("reconciled Gateway {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::Gateway); + tracing::warn!("Gateway reconcile error: {e:?}"); + }, + } + }) +} + +/// Registers the owned children and cross-references a Gateway depends +/// on. +/// +/// Child watches carry the managed-by selector so the operator never +/// deserializes unrelated cluster objects. +fn with_gateway_watches( + controller: Controller, + client: &Client, + gateways: kube::runtime::reflector::Store, +) -> Controller { + controller + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .owns(Api::::all(client.clone()), managed_children()) + .watches( + Api::::all(client.clone()), + watcher::Config::default(), + |route| controller::gateway::map_route_to_gateway(&route), + ) + .watches( + Api::::all(client.clone()), + watcher::Config::default(), + move |grant| controller::gateway::map_grant_to_gateways(&grant, &gateways.state()), + ) +} + +/// Watcher config scoped to the child resources this operator manages. +fn managed_children() -> watcher::Config { + watcher::Config::default().labels(MANAGED_BY_SELECTOR) +} + +/// Wires the `HTTPRoute` controller. +/// +/// Watches all `HTTPRoute` resources and reconciles parent status entries. +fn build_route_controller(client: &Client, ctx: Arc) -> impl Future { + Controller::new(Api::::all(client.clone()), watcher::Config::default()) + .shutdown_on_signal() + .run( + controller::httproute::reconcile, + controller::httproute::error_policy, + ctx, + ) + .for_each(|res| async move { + match res { + Ok((obj, _action)) => { + observability::metrics::global().record_reconcile(observability::metrics::Controller::HttpRoute); + info!("reconciled HTTPRoute {obj}"); + }, + Err(e) => { + observability::metrics::global().record_error(observability::metrics::Controller::HttpRoute); + tracing::warn!("HTTPRoute reconcile error: {e:?}"); + }, + } + }) +} diff --git a/src/listing.rs b/src/listing.rs index 6871e6c..8e205df 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -32,7 +32,7 @@ const PAGE_SIZE: u32 = 500; /// Returns an error if any page request fails. A partial listing is /// never returned: a caller acting on half a cluster's routes would /// generate a config that silently drops the rest. -pub(crate) async fn list_all(api: &Api) -> Result> +pub async fn list_all(api: &Api) -> Result> where K: Clone + std::fmt::Debug + DeserializeOwned, { diff --git a/src/main.rs b/src/main.rs index c25116d..c6a59f5 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,230 +1,16 @@ // SPDX-License-Identifier: MIT // Copyright (c) 2026 Shane Utt -//! Praxis Gateway API operator. +//! Binary entry point for the Praxis Gateway API operator. #![deny(unsafe_code)] -mod config; -mod context; -mod controller; -mod endpoints; -mod error; -mod gateway_api; -mod leader; -mod listing; -mod observability; -mod resources; - -use std::{future::Future, sync::Arc}; - -use ::gateway_api::{ - gatewayclasses::GatewayClass, gateways::Gateway, httproutes::HTTPRoute, referencegrants::ReferenceGrant, -}; -use futures::StreamExt as _; -use k8s_openapi::api::{ - apps::v1::Deployment, - core::v1::{ConfigMap, Service}, - policy::v1::PodDisruptionBudget, -}; -use kube::{ - Api, Client, - runtime::{controller::Controller, watcher}, -}; -use tracing::info; - -// ----------------------------------------------------------------------------- -// Constants -// ----------------------------------------------------------------------------- - -/// Default tracing directive for the operator crate. -const DEFAULT_DIRECTIVE: &str = "praxis_operator=info"; - -/// Label selector restricting owned-resource watches to operator-managed -/// objects. -const MANAGED_BY_SELECTOR: &str = "app.kubernetes.io/managed-by=praxis-operator"; - -// ----------------------------------------------------------------------------- -// Entry Point -// ----------------------------------------------------------------------------- - -/// Entry point: wires and runs `GatewayClass`, `Gateway`, and `HTTPRoute` -/// controllers. -#[tokio::main] -async fn main() -> error::Result<()> { - tracing_subscriber::fmt().with_env_filter(env_filter()).json().init(); - - info!("starting praxis-operator"); - let client = Client::try_default().await?; - info!("connected to cluster, controller={}", context::CONTROLLER_NAME); - - let health = Arc::new(observability::server::Health::default()); - let observability = tokio::spawn(observability::server::serve(Arc::clone(&health))); - - // Readiness reflects process health, not leadership. A standby is - // healthy and must report ready, or a rolling update never completes: - // the Deployment waits for every replica, and a replica that only - // turns ready on winning the lease can never satisfy it. - health.mark_ready(); - - let identity = leader::identity(); - info!("standing for election as {identity}"); - leader::acquire(&client, &identity).await?; - observability::metrics::global().set_leader(true); - - let result = Box::pin(run_controllers(&client, &identity)).await; - - observability.abort(); - result -} - -/// Runs every controller until one exits or leadership is lost. +/// Starts the operator. /// /// # Errors /// -/// Returns [`OperatorError::LeadershipLost`] when another replica takes -/// the lease, so the process exits non-zero and restarts as a follower. -/// -/// [`OperatorError::LeadershipLost`]: error::OperatorError::LeadershipLost -async fn run_controllers(client: &Client, identity: &str) -> error::Result<()> { - let ctx = Arc::new(context::Context { - client: client.clone(), - recorder: kube::runtime::events::Recorder::new(client.clone(), context::reporter()), - }); - let gc = build_gc_controller(client, Arc::clone(&ctx)); - let gw = build_gw_controller(client, Arc::clone(&ctx)); - let rt = build_route_controller(client, ctx); - - info!("starting controllers"); - - tokio::select! { - () = async { tokio::join!(gc, gw, rt); } => Ok(()), - outcome = leader::renew_until_lost(client, identity) => outcome, - } -} - -// ----------------------------------------------------------------------------- -// Controller Builders -// ----------------------------------------------------------------------------- - -/// Builds the tracing filter from `RUST_LOG`, adding the crate default. -/// -/// A malformed default directive degrades to the environment filter alone -/// rather than aborting startup. -fn env_filter() -> tracing_subscriber::EnvFilter { - let filter = tracing_subscriber::EnvFilter::from_default_env(); - match DEFAULT_DIRECTIVE.parse() { - Ok(directive) => filter.add_directive(directive), - Err(_) => filter, - } -} - -/// Wires the `GatewayClass` controller. -/// -/// Watches all `GatewayClass` resources and reconciles their `Accepted` -/// status. -fn build_gc_controller(client: &Client, ctx: Arc) -> impl Future { - Controller::new(Api::::all(client.clone()), watcher::Config::default()) - .shutdown_on_signal() - .run( - controller::gateway_class::reconcile, - controller::gateway_class::error_policy, - ctx, - ) - .for_each(|res| async move { - match res { - Ok((obj, _action)) => { - observability::metrics::global().record_reconcile(observability::metrics::Controller::GatewayClass); - info!("reconciled GatewayClass {obj}"); - }, - Err(e) => { - observability::metrics::global().record_error(observability::metrics::Controller::GatewayClass); - tracing::warn!("GatewayClass reconcile error: {e:?}"); - }, - } - }) -} - -/// Wires the `Gateway` controller with owned-resource watches. -/// -/// Watches `Gateway` resources and their owned `Deployment`, `ConfigMap`, -/// and `Service` children, `HTTPRoute` cross-references, and -/// `ReferenceGrant` changes. Child watches carry the managed-by label -/// selector so the operator never deserializes unrelated cluster objects. -fn build_gw_controller(client: &Client, ctx: Arc) -> impl Future { - let controller = Controller::new(Api::::all(client.clone()), watcher::Config::default()); - let gateways = controller.store(); - - with_gateway_watches(controller, client, gateways) - .shutdown_on_signal() - .run(controller::gateway::reconcile, controller::gateway::error_policy, ctx) - .for_each(|res| async move { - match res { - Ok((obj, _action)) => { - observability::metrics::global().record_reconcile(observability::metrics::Controller::Gateway); - info!("reconciled Gateway {obj}"); - }, - Err(e) => { - observability::metrics::global().record_error(observability::metrics::Controller::Gateway); - tracing::warn!("Gateway reconcile error: {e:?}"); - }, - } - }) -} - -/// Registers the owned children and cross-references a Gateway depends -/// on. -/// -/// Child watches carry the managed-by selector so the operator never -/// deserializes unrelated cluster objects. -fn with_gateway_watches( - controller: Controller, - client: &Client, - gateways: kube::runtime::reflector::Store, -) -> Controller { - controller - .owns(Api::::all(client.clone()), managed_children()) - .owns(Api::::all(client.clone()), managed_children()) - .owns(Api::::all(client.clone()), managed_children()) - .owns(Api::::all(client.clone()), managed_children()) - .watches( - Api::::all(client.clone()), - watcher::Config::default(), - |route| controller::gateway::map_route_to_gateway(&route), - ) - .watches( - Api::::all(client.clone()), - watcher::Config::default(), - move |grant| controller::gateway::map_grant_to_gateways(&grant, &gateways.state()), - ) -} - -/// Watcher config scoped to the child resources this operator manages. -fn managed_children() -> watcher::Config { - watcher::Config::default().labels(MANAGED_BY_SELECTOR) -} - -/// Wires the `HTTPRoute` controller. -/// -/// Watches all `HTTPRoute` resources and reconciles parent status entries. -fn build_route_controller(client: &Client, ctx: Arc) -> impl Future { - Controller::new(Api::::all(client.clone()), watcher::Config::default()) - .shutdown_on_signal() - .run( - controller::httproute::reconcile, - controller::httproute::error_policy, - ctx, - ) - .for_each(|res| async move { - match res { - Ok((obj, _action)) => { - observability::metrics::global().record_reconcile(observability::metrics::Controller::HttpRoute); - info!("reconciled HTTPRoute {obj}"); - }, - Err(e) => { - observability::metrics::global().record_error(observability::metrics::Controller::HttpRoute); - tracing::warn!("HTTPRoute reconcile error: {e:?}"); - }, - } - }) +/// Propagates any error from [`praxis_operator::run`]. +#[tokio::main] +async fn main() -> praxis_operator::error::Result<()> { + praxis_operator::run().await } diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index 4699c46..1c72067 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -39,13 +39,13 @@ static GLOBAL: LazyLock = LazyLock::new(Metrics::default); // ----------------------------------------------------------------------------- /// Returns the process-wide counter registry. -pub(crate) fn global() -> &'static Metrics { +pub fn global() -> &'static Metrics { &GLOBAL } /// Which controller a measurement belongs to. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum Controller { +pub enum Controller { /// The `GatewayClass` reconciler. GatewayClass, @@ -73,7 +73,7 @@ impl Controller { /// Counters shared by every controller. #[derive(Debug, Default)] -pub(crate) struct Metrics { +pub struct Metrics { /// Successful reconciliations, indexed by [`Controller::index`]. reconciles: [AtomicU64; 3], @@ -100,27 +100,27 @@ pub(crate) struct Metrics { impl Metrics { /// Records a successful reconciliation. - pub(crate) fn record_reconcile(&self, controller: Controller) { + pub fn record_reconcile(&self, controller: Controller) { Self::bump(&self.reconciles, controller); } /// Records a failed reconciliation. - pub(crate) fn record_error(&self, controller: Controller) { + pub fn record_error(&self, controller: Controller) { Self::bump(&self.errors, controller); } /// Records a status patch that was skipped as redundant. - pub(crate) fn record_status_skipped(&self) { + pub fn record_status_skipped(&self) { self.status_patches_skipped.fetch_add(1, Ordering::Relaxed); } /// Records a status patch that was written. - pub(crate) fn record_status_written(&self) { + pub fn record_status_written(&self) { self.status_patches_written.fetch_add(1, Ordering::Relaxed); } /// Records whether this replica holds the leadership lease. - pub(crate) fn set_leader(&self, leading: bool) { + pub fn set_leader(&self, leading: bool) { self.leader.store(u64::from(leading), Ordering::Relaxed); } diff --git a/src/observability/mod.rs b/src/observability/mod.rs index 7da3075..e3aa12b 100644 --- a/src/observability/mod.rs +++ b/src/observability/mod.rs @@ -3,5 +3,5 @@ //! Health, readiness and metrics for the operator process. -pub(crate) mod metrics; -pub(crate) mod server; +pub mod metrics; +pub mod server; diff --git a/src/observability/server.rs b/src/observability/server.rs index a585b73..486c3e6 100644 --- a/src/observability/server.rs +++ b/src/observability/server.rs @@ -37,19 +37,19 @@ const MAX_REQUEST_BYTES: usize = 8192; // 8 KiB /// Liveness and readiness shared with the controllers. #[derive(Debug, Default)] -pub(crate) struct Health { +pub struct Health { /// Whether every controller has completed a first pass. ready: AtomicBool, } impl Health { /// Marks the operator ready to serve. - pub(crate) fn mark_ready(&self) { + pub fn mark_ready(&self) { self.ready.store(true, Ordering::Relaxed); } /// Returns whether the operator is ready to serve. - pub(crate) fn is_ready(&self) -> bool { + pub fn is_ready(&self) -> bool { self.ready.load(Ordering::Relaxed) } } @@ -62,7 +62,7 @@ impl Health { /// /// Binding failures are logged rather than propagated: losing metrics /// is not a reason to take a working control plane down. -pub(crate) async fn serve(health: Arc) { +pub async fn serve(health: Arc) { let listener = match TcpListener::bind(BIND_ADDRESS).await { Ok(listener) => listener, Err(e) => { diff --git a/src/resources/configmap.rs b/src/resources/configmap.rs index 58eda02..aa2294f 100644 --- a/src/resources/configmap.rs +++ b/src/resources/configmap.rs @@ -23,7 +23,7 @@ use super::labels::{owner_reference, standard_labels}; /// # Errors /// /// Returns an error if the Gateway has no UID. -pub(crate) fn build_configmap( +pub fn build_configmap( name: &str, namespace: &str, gateway: &Gateway, diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index aed5b61..e6f28f6 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -48,9 +48,9 @@ const DEFAULT_REPLICAS: i32 = 2; // ----------------------------------------------------------------------------- /// Parameters for building a Praxis data-plane [`Deployment`]. -pub(crate) struct DeploymentParams<'a> { +pub struct DeploymentParams<'a> { /// Child resource name. - pub(crate) name: &'a str, + pub name: &'a str, /// SHA-256 hex digest of the `ConfigMap` contents. /// @@ -58,19 +58,19 @@ pub(crate) struct DeploymentParams<'a> { /// rolling restart. Required because Kubernetes `ConfigMap` volume mounts /// use atomic symlink swaps that `inotify`-based file watchers cannot /// detect. - pub(crate) config_hash: &'a str, + pub config_hash: &'a str, /// Parent Gateway. - pub(crate) gateway: &'a Gateway, + pub gateway: &'a Gateway, /// `(listener_name, port)` pairs from Gateway listeners. - pub(crate) listener_ports: &'a [(String, i32)], + pub listener_ports: &'a [(String, i32)], /// Target namespace. - pub(crate) namespace: &'a str, + pub namespace: &'a str, /// Deduplicated TLS secret names from HTTPS listeners. - pub(crate) tls_secret_names: &'a [String], + pub tls_secret_names: &'a [String], } /// Builds a Deployment for the Praxis data-plane. @@ -89,7 +89,7 @@ pub(crate) struct DeploymentParams<'a> { /// # Errors /// /// Returns an error if the Gateway has no UID. -pub(crate) fn build_deployment(params: &DeploymentParams<'_>) -> crate::error::Result { +pub fn build_deployment(params: &DeploymentParams<'_>) -> crate::error::Result { let instance = params.gateway.name_any(); let labels = standard_labels(&instance); let pod_annotations = BTreeMap::from([("praxis.sh/config-hash".to_owned(), params.config_hash.to_owned())]); diff --git a/src/resources/disruption.rs b/src/resources/disruption.rs index ab8fc34..106e13a 100644 --- a/src/resources/disruption.rs +++ b/src/resources/disruption.rs @@ -31,7 +31,7 @@ use super::labels::{owner_reference, standard_labels}; /// # Errors /// /// Returns an error if the Gateway has no UID. -pub(crate) fn build_pod_disruption_budget( +pub fn build_pod_disruption_budget( name: &str, namespace: &str, gateway: &Gateway, diff --git a/src/resources/labels.rs b/src/resources/labels.rs index 45b612f..3d6e32b 100644 --- a/src/resources/labels.rs +++ b/src/resources/labels.rs @@ -16,7 +16,7 @@ use kube::ResourceExt as _; /// /// Includes `app.kubernetes.io/name`, `app.kubernetes.io/instance`, and /// `app.kubernetes.io/managed-by`. -pub(crate) fn standard_labels(instance: &str) -> BTreeMap { +pub fn standard_labels(instance: &str) -> BTreeMap { let mut labels = BTreeMap::new(); labels.insert("app.kubernetes.io/name".to_owned(), "praxis".to_owned()); labels.insert("app.kubernetes.io/instance".to_owned(), instance.to_owned()); @@ -28,7 +28,7 @@ pub(crate) fn standard_labels(instance: &str) -> BTreeMap { /// /// Prefixes the gateway name with `praxis-` to form the deployment and service /// names. -pub(crate) fn child_name(gateway_name: &str) -> String { +pub fn child_name(gateway_name: &str) -> String { format!("praxis-{gateway_name}") } @@ -42,7 +42,7 @@ pub(crate) fn child_name(gateway_name: &str) -> String { /// Returns [`OperatorError::MissingObjectKey`] when the Gateway has no UID. /// /// [`OperatorError::MissingObjectKey`]: crate::error::OperatorError::MissingObjectKey -pub(crate) fn owner_reference(gateway: &gateway_api::gateways::Gateway) -> crate::error::Result { +pub fn owner_reference(gateway: &gateway_api::gateways::Gateway) -> crate::error::Result { Ok(OwnerReference { api_version: "gateway.networking.k8s.io/v1".to_owned(), block_owner_deletion: Some(true), diff --git a/src/resources/mod.rs b/src/resources/mod.rs index 312ee05..ff5d7de 100644 --- a/src/resources/mod.rs +++ b/src/resources/mod.rs @@ -3,8 +3,8 @@ //! Kubernetes resource builders for managed data-plane objects. -pub(crate) mod configmap; -pub(crate) mod deployment; -pub(crate) mod disruption; -pub(crate) mod labels; -pub(crate) mod service; +pub mod configmap; +pub mod deployment; +pub mod disruption; +pub mod labels; +pub mod service; diff --git a/src/resources/service.rs b/src/resources/service.rs index 0b8e0d4..e5b9164 100644 --- a/src/resources/service.rs +++ b/src/resources/service.rs @@ -23,7 +23,7 @@ use super::labels::{owner_reference, standard_labels}; /// # Errors /// /// Returns an error if the Gateway has no UID. -pub(crate) fn build_service( +pub fn build_service( name: &str, namespace: &str, gateway: &Gateway, From 31153967e6c7f30aa9c1dd0c0a5b0e84d7178849 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:32:14 -0400 Subject: [PATCH 19/51] chore: benchmark the converter Signed-off-by: Shane Utt --- benches/config_generation.rs | 186 ++++++++++++++++++++++++++--------- 1 file changed, 142 insertions(+), 44 deletions(-) diff --git a/benches/config_generation.rs b/benches/config_generation.rs index 0d56258..cdea6e0 100644 --- a/benches/config_generation.rs +++ b/benches/config_generation.rs @@ -7,15 +7,34 @@ //! is the input that grows without bound in a real cluster. These //! measure how conversion scales with it so a regression shows up as a //! number rather than as a slow conformance run. +//! +//! The benchmark drives the operator's own converters, not a stand-in. +//! Everything the reconciler does between "here are the routes" and +//! "here is the YAML" is measured: parent-ref attachment, hostname +//! intersection, rule conversion, cluster assembly, and serialization. +//! Endpoint resolution is the one step left out — it is an API round +//! trip, so its cost is latency rather than CPU, and including it would +//! measure a fake client instead of the operator. #![expect( missing_docs, reason = "criterion_group and criterion_main generate undocumented items" )] -use std::hint::black_box; +use std::{collections::HashMap, hint::black_box}; use criterion::{Criterion, criterion_group, criterion_main}; +use fixtures::{GATEWAY_NAME, GATEWAY_NAMESPACE, listener_manifests, route_manifests}; +use gateway_api::gateways::GatewayListeners; +use praxis_operator::{ + config::{ + cluster::{PraxisCluster, build_cluster}, + generate::assemble_config, + listener::convert_listener, + routing::{BackendRef, convert_routes}, + }, + gateway_api::attachment::attached_routes, +}; // ----------------------------------------------------------------------------- // Route Set Sizes @@ -27,87 +46,166 @@ use criterion::{Criterion, criterion_group, criterion_main}; /// behaviour would be obvious. const ROUTE_COUNTS: [usize; 4] = [1, 10, 100, 500]; +/// Endpoints synthesized per backend `Service`. +/// +/// Stands in for what endpoint resolution would have returned, so +/// cluster assembly and serialization see a realistic amount of data. +const ENDPOINTS_PER_SERVICE: usize = 3; + // ----------------------------------------------------------------------------- // Benchmarks // ----------------------------------------------------------------------------- -/// Measures route conversion across growing route sets. -fn bench_route_conversion(c: &mut Criterion) { - let mut group = c.benchmark_group("route_conversion"); +/// Measures the full route-to-YAML pipeline across growing route sets. +fn bench_config_generation(c: &mut Criterion) { + let mut group = c.benchmark_group("config_generation"); + let listeners = listener_manifests(); + + for count in ROUTE_COUNTS { + group.bench_function(format!("{count}_routes"), |b| { + let routes = route_manifests(count); + b.iter(|| black_box(generate_config(&listeners, &routes))); + }); + } + + group.finish(); +} + +/// Measures attachment alone, which every reconcile pays per Gateway. +/// +/// Split out because it scales with the cluster-wide route count rather +/// than with the routes that actually attach: a Gateway with no routes +/// of its own still walks the whole list. +fn bench_attachment(c: &mut Criterion) { + let mut group = c.benchmark_group("route_attachment"); for count in ROUTE_COUNTS { group.bench_function(format!("{count}_routes"), |b| { - let manifests = praxis_operator_bench::route_manifests(count); - b.iter(|| black_box(praxis_operator_bench::convert(&manifests))); + let routes = route_manifests(count); + b.iter(|| black_box(attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, &routes).len())); }); } group.finish(); } -criterion_group!(benches, bench_route_conversion); +criterion_group!(benches, bench_config_generation, bench_attachment); criterion_main!(benches); // ----------------------------------------------------------------------------- -// Harness Support +// Pipeline // ----------------------------------------------------------------------------- -/// Fixtures and entry points the benchmark drives. -/// -/// The operator is a binary crate, so its internals are not importable -/// here. This module stands in with an equivalent workload built from -/// the same public Gateway API types, which keeps the benchmark honest -/// about input shape even though it cannot call the private converter -/// directly. -mod praxis_operator_bench { - use gateway_api::httproutes::{ - HTTPRoute, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesMatches, HttpRouteRulesMatchesPath, - HttpRouteRulesMatchesPathType, HttpRouteSpec, +/// Runs the synchronous half of `build_praxis_config` and returns the +/// serialized length, which keeps the optimizer from eliding the work. +fn generate_config(listeners: &[GatewayListeners], routes: &[gateway_api::httproutes::HTTPRoute]) -> usize { + let attached = attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, routes); + let listener_hostnames: HashMap> = + listeners.iter().map(|l| (l.name.clone(), l.hostname.clone())).collect(); + + let praxis_listeners: Vec<_> = listeners + .iter() + .map(|l| convert_listener(l, &format!("{}-chain", l.name))) + .collect(); + + let (praxis_routes, backend_refs) = convert_routes(&attached, &listener_hostnames, &[]); + let clusters = synthesize_clusters(&backend_refs); + + assemble_config(praxis_listeners, &praxis_routes, &clusters, &[], &listener_hostnames) + .ok() + .and_then(|config| serde_norway::to_string(&config).ok()) + .map_or(0, |yaml| yaml.len()) +} + +/// Builds clusters with fixed endpoints, standing in for the API reads +/// that resolution would otherwise perform. +fn synthesize_clusters(backend_refs: &[BackendRef]) -> Vec { + backend_refs + .iter() + .map(|backend| { + let endpoints = (0..ENDPOINTS_PER_SERVICE) + .map(|i| format!("10.0.{i}.1:{}", backend.port)) + .collect(); + build_cluster(&backend.cluster_name, endpoints, None) + }) + .collect() +} + +// ----------------------------------------------------------------------------- +// Fixtures +// ----------------------------------------------------------------------------- + +/// Gateway API manifests the benchmark converts. +mod fixtures { + use gateway_api::{ + gateways::GatewayListeners, + httproutes::{ + HTTPRoute, HttpRouteParentRefs, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesMatches, + HttpRouteRulesMatchesPath, HttpRouteRulesMatchesPathType, HttpRouteSpec, + }, }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + /// Name of the Gateway every generated route attaches to. + pub(super) const GATEWAY_NAME: &str = "bench-gateway"; + + /// Namespace holding the Gateway and every generated route. + pub(super) const GATEWAY_NAMESPACE: &str = "default"; + + /// Builds the listener set routes attach to. + /// + /// One cleartext listener with no hostname constraint, so hostname + /// intersection runs on every route without discarding any. + pub(super) fn listener_manifests() -> Vec { + vec![GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + }] + } + /// Builds `count` routes, each with one match and one backend. pub(super) fn route_manifests(count: usize) -> Vec { (0..count).map(build_route).collect() } - /// Serializes every route, standing in for conversion work. - pub(super) fn convert(routes: &[HTTPRoute]) -> usize { - routes - .iter() - .filter_map(|route| serde_json::to_string(route).ok()) - .map(|yaml| yaml.len()) - .sum() - } - - /// Builds one route with a distinct path and backend. + /// Builds one route with a distinct path, hostname, and backend. fn build_route(index: usize) -> HTTPRoute { HTTPRoute { metadata: ObjectMeta { name: Some(format!("route-{index}")), - namespace: Some("default".to_owned()), + namespace: Some(GATEWAY_NAMESPACE.to_owned()), ..Default::default() }, spec: HttpRouteSpec { hostnames: Some(vec![format!("host-{index}.example.com")]), - rules: Some(vec![HttpRouteRules { - backend_refs: Some(vec![HttpRouteRulesBackendRefs { - name: format!("svc-{index}"), - port: Some(8080), - ..Default::default() - }]), - matches: Some(vec![HttpRouteRulesMatches { - path: Some(HttpRouteRulesMatchesPath { - r#type: Some(HttpRouteRulesMatchesPathType::PathPrefix), - value: Some(format!("/api/{index}")), - }), - ..Default::default() - }]), + parent_refs: Some(vec![HttpRouteParentRefs { + name: GATEWAY_NAME.to_owned(), ..Default::default() }]), - ..Default::default() + rules: Some(vec![build_rule(index)]), }, status: None, } } + + /// Builds one rule matching a distinct prefix onto a distinct backend. + fn build_rule(index: usize) -> HttpRouteRules { + HttpRouteRules { + backend_refs: Some(vec![HttpRouteRulesBackendRefs { + name: format!("svc-{index}"), + port: Some(8080), + ..Default::default() + }]), + matches: Some(vec![HttpRouteRulesMatches { + path: Some(HttpRouteRulesMatchesPath { + r#type: Some(HttpRouteRulesMatchesPathType::PathPrefix), + value: Some(format!("/api/{index}")), + }), + ..Default::default() + }]), + ..Default::default() + } + } } From 9ed9f21c5c7e4e5bc52a23c81cec7b3e8d53da2b Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:50:33 -0400 Subject: [PATCH 20/51] chore: replace the copy-pasted test lint preamble with real cfg Signed-off-by: Shane Utt --- clippy.toml | 7 + src/config/cluster.rs | 14 +- src/config/filter_conversion.rs | 14 +- src/config/generate.rs | 14 +- src/config/listener.rs | 14 +- src/config/routing.rs | 14 +- src/config/weights.rs | 13 - src/context.rs | 22 +- src/controller/fixtures.rs | 175 ++++++++++ src/controller/gateway.rs | 34 +- src/controller/gateway_class.rs | 13 - src/controller/gateway_helpers.rs | 14 +- src/controller/gateway_status.rs | 449 +++++++++++++++++++++++++ src/controller/httproute.rs | 13 - src/controller/listener_validation.rs | 330 ++++++++++++++++++ src/controller/mod.rs | 10 +- src/controller/namespace_filter.rs | 293 ++++++++++++++++ src/controller/ownership.rs | 91 +++++ src/controller/praxis_config.rs | 464 ++++++++++++++++++++++++++ src/controller/rollout.rs | 102 ++++++ src/controller/route_parent_status.rs | 169 ++++++++++ src/endpoints.rs | 14 +- src/gateway_api/attachment.rs | 14 +- src/gateway_api/conditions.rs | 13 - src/gateway_api/hostname.rs | 13 - src/gateway_api/listener_conflict.rs | 13 - src/gateway_api/protocol.rs | 13 - src/gateway_api/reference_grant.rs | 25 +- src/gateway_api/route_status.rs | 18 +- src/gateway_api/route_validation.rs | 14 +- src/gateway_api/status.rs | 13 - src/leader.rs | 21 +- src/listing.rs | 13 - src/observability/metrics.rs | 13 - src/observability/server.rs | 13 - src/resources/configmap.rs | 14 +- src/resources/deployment.rs | 14 +- src/resources/disruption.rs | 14 +- src/resources/labels.rs | 14 +- src/resources/service.rs | 14 +- 40 files changed, 2135 insertions(+), 414 deletions(-) create mode 100644 src/controller/fixtures.rs create mode 100644 src/controller/gateway_status.rs create mode 100644 src/controller/listener_validation.rs create mode 100644 src/controller/namespace_filter.rs create mode 100644 src/controller/ownership.rs create mode 100644 src/controller/praxis_config.rs create mode 100644 src/controller/rollout.rs create mode 100644 src/controller/route_parent_status.rs diff --git a/clippy.toml b/clippy.toml index 08ad74b..ef27271 100644 --- a/clippy.toml +++ b/clippy.toml @@ -12,6 +12,13 @@ type-complexity-threshold = 200 stack-size-threshold = 65536 avoid-breaking-exported-api = false msrv = "1.96" +# A test that unwraps is asserting; a test that panics is failing. These +# four were previously suppressed by a hand-copied attribute block at the +# top of every test module, which drifts and hides more than it says. +allow-unwrap-in-tests = true +allow-expect-in-tests = true +allow-panic-in-tests = true +allow-indexing-slicing-in-tests = true disallowed-methods = [ { path = "std::thread::sleep", reason = "use tokio::time::sleep in async context" }, { path = "std::io::stdin", reason = "a server must not read interactive input" }, diff --git a/src/config/cluster.rs b/src/config/cluster.rs index 816b459..d9a1341 100644 --- a/src/config/cluster.rs +++ b/src/config/cluster.rs @@ -95,19 +95,7 @@ pub fn build_cluster(name: &str, endpoints: Vec, weights: Option yaml_serde::Result // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, reason = "tests")] mod tests { use gateway_api::httproutes::{ HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesMatches, HttpRouteRulesMatchesPath, HttpRouteSpec, diff --git a/src/config/weights.rs b/src/config/weights.rs index f07f4c5..fa2c740 100644 --- a/src/config/weights.rs +++ b/src/config/weights.rs @@ -149,19 +149,6 @@ fn lcm(a: i64, b: i64) -> i64 { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; // ----------------------------------------------------------------------------- diff --git a/src/context.rs b/src/context.rs index b3477d4..a3fdf75 100644 --- a/src/context.rs +++ b/src/context.rs @@ -18,6 +18,15 @@ pub const CONTROLLER_NAME: &str = "praxis.sh/gateway-controller"; /// Finalizer string applied to Gateways. pub const GATEWAY_FINALIZER: &str = "gateway.praxis.sh/finalizer"; +/// Field manager recorded on every server-side apply the operator issues. +/// +/// Server-side apply tracks ownership per manager name, so every write +/// this operator makes must use the same one. Two names would let the +/// operator fight itself: fields written under the first would look +/// like another actor's to the second, and neither would ever release +/// them. +pub const FIELD_MANAGER: &str = "praxis-operator"; + /// Admin port on the Praxis data-plane container. pub const ADMIN_PORT: i32 = 9901; @@ -67,19 +76,6 @@ impl std::fmt::Debug for Context { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/controller/fixtures.rs b/src/controller/fixtures.rs new file mode 100644 index 0000000..da988b4 --- /dev/null +++ b/src/controller/fixtures.rs @@ -0,0 +1,175 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Gateway API object builders shared by the controller's tests. +//! +//! The reconciliation path is split across several modules that all +//! need the same handful of listeners, routes, and namespaces. Keeping +//! one copy here means a change to a Gateway API type is a change in +//! one place, and the fixtures cannot drift apart between modules. + +use std::collections::BTreeMap; + +use gateway_api::{ + gateways::{ + GatewayListeners, GatewayListenersAllowedRoutes, GatewayListenersAllowedRoutesNamespaces, + GatewayListenersAllowedRoutesNamespacesFrom, GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions, + GatewayListenersTls, GatewayListenersTlsCertificateRefs, + }, + httproutes::{HTTPRoute, HttpRouteSpec}, +}; +use k8s_openapi::{ + ByteString, + api::{ + apps::v1::{DeploymentCondition, DeploymentStatus}, + core::v1::Namespace, + }, + apimachinery::pkg::apis::meta::v1::{ObjectMeta, Time}, + jiff::Timestamp, +}; + +// ----------------------------------------------------------------------------- +// Builders +// ----------------------------------------------------------------------------- + +/// Builds a Gateway listener with the given name, port, and protocol. +pub(super) fn listener(name: &str, port: i32, protocol: &str) -> GatewayListeners { + GatewayListeners { + name: name.to_owned(), + port, + protocol: protocol.to_owned(), + ..Default::default() + } +} + +/// Builds an HTTPS listener referencing a TLS secret, scoped by hostname. +pub(super) fn https_listener(name: &str, port: i32, secret: &str) -> GatewayListeners { + GatewayListeners { + hostname: Some(format!("{name}.example.com")), + tls: Some(GatewayListenersTls { + certificate_refs: Some(vec![GatewayListenersTlsCertificateRefs { + name: secret.to_owned(), + ..Default::default() + }]), + ..Default::default() + }), + ..listener(name, port, "HTTPS") + } +} + +/// Builds a listener carrying an explicit `allowedRoutes.namespaces.from`. +pub(super) fn listener_with_namespace_policy(from: GatewayListenersAllowedRoutesNamespacesFrom) -> GatewayListeners { + GatewayListeners { + allowed_routes: Some(GatewayListenersAllowedRoutes { + namespaces: Some(GatewayListenersAllowedRoutesNamespaces { + from: Some(from), + selector: None, + }), + ..Default::default() + }), + ..listener("http", 80, "HTTP") + } +} + +/// Builds an `HTTPRoute` carrying the given hostnames. +pub(super) fn route_with_hostnames(hostnames: &[&str]) -> HTTPRoute { + HTTPRoute { + metadata: ObjectMeta { + name: Some("route".to_owned()), + namespace: Some("apps".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + hostnames: Some(hostnames.iter().map(|h| (*h).to_owned()).collect()), + ..Default::default() + }, + status: None, + } +} + +/// Builds Secret data with the given `tls.crt` and `tls.key` contents. +pub(super) fn secret_data(cert: &str, key: &str) -> BTreeMap { + [ + ("tls.crt".to_owned(), ByteString(cert.as_bytes().to_vec())), + ("tls.key".to_owned(), ByteString(key.as_bytes().to_vec())), + ] + .into_iter() + .collect() +} + +/// Builds a `DeploymentStatus` carrying a single condition. +pub(super) fn deployment_status(type_: &str, status: &str, reason: &str) -> DeploymentStatus { + DeploymentStatus { + conditions: Some(vec![DeploymentCondition { + last_transition_time: Some(Time(Timestamp::UNIX_EPOCH)), + last_update_time: Some(Time(Timestamp::UNIX_EPOCH)), + message: None, + reason: Some(reason.to_owned()), + status: status.to_owned(), + type_: type_.to_owned(), + }]), + ..Default::default() + } +} + +/// Builds a `Namespace` with the given labels. +pub(super) fn namespace(name: &str, labels: &[(&str, &str)]) -> Namespace { + Namespace { + metadata: ObjectMeta { + name: Some(name.to_owned()), + labels: Some(labels.iter().map(|(k, v)| ((*k).to_owned(), (*v).to_owned())).collect()), + ..Default::default() + }, + ..Default::default() + } +} + +/// Builds a label-selector match expression. +pub(super) fn expression( + key: &str, + operator: &str, + values: &[&str], +) -> GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions { + GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions { + key: key.to_owned(), + operator: operator.to_owned(), + values: Some(values.iter().map(|v| (*v).to_owned()).collect()), + } +} + +/// Builds a route with `rules` rules, the last of which uses an +/// unsupported `RegularExpression` path match. +pub(super) fn regex_route(rules: usize) -> HTTPRoute { + use gateway_api::httproutes::{ + HttpRouteRules, HttpRouteRulesMatches, HttpRouteRulesMatchesPath, HttpRouteRulesMatchesPathType, HttpRouteSpec, + }; + + let built = (0..rules) + .map(|index| { + let kind = if index + 1 == rules { + HttpRouteRulesMatchesPathType::RegularExpression + } else { + HttpRouteRulesMatchesPathType::Exact + }; + HttpRouteRules { + matches: Some(vec![HttpRouteRulesMatches { + path: Some(HttpRouteRulesMatchesPath { + r#type: Some(kind), + value: Some("/x".to_owned()), + }), + ..Default::default() + }]), + ..Default::default() + } + }) + .collect(); + + HTTPRoute { + metadata: ObjectMeta::default(), + spec: HttpRouteSpec { + rules: Some(built), + ..Default::default() + }, + status: None, + } +} diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index 7ad417d..b6eaaae 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -19,7 +19,7 @@ use kube::{ use serde::{Serialize, de::DeserializeOwned}; use tracing::{debug, error, info}; -use super::gateway_helpers; +use super::{gateway_status, ownership, praxis_config, rollout, route_parent_status}; use crate::{ context::{Context, GATEWAY_FINALIZER}, error::{OperatorError, Result}, @@ -98,21 +98,21 @@ pub fn error_policy(_gw: Arc, error: &OperatorError, _ctx: Arc /// test from sending traffic before the data plane has the latest /// configuration. async fn apply(gw: Arc, ctx: &Context) -> Result { - if !gateway_helpers::validate_gateway_class(&ctx.client, &gw).await? || reject_unsupported_spec(ctx, &gw).await? { + if !ownership::validate_gateway_class(&ctx.client, &gw).await? || reject_unsupported_spec(ctx, &gw).await? { return Ok(Action::await_change()); } let routes = list_all_routes(&ctx.client).await?; - let attached = gateway_helpers::collect_routes(&ctx.client, &gw, &routes).await; + let attached = ownership::collect_routes(&ctx.client, &gw, &routes).await; let ns = gw.namespace().unwrap_or_default(); let grants = list_all_grants(&ctx.client).await?; let config_changed = apply_config_if_supported(&ctx.client, &gw, &attached, &ns, &grants).await?; - gateway_helpers::build_and_apply_gateway_status(&ctx.client, &gw, &gw.spec.listeners, &attached).await?; + gateway_status::build_and_apply_gateway_status(&ctx.client, &gw, &gw.spec.listeners, &attached).await?; let can_accept = can_accept_routes(&ctx.client, &gw, &ns, config_changed).await; if can_accept { - gateway_helpers::update_route_parent_statuses(&ctx.client, &gw, &attached, &grants).await?; + route_parent_status::update_route_parent_statuses(&ctx.client, &gw, &attached, &grants).await?; } let requeue_secs = if can_accept { 15 } else { 2 }; @@ -140,9 +140,9 @@ async fn apply_config_if_supported( } let child = crate::resources::labels::child_name(&gw.name_any()); - let prev_hash = gateway_helpers::current_deployment_hash(client, ns, &child).await; - let config = gateway_helpers::build_praxis_config(client, &gw.spec.listeners, attached, grants).await?; - let new_hash = Box::pin(gateway_helpers::apply_child_resources(client, gw, &config)).await?; + let prev_hash = rollout::current_deployment_hash(client, ns, &child).await; + let config = praxis_config::build_praxis_config(client, &gw.spec.listeners, attached, grants).await?; + let new_hash = Box::pin(praxis_config::apply_child_resources(client, gw, &config)).await?; let changed = prev_hash.as_deref() != Some(&new_hash); debug!( @@ -158,7 +158,7 @@ async fn apply_config_if_supported( /// rolled out, so attached routes may be marked accepted. async fn can_accept_routes(client: &kube::Client, gw: &Gateway, ns: &str, config_changed: bool) -> bool { let child = crate::resources::labels::child_name(&gw.name_any()); - let rolled_out = gateway_helpers::is_deployment_rolled_out(client, ns, &child).await; + let rolled_out = rollout::is_deployment_rolled_out(client, ns, &child).await; let can_accept = !config_changed && rolled_out; debug!(gateway = %gw.name_any(), can_accept, "route acceptance decision"); @@ -344,7 +344,7 @@ async fn reject_gateway( ], }); - gateway_helpers::apply_gateway_status(client, gw, &status).await + gateway_status::apply_gateway_status(client, gw, &status).await } // ----------------------------------------------------------------------------- @@ -421,19 +421,7 @@ fn find_gateway_parent_ref( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::default_trait_access, reason = "tests")] mod tests { use gateway_api::{ gateways::GatewayAddresses, diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index b0794ee..064865a 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -158,19 +158,6 @@ pub fn error_policy(_gc: Arc, error: &OperatorError, _ctx: Arc], +) -> Result<()> { + let ns = gw.namespace().unwrap_or_default(); + let name = gw.name_any(); + let generation = gw.metadata.generation.unwrap_or(1); + let child = child_name(&name); + + let addresses = resolve_lb_addresses(client, &ns, &child).await; + let deployment_ready = is_deployment_ready(client, &ns, &child).await; + let (listener_statuses, any_accepted, any_rejected) = + build_listener_statuses(listeners, generation, &ns, client, attached).await; + + let data_plane_ready = deployment_ready && !addresses.is_empty(); + let status = gateway_status_json(&GatewayStatusParts { + accepted: &gateway_accepted_condition(generation, any_accepted, any_rejected), + addresses: &addresses, + listener_statuses: &listener_statuses, + programmed: &gateway_programmed_condition(generation, any_accepted, data_plane_ready), + }); + + apply_gateway_status(client, gw, &status).await?; + info!("Gateway {ns}/{name} reconciled successfully"); + Ok(()) +} + +/// Components used to build the Gateway status JSON payload. +struct GatewayStatusParts<'a> { + /// Gateway-level `Accepted` condition. + accepted: &'a Condition, + + /// Load-balancer addresses. + addresses: &'a [Value], + + /// Per-listener status entries. + listener_statuses: &'a [Value], + + /// Gateway-level `Programmed` condition. + programmed: &'a Condition, +} + +/// Constructs the `status` sub-object of the Gateway status patch. +fn gateway_status_json(parts: &GatewayStatusParts<'_>) -> Value { + json!({ + "addresses": parts.addresses, + "conditions": [parts.accepted, parts.programmed], + "listeners": parts.listener_statuses, + }) +} + +/// Patches the Gateway status via server-side apply. +/// +/// Carries condition transition times forward and returns without +/// contacting the API server when the computed status already matches +/// the live object. Writing an unchanged status re-triggers the +/// controller's own watch, which would keep an idle Gateway reconciling +/// forever. +pub(super) async fn apply_gateway_status(client: &kube::Client, gw: &Gateway, status_json: &Value) -> Result<()> { + let ns = gw.namespace().unwrap_or_default(); + let name = gw.name_any(); + + let observed = serde_json::to_value(&gw.status)?; + let mut desired = status_json.clone(); + status::preserve_condition_times(&mut desired, &observed); + + if status::is_status_unchanged(&desired, &observed) { + metrics::global().record_status_skipped(); + debug!("Gateway {ns}/{name} status unchanged, skipping patch"); + return Ok(()); + } + metrics::global().record_status_written(); + + let payload = json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": { "name": name, "namespace": ns }, + "status": desired, + }); + + Api::::namespaced(client.clone(), &ns) + .patch_status( + &name, + &PatchParams::apply(FIELD_MANAGER).force(), + &Patch::Apply(&payload), + ) + .await?; + Ok(()) +} + +/// Queries the child Service for load-balancer ingress IP addresses. +async fn resolve_lb_addresses(client: &kube::Client, ns: &str, child: &str) -> Vec { + Api::::namespaced(client.clone(), ns) + .get(child) + .await + .ok() + .and_then(|svc| svc.status) + .and_then(|s| s.load_balancer) + .and_then(|lb| lb.ingress) + .map(|ingress| { + ingress + .iter() + .filter_map(|i| i.ip.as_ref().map(|ip| json!({ "type": "IPAddress", "value": ip }))) + .collect() + }) + .unwrap_or_default() +} + +/// Checks whether the child Deployment has at least one ready replica. +/// +/// Used for the Gateway `Programmed` condition, which reflects whether +/// the data plane can serve traffic at all (even with a stale config). +async fn is_deployment_ready(client: &kube::Client, ns: &str, child: &str) -> bool { + Api::::namespaced(client.clone(), ns) + .get(child) + .await + .ok() + .and_then(|d| d.status) + .is_some_and(|s| s.ready_replicas.unwrap_or(0) > 0) +} + +/// Builds per-listener status entries. +/// +/// Returns `(statuses, any_accepted, any_rejected)`. +async fn build_listener_statuses( + listeners: &[GatewayListeners], + generation: i64, + gateway_ns: &str, + client: &kube::Client, + attached: &[AttachedRoute<'_>], +) -> (Vec, bool, bool) { + let conflicts = listener_conflict::detect_conflicts(listeners); + let mut statuses = Vec::new(); + let mut any_accepted = false; + let mut any_rejected = false; + + for l in listeners { + if let Some(reason) = conflicts.get(&l.name) { + any_rejected = true; + statuses.push(conflicted_listener_status(l, generation, *reason)); + continue; + } + + let protocol_supported = ListenerProtocol::is_supported(&l.protocol); + if !protocol_supported { + any_rejected = true; + statuses.push(unsupported_listener_status(l, generation)); + continue; + } + + any_accepted = true; + let count = count_attached_routes(attached, l); + let status = accepted_listener_status(l, generation, gateway_ns, client, count).await; + statuses.push(status); + } + + (statuses, any_accepted, any_rejected) +} + +/// Builds a status entry for a listener conflicting with another. +/// +/// A conflicted listener is not accepted, not programmed, and attaches +/// no routes: it never reaches the data plane, so claiming otherwise +/// would misreport what is serving traffic. +fn conflicted_listener_status( + l: &GatewayListeners, + generation: i64, + reason: listener_conflict::ConflictReason, +) -> Value { + json!({ + "name": l.name, + "attachedRoutes": 0, + "supportedKinds": [], + "conditions": [ + conditions::not_accepted(generation, reason.as_str(), reason.message()), + conditions::conflicted(generation, reason.as_str(), reason.message()), + conditions::not_programmed(generation, reason.as_str(), reason.message()), + ], + }) +} + +/// Builds a status entry for an unsupported-protocol listener. +fn unsupported_listener_status(l: &GatewayListeners, generation: i64) -> Value { + json!({ + "name": l.name, + "attachedRoutes": 0, + "supportedKinds": [], + "conditions": [ + conditions::not_accepted( + generation, + "UnsupportedProtocol", + "protocol not supported", + ), + conditions::not_programmed( + generation, "Invalid", "unsupported protocol", + ), + ], + }) +} + +/// Counts routes attached to a specific listener. +fn count_attached_routes(attached: &[AttachedRoute<'_>], listener: &GatewayListeners) -> usize { + attached + .iter() + .filter(|attached| { + if !attached.targets_listener(&listener.name) { + return false; + } + let route_hostnames = attached.route.spec.hostnames.as_deref().unwrap_or(&[]); + if route_hostnames.is_empty() { + return true; + } + match &listener.hostname { + None => true, + Some(lh) => route_hostnames.iter().any(|rh| hostname::hostname_matches(rh, lh)), + } + }) + .count() +} + +/// Builds a status entry for an accepted listener. +async fn accepted_listener_status( + l: &GatewayListeners, + generation: i64, + gateway_ns: &str, + client: &kube::Client, + count: usize, +) -> Value { + let (supported_kinds, resolved_refs_condition) = + listener_validation::listener_resolved_refs(l, generation, gateway_ns, client).await; + + let refs_resolved = resolved_refs_condition.status == "True"; + let programmed_condition = if refs_resolved { + conditions::programmed(generation, "listener programmed") + } else { + conditions::not_programmed(generation, "Invalid", "listener has unresolved refs") + }; + + json!({ + "name": l.name, + "attachedRoutes": count, + "supportedKinds": supported_kinds, + "conditions": [ + conditions::accepted(generation, "listener accepted"), + programmed_condition, + conditions::no_conflicts(generation), + resolved_refs_condition, + ], + }) +} + +/// Returns the `Accepted` condition for the Gateway. +/// +/// `ListenersNotValid` is only a valid reason alongside `Accepted: +/// False`, so a Gateway with a mix of valid and invalid listeners +/// reports `Accepted`/`Accepted` and carries the partial failure in the +/// message; the per-listener conditions describe which ones failed. +fn gateway_accepted_condition(generation: i64, any_accepted: bool, any_rejected: bool) -> Condition { + if !any_accepted { + return conditions::not_accepted( + generation, + "ListenersNotValid", + "no listeners have a supported protocol", + ); + } + + if any_rejected { + return conditions::accepted(generation, "Gateway accepted, but some listeners are invalid"); + } + + conditions::accepted(generation, "Gateway accepted") +} + +/// Returns the `Programmed` condition for the Gateway. +/// +/// Requires accepted listeners, a ready Deployment, and at least one +/// load-balancer address before reporting `True`. +fn gateway_programmed_condition(generation: i64, any_accepted: bool, data_plane_ready: bool) -> Condition { + if !any_accepted { + return conditions::not_programmed(generation, "Invalid", "no valid listeners"); + } + if !data_plane_ready { + return conditions::not_programmed(generation, "Pending", "data plane not ready"); + } + conditions::programmed(generation, "Data plane ready") +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::controller::fixtures::{https_listener, listener, route_with_hostnames}; + + #[test] + fn test_gateway_programmed_all_ready() { + let cond = gateway_programmed_condition(1, true, true); + assert_eq!(cond.type_, "Programmed", "type should be Programmed"); + assert_eq!(cond.status, "True", "should be True when all ready"); + assert_eq!(cond.reason, "Programmed", "reason should be Programmed"); + assert_eq!(cond.observed_generation, Some(1), "generation should match"); + } + + #[test] + fn test_gateway_programmed_no_accepted_listeners() { + let cond = gateway_programmed_condition(2, false, false); + assert_eq!(cond.status, "False", "should be False without accepted listeners"); + assert_eq!(cond.reason, "Invalid", "reason should be Invalid"); + } + + #[test] + fn test_gateway_programmed_deployment_not_ready() { + let cond = gateway_programmed_condition(3, true, false); + assert_eq!(cond.status, "False", "should be False when data plane not ready"); + assert_eq!(cond.reason, "Pending", "reason should be Pending"); + } + + #[test] + fn test_gateway_programmed_invalid_takes_precedence() { + let cond = gateway_programmed_condition(4, false, true); + assert_eq!(cond.status, "False", "should be False without accepted listeners"); + assert_eq!( + cond.reason, "Invalid", + "Invalid should take precedence over data plane readiness" + ); + } + + #[test] + fn test_gateway_accepted_all_valid() { + let cond = gateway_accepted_condition(1, true, false); + assert_eq!(cond.type_, "Accepted", "type should be Accepted"); + assert_eq!(cond.status, "True", "should be True when all accepted"); + assert_eq!(cond.reason, "Accepted", "reason should be Accepted"); + } + + #[test] + fn test_gateway_accepted_none_valid() { + let cond = gateway_accepted_condition(1, false, true); + assert_eq!(cond.status, "False", "should be False with no accepted listeners"); + } + + #[test] + fn test_gateway_accepted_mixed_listeners() { + let cond = gateway_accepted_condition(1, true, true); + assert_eq!(cond.status, "True", "should be True when some listeners are accepted"); + assert_eq!( + cond.reason, "Accepted", + "Accepted: True must not carry ListenersNotValid, which is a False-only reason" + ); + assert!( + cond.message.contains("some listeners are invalid"), + "the partial failure belongs in the message: {}", + cond.message + ); + } + + #[test] + fn test_count_attached_routes_matches_hostname() { + let listener = https_listener("https", 443, "cert"); + let route = route_with_hostnames(&["a.example.com"]); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + assert_eq!( + count_attached_routes(&attached, &listener), + 0, + "a route whose hostname misses the listener must not be counted" + ); + } + + #[test] + fn test_count_attached_routes_counts_unconstrained_routes() { + let listener = listener("http", 80, "HTTP"); + let route = route_with_hostnames(&[]); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + assert_eq!( + count_attached_routes(&attached, &listener), + 1, + "a route without hostnames attaches to any listener" + ); + } + + #[test] + fn test_count_attached_routes_respects_section_name() { + let listener = listener("http", 80, "HTTP"); + let route = route_with_hostnames(&[]); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![Some("https".to_owned())], + }]; + + assert_eq!( + count_attached_routes(&attached, &listener), + 0, + "a route bound to another section is not attached here" + ); + } +} diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index d567cc2..f4dadba 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -342,19 +342,6 @@ pub fn error_policy(_route: Arc, error: &OperatorError, _ctx: Arc (Vec, Condition) { + let (supported, kinds_invalid) = validate_route_kinds(listener); + + if kinds_invalid { + return ( + supported, + conditions::unresolved_refs(generation, "InvalidRouteKinds", "unsupported route kinds specified"), + ); + } + + if let Some(condition) = validate_tls_cert_refs(listener, generation, gateway_ns, client).await { + return (supported, condition); + } + + (supported, conditions::resolved_refs(generation, "all refs resolved")) +} + +/// Validates the configured `allowedRoutes.kinds` on a listener. +/// +/// Returns `(supported_kinds_json, has_invalid_kinds)`. +fn validate_route_kinds(listener: &GatewayListeners) -> (Vec, bool) { + let configured = listener.allowed_routes.as_ref().and_then(|ar| ar.kinds.as_ref()); + let Some(kinds) = configured else { + return (httproute_supported_kinds(), false); + }; + + let has_httproute = kinds.iter().any(is_httproute_kind); + let has_unsupported = kinds.iter().any(|k| !is_httproute_kind(k)); + let supported = if has_httproute { + httproute_supported_kinds() + } else { + Vec::new() + }; + (supported, has_unsupported) +} + +/// Returns the default `supportedKinds` JSON for `HTTPRoute`. +fn httproute_supported_kinds() -> Vec { + vec![json!({"group": "gateway.networking.k8s.io", "kind": "HTTPRoute"})] +} + +/// Checks whether a route kind ref is `HTTPRoute` in the Gateway API group. +fn is_httproute_kind(k: &GatewayListenersAllowedRoutesKinds) -> bool { + let group = k.group.as_deref().unwrap_or("gateway.networking.k8s.io"); + group == "gateway.networking.k8s.io" && k.kind == "HTTPRoute" +} + +/// Validates TLS certificate refs on a listener. +/// +/// Returns `Some(condition)` on the first validation failure, `None` when +/// all refs are valid. +async fn validate_tls_cert_refs( + listener: &GatewayListeners, + generation: i64, + gateway_ns: &str, + client: &kube::Client, +) -> Option { + let cert_refs = listener.tls.as_ref()?.certificate_refs.as_ref()?; + + for cert_ref in cert_refs { + if !is_secret_cert_ref(cert_ref) { + return Some(conditions::unresolved_refs( + generation, + "InvalidCertificateRef", + "unsupported certificate ref", + )); + } + let secret_ns = cert_ref.namespace.as_deref().unwrap_or(gateway_ns); + if let Some(c) = check_cross_ns_grant(client, generation, gateway_ns, secret_ns, &cert_ref.name).await { + return Some(c); + } + if let Some(c) = check_secret_contents(client, generation, secret_ns, &cert_ref.name).await { + return Some(c); + } + } + None +} + +/// Returns `true` when the cert ref points to a core `Secret`. +fn is_secret_cert_ref(cert_ref: &GatewayListenersTlsCertificateRefs) -> bool { + let group = cert_ref.group.as_deref().unwrap_or(""); + let kind = cert_ref.kind.as_deref().unwrap_or("Secret"); + group.is_empty() && kind == "Secret" +} + +/// Checks cross-namespace `ReferenceGrant` authorization for a TLS secret. +/// +/// Returns `Some(condition)` when the reference is denied, `None` when +/// allowed or same-namespace. +async fn check_cross_ns_grant( + client: &kube::Client, + generation: i64, + gateway_ns: &str, + secret_ns: &str, + secret_name: &str, +) -> Option { + if secret_ns == gateway_ns { + return None; + } + + let Ok(grants) = list_reference_grants(client, secret_ns).await else { + return Some(conditions::unresolved_refs( + generation, + "RefNotPermitted", + "cannot verify cross-namespace grant", + )); + }; + + if is_secret_ref_granted(gateway_ns, secret_ns, secret_name, &grants) { + return None; + } + + Some(conditions::unresolved_refs( + generation, + "RefNotPermitted", + "cross-namespace secret reference requires a valid ReferenceGrant", + )) +} + +/// Lists `ReferenceGrant` resources in the given namespace. +async fn list_reference_grants(client: &kube::Client, ns: &str) -> Result> { + let api = Api::::namespaced(client.clone(), ns); + listing::list_all(&api).await +} + +/// Checks whether a Gateway-to-Secret cross-namespace ref is allowed. +fn is_secret_ref_granted(gateway_ns: &str, secret_ns: &str, secret_name: &str, grants: &[ReferenceGrant]) -> bool { + reference_grant::is_reference_allowed( + gateway_ns, + "gateway.networking.k8s.io", + "Gateway", + secret_ns, + "", + "Secret", + Some(secret_name), + grants, + ) +} + +/// Validates that a TLS Secret exists and contains valid PEM data. +/// +/// Returns `Some(condition)` on failure, `None` when the secret is valid. +async fn check_secret_contents( + client: &kube::Client, + generation: i64, + secret_ns: &str, + secret_name: &str, +) -> Option { + let secret_api = Api::::namespaced(client.clone(), secret_ns); + + let Ok(secret) = secret_api.get(secret_name).await else { + return Some(conditions::unresolved_refs( + generation, + "InvalidCertificateRef", + "secret not found", + )); + }; + validate_tls_secret_data(secret.data.as_ref(), generation) +} + +/// Validates that a Secret's data contains well-formed TLS PEM entries. +fn validate_tls_secret_data(data: Option<&BTreeMap>, generation: i64) -> Option { + let has_keys = data.is_some_and(|d| d.contains_key("tls.crt") && d.contains_key("tls.key")); + if !has_keys { + return Some(conditions::unresolved_refs( + generation, + "InvalidCertificateRef", + "malformed secret", + )); + } + + let is_pem = data.is_some_and(|d| is_pem_entry(d, "tls.crt") && is_pem_entry(d, "tls.key")); + if !is_pem { + return Some(conditions::unresolved_refs( + generation, + "InvalidCertificateRef", + "invalid PEM data", + )); + } + + None +} + +/// Checks whether a Secret data entry starts with a PEM header. +fn is_pem_entry(data: &BTreeMap, key: &str) -> bool { + data.get(key) + .is_some_and(|v| String::from_utf8_lossy(&v.0).starts_with("-----BEGIN ")) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use gateway_api::gateways::{GatewayListenersAllowedRoutes, GatewayListenersAllowedRoutesKinds}; + + use super::*; + use crate::controller::fixtures::{listener, secret_data}; + + #[test] + fn test_validate_route_kinds_defaults_to_httproute() { + let (supported, invalid) = validate_route_kinds(&listener("http", 80, "HTTP")); + + assert_eq!(supported.len(), 1, "HTTPRoute is supported by default"); + assert!(!invalid, "an unspecified kind list is never invalid"); + } + + #[test] + fn test_validate_route_kinds_flags_unsupported_kinds() { + let mut l = listener("http", 80, "HTTP"); + l.allowed_routes = Some(GatewayListenersAllowedRoutes { + kinds: Some(vec![GatewayListenersAllowedRoutesKinds { + group: None, + kind: "TCPRoute".to_owned(), + }]), + ..Default::default() + }); + + let (supported, invalid) = validate_route_kinds(&l); + + assert!(supported.is_empty(), "an unsupported-only list supports nothing"); + assert!(invalid, "TCPRoute is not implemented and must be reported"); + } + + #[test] + fn test_is_secret_cert_ref_accepts_core_secret() { + assert!( + is_secret_cert_ref(&GatewayListenersTlsCertificateRefs { + name: "cert".to_owned(), + ..Default::default() + }), + "an unqualified certificateRef defaults to a core Secret" + ); + } + + #[test] + fn test_is_secret_cert_ref_rejects_other_kinds() { + assert!( + !is_secret_cert_ref(&GatewayListenersTlsCertificateRefs { + name: "cert".to_owned(), + kind: Some("ConfigMap".to_owned()), + ..Default::default() + }), + "only Secrets can carry TLS material" + ); + } + + #[test] + fn test_validate_tls_secret_data_accepts_pem() { + let data = secret_data("-----BEGIN CERTIFICATE-----", "-----BEGIN PRIVATE KEY-----"); + + assert!( + validate_tls_secret_data(Some(&data), 1).is_none(), + "a well-formed TLS secret produces no failure condition" + ); + } + + #[test] + fn test_validate_tls_secret_data_rejects_missing_keys() { + let condition = validate_tls_secret_data(None, 1); + + assert_eq!( + condition.map(|c| c.message), + Some("malformed secret".to_owned()), + "a secret without tls.crt and tls.key is malformed" + ); + } + + #[test] + fn test_validate_tls_secret_data_rejects_non_pem() { + let data = secret_data("not a certificate", "not a key"); + let condition = validate_tls_secret_data(Some(&data), 1); + + assert_eq!( + condition.map(|c| c.message), + Some("invalid PEM data".to_owned()), + "non-PEM contents must be reported" + ); + } + + #[test] + fn test_is_pem_entry() { + let data = secret_data("-----BEGIN CERTIFICATE-----", "garbage"); + + assert!(is_pem_entry(&data, "tls.crt"), "a PEM header should be recognised"); + assert!(!is_pem_entry(&data, "tls.key"), "non-PEM data should be rejected"); + assert!(!is_pem_entry(&data, "missing"), "an absent key is not PEM"); + } +} diff --git a/src/controller/mod.rs b/src/controller/mod.rs index f0f10bc..6642908 100644 --- a/src/controller/mod.rs +++ b/src/controller/mod.rs @@ -3,7 +3,15 @@ //! Kubernetes controllers for Gateway API resources. +#[cfg(test)] +mod fixtures; pub mod gateway; pub mod gateway_class; -mod gateway_helpers; +mod gateway_status; pub mod httproute; +mod listener_validation; +mod namespace_filter; +mod ownership; +mod praxis_config; +mod rollout; +mod route_parent_status; diff --git a/src/controller/namespace_filter.rs b/src/controller/namespace_filter.rs new file mode 100644 index 0000000..de1df98 --- /dev/null +++ b/src/controller/namespace_filter.rs @@ -0,0 +1,293 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! `allowedRoutes.namespaces` evaluation. +//! +//! A listener decides which namespaces may attach routes to it. The +//! policy is one of three modes, and the selector mode carries a full +//! Kubernetes label selector, so the evaluation is large enough to +//! separate from the status writing that consumes its answer. + +use std::collections::BTreeMap; + +use gateway_api::{ + gateways::{ + GatewayListeners, GatewayListenersAllowedRoutesNamespacesFrom, GatewayListenersAllowedRoutesNamespacesSelector, + GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions, + }, + httproutes::HTTPRoute, +}; +use k8s_openapi::api::core::v1::Namespace; +use kube::Api; +use tracing::warn; + +use crate::{ + gateway_api::{attachment::AttachedRoute, route_status}, + listing, +}; + +// ----------------------------------------------------------------------------- +// Namespace Filtering +// ----------------------------------------------------------------------------- + +/// Filters attached routes by the `allowedRoutes.namespaces` policy on +/// each listener. +/// +/// A route is retained if at least one listener it targets allows its +/// namespace. The default policy (when unspecified) is `Same`. +pub(super) async fn filter_routes_by_allowed_namespaces<'a>( + attached: &[AttachedRoute<'a>], + listeners: &[GatewayListeners], + gateway_ns: &str, + client: &kube::Client, +) -> Vec> { + let all_namespaces = fetch_all_namespaces(client).await; + + attached + .iter() + .filter(|attached| { + route_allowed_by_any_listener( + attached.route, + &attached.section_names, + listeners, + gateway_ns, + all_namespaces.as_deref(), + ) + }) + .cloned() + .collect() +} + +/// Fetches all namespaces from the cluster, returning `None` on error. +async fn fetch_all_namespaces(client: &kube::Client) -> Option> { + match listing::list_all(&Api::::all(client.clone())).await { + Ok(namespaces) => Some(namespaces), + Err(e) => { + warn!(%e, "failed to list namespaces for route filtering"); + None + }, + } +} + +/// Checks whether a route is allowed by at least one targeted listener. +fn route_allowed_by_any_listener( + route: &HTTPRoute, + section_names: &[Option], + listeners: &[GatewayListeners], + gateway_ns: &str, + all_namespaces: Option<&[Namespace]>, +) -> bool { + let route_ns = route_status::route_namespace(route); + section_names.iter().any(|section| { + let matching: Vec<&GatewayListeners> = match section { + Some(name) => listeners.iter().filter(|l| l.name == *name).collect(), + None => listeners.iter().collect(), + }; + matching + .iter() + .any(|listener| is_namespace_allowed(listener, route_ns, gateway_ns, all_namespaces)) + }) +} + +/// Checks whether a route namespace is allowed by a listener's policy. +/// +/// Defaults to `Same` when `allowedRoutes` is unspecified. +fn is_namespace_allowed( + listener: &GatewayListeners, + route_ns: &str, + gateway_ns: &str, + all_namespaces: Option<&[Namespace]>, +) -> bool { + let from = listener + .allowed_routes + .as_ref() + .and_then(|ar| ar.namespaces.as_ref()) + .and_then(|ns| ns.from.as_ref()); + + match from { + None | Some(GatewayListenersAllowedRoutesNamespacesFrom::Same) => route_ns == gateway_ns, + Some(GatewayListenersAllowedRoutesNamespacesFrom::All) => true, + Some(GatewayListenersAllowedRoutesNamespacesFrom::Selector) => { + namespace_matches_selector(listener, route_ns, all_namespaces) + }, + } +} + +/// Checks whether a route namespace matches the listener's label selector. +fn namespace_matches_selector( + listener: &GatewayListeners, + route_ns: &str, + all_namespaces: Option<&[Namespace]>, +) -> bool { + let selector = listener + .allowed_routes + .as_ref() + .and_then(|ar| ar.namespaces.as_ref()) + .and_then(|ns| ns.selector.as_ref()); + + let Some(selector) = selector else { + return false; + }; + let Some(all_ns) = all_namespaces else { + return false; + }; + + all_ns.iter().any(|ns_obj| { + let ns_name = ns_obj.metadata.name.as_deref().unwrap_or(""); + ns_name == route_ns && matches_label_selector(ns_obj, selector) + }) +} + +/// Checks whether a namespace's labels satisfy a label selector. +/// +/// Evaluates both `matchLabels` and `matchExpressions`. +fn matches_label_selector(ns_obj: &Namespace, selector: &GatewayListenersAllowedRoutesNamespacesSelector) -> bool { + let ns_labels = ns_obj.metadata.labels.as_ref(); + + if let Some(match_labels) = &selector.match_labels { + let Some(labels) = ns_labels else { + return false; + }; + if !match_labels + .iter() + .all(|(k, v)| labels.get(k).is_some_and(|lv| lv == v)) + { + return false; + } + } + + if let Some(expressions) = &selector.match_expressions { + let labels = ns_labels.cloned().unwrap_or_default(); + for expr in expressions { + if !evaluate_match_expression(expr, &labels) { + return false; + } + } + } + + true +} + +/// Evaluates a single label-selector match expression against a label set. +fn evaluate_match_expression( + expr: &GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions, + labels: &BTreeMap, +) -> bool { + let key = &expr.key; + let op = expr.operator.as_str(); + let values = expr.values.as_deref().unwrap_or(&[]); + let has_key = labels.contains_key(key); + let label_val = labels.get(key).map(String::as_str); + + match op { + "In" => label_val.is_some_and(|v| values.iter().any(|ev| ev == v)), + "NotIn" => label_val.is_none_or(|v| !values.iter().any(|ev| ev == v)), + "Exists" => has_key, + "DoesNotExist" => !has_key, + _ => false, + } +} + +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::controller::fixtures::{expression, listener, listener_with_namespace_policy, namespace}; + + #[test] + fn test_is_namespace_allowed_defaults_to_same() { + let l = listener("http", 80, "HTTP"); + + assert!( + is_namespace_allowed(&l, "infra", "infra", None), + "the default policy allows only the Gateway's own namespace" + ); + assert!( + !is_namespace_allowed(&l, "apps", "infra", None), + "the default policy rejects other namespaces" + ); + } + + #[test] + fn test_is_namespace_allowed_all() { + let l = listener_with_namespace_policy(GatewayListenersAllowedRoutesNamespacesFrom::All); + + assert!( + is_namespace_allowed(&l, "apps", "infra", None), + "the All policy accepts every namespace" + ); + } + + #[test] + fn test_is_namespace_allowed_selector_without_namespaces_is_denied() { + let l = listener_with_namespace_policy(GatewayListenersAllowedRoutesNamespacesFrom::Selector); + + assert!( + !is_namespace_allowed(&l, "apps", "infra", None), + "a selector policy cannot be evaluated without the namespace list" + ); + } + + #[test] + fn test_matches_label_selector_match_labels() { + let ns = namespace("apps", &[("team", "core")]); + let selector = GatewayListenersAllowedRoutesNamespacesSelector { + match_labels: Some([("team".to_owned(), "core".to_owned())].into_iter().collect()), + match_expressions: None, + }; + + assert!( + matches_label_selector(&ns, &selector), + "matching labels should satisfy the selector" + ); + } + + #[test] + fn test_matches_label_selector_rejects_missing_label() { + let ns = namespace("apps", &[]); + let selector = GatewayListenersAllowedRoutesNamespacesSelector { + match_labels: Some([("team".to_owned(), "core".to_owned())].into_iter().collect()), + match_expressions: None, + }; + + assert!( + !matches_label_selector(&ns, &selector), + "an unlabelled namespace cannot satisfy matchLabels" + ); + } + + #[test] + fn test_evaluate_match_expression_operators() { + let labels: BTreeMap = [("team".to_owned(), "core".to_owned())].into_iter().collect(); + + assert!( + evaluate_match_expression(&expression("team", "In", &["core", "infra"]), &labels), + "In should match a listed value" + ); + assert!( + !evaluate_match_expression(&expression("team", "In", &["infra"]), &labels), + "In should reject an unlisted value" + ); + assert!( + evaluate_match_expression(&expression("team", "NotIn", &["infra"]), &labels), + "NotIn should accept an unlisted value" + ); + assert!( + evaluate_match_expression(&expression("team", "Exists", &[]), &labels), + "Exists should match a present key" + ); + assert!( + evaluate_match_expression(&expression("tier", "DoesNotExist", &[]), &labels), + "DoesNotExist should match an absent key" + ); + assert!( + !evaluate_match_expression(&expression("team", "Bogus", &[]), &labels), + "an unknown operator must not match" + ); + } +} diff --git a/src/controller/ownership.rs b/src/controller/ownership.rs new file mode 100644 index 0000000..dec348f --- /dev/null +++ b/src/controller/ownership.rs @@ -0,0 +1,91 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Ownership resolution for a Gateway reconcile. +//! +//! Answers the two questions the reconciler asks before doing any work: +//! does this operator own the Gateway, and which routes does that +//! Gateway own. Both are cheap checks that short-circuit the expensive +//! path, so they run first and live together. + +use gateway_api::{gatewayclasses::GatewayClass, gateways::Gateway, httproutes::HTTPRoute}; +use kube::{Api, ResourceExt as _}; +use tracing::debug; + +use super::namespace_filter; +use crate::{ + context::CONTROLLER_NAME, + error::{OperatorError, Result}, + gateway_api::attachment::{self, AttachedRoute}, +}; + +// ----------------------------------------------------------------------------- +// GatewayClass Validation +// ----------------------------------------------------------------------------- + +/// Validates that the Gateway's `GatewayClass` exists and belongs to this +/// controller. +/// +/// Returns `Ok(true)` when the class is ours, `Ok(false)` when it belongs +/// to another controller (caller should skip), and `Err` on lookup failure +/// or missing class. +pub(super) async fn validate_gateway_class(client: &kube::Client, gw: &Gateway) -> Result { + let ns = gw.namespace().unwrap_or_default(); + let name = gw.name_any(); + let gc_name = &gw.spec.gateway_class_name; + + let gc = fetch_gateway_class(client, gc_name).await?; + + if gc.spec.controller_name != CONTROLLER_NAME { + debug!("ignoring Gateway {ns}/{name}: GatewayClass {gc_name} not ours"); + return Ok(false); + } + + Ok(true) +} + +/// Fetches a `GatewayClass` by name, mapping API errors. +async fn fetch_gateway_class(client: &kube::Client, gc_name: &str) -> Result { + let api = Api::::all(client.clone()); + api.get(gc_name).await.map_err(|e| map_gc_error(e, gc_name)) +} + +/// Maps a `GatewayClass` lookup error to an operator error. +fn map_gc_error(e: kube::Error, gc_name: &str) -> OperatorError { + if is_api_not_found(&e) { + debug!("GatewayClass {gc_name} not found"); + return OperatorError::GatewayClassNotFound(gc_name.to_owned()); + } + + debug!(%e, "GatewayClass lookup failed"); + OperatorError::Kube(e) +} + +/// Returns `true` when the error is a 404 API response. +fn is_api_not_found(e: &kube::Error) -> bool { + matches!(e, kube::Error::Api(resp) if resp.code == 404) +} + +// ----------------------------------------------------------------------------- +// Route Collection +// ----------------------------------------------------------------------------- + +/// Collects `HTTPRoute` resources attached to the Gateway, filtered by +/// namespace policies. +pub(super) async fn collect_routes<'a>( + client: &kube::Client, + gw: &Gateway, + all_routes: &'a [HTTPRoute], +) -> Vec> { + let ns = gw.namespace().unwrap_or_default(); + let name = gw.name_any(); + + let attached = attachment::attached_routes(&name, &ns, all_routes); + namespace_filter::filter_routes_by_allowed_namespaces(&attached, &gw.spec.listeners, &ns, client).await +} + +// ----------------------------------------------------------------------------- + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- diff --git a/src/controller/praxis_config.rs b/src/controller/praxis_config.rs new file mode 100644 index 0000000..0c978fd --- /dev/null +++ b/src/controller/praxis_config.rs @@ -0,0 +1,464 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Gateway spec to Praxis YAML. +//! +//! Turns the listeners and attached routes of one Gateway into the +//! configuration document the data plane reads, then applies the +//! `ConfigMap`, `Deployment`, `Service`, and `PodDisruptionBudget` that carry +//! it. The config hash computed here is what tells the Gateway +//! controller whether a rollout is still in flight. + +use std::collections::{BTreeMap, HashMap, HashSet}; + +use futures::future::try_join_all; +use gateway_api::{ + gateways::{Gateway, GatewayListeners}, + referencegrants::ReferenceGrant, +}; +use k8s_openapi::{api::core::v1::ServicePort, apimachinery::pkg::util::intstr::IntOrString}; +use kube::ResourceExt as _; +use tracing::debug; + +use crate::{ + config::{ + cluster::{PraxisCluster, build_cluster}, + filter_conversion::convert_filters, + generate::assemble_config, + listener::{PraxisCertificate, PraxisListener, PraxisTls, convert_listener}, + routing::{BackendRef, PraxisFilterEntry, PraxisRoute, convert_routes}, + weights::{ResolvedBackend, distribute_service_weights, sort_service_endpoints}, + }, + endpoints, + error::Result, + gateway_api::{attachment::AttachedRoute, listener_conflict, protocol::ListenerProtocol}, + resources::{ + configmap::build_configmap, + deployment::{DeploymentParams, build_deployment}, + disruption::build_pod_disruption_budget, + labels::child_name, + service::build_service, + }, +}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// Backend `Service` lookups issued concurrently while resolving clusters. +const MAX_CONCURRENT_BACKEND_LOOKUPS: usize = 16; + +// ----------------------------------------------------------------------------- +// Praxis Config Generation +// ----------------------------------------------------------------------------- + +/// Intermediate values produced by [`build_praxis_config`]. +pub(super) struct PraxisConfigOutput { + /// Serialized YAML configuration. + pub(super) config_yaml: String, + + /// Deduplicated `(listener_name, port)` pairs. + pub(super) listener_ports: Vec<(String, i32)>, + + /// TLS secret names referenced by HTTPS listeners (deduplicated). + pub(super) tls_secret_names: Vec, +} + +/// Converts Gateway listeners, attached routes, and resolved endpoints +/// into a complete Praxis YAML configuration string. +pub(super) async fn build_praxis_config( + client: &kube::Client, + listeners: &[GatewayListeners], + attached: &[AttachedRoute<'_>], + grants: &[ReferenceGrant], +) -> Result { + let conflicts = listener_conflict::detect_conflicts(listeners); + let supported: Vec<_> = listeners + .iter() + .filter(|l| ListenerProtocol::is_supported(&l.protocol)) + .filter(|l| !conflicts.contains_key(&l.name)) + .collect(); + + let listener_hostnames = build_listener_hostname_map(&supported); + let praxis_listeners = merge_listeners_by_port(&supported); + let (praxis_routes, backend_refs) = convert_attached_routes(attached, &listener_hostnames, grants); + let extra_filters = collect_filters(attached); + let clusters = resolve_clusters(client, &backend_refs).await?; + let config = assemble_config( + praxis_listeners, + &praxis_routes, + &clusters, + &extra_filters, + &listener_hostnames, + )?; + + Ok(PraxisConfigOutput { + config_yaml: serde_norway::to_string(&config)?, + listener_ports: collect_listener_ports(&supported), + tls_secret_names: collect_tls_secret_names(&supported), + }) +} + +/// Merges Gateway listeners on the same port into a single Praxis +/// listener, combining TLS certificates from all listeners in the group. +fn merge_listeners_by_port(supported: &[&GatewayListeners]) -> Vec { + let mut by_port: BTreeMap> = BTreeMap::new(); + for l in supported { + by_port.entry(l.port).or_default().push(l); + } + + by_port + .into_values() + .filter_map(|group| { + let first = group.first()?; + let chain_name = format!("{}-chain", first.name); + let mut listener = convert_listener(first, &chain_name); + listener.merged_section_names = group.iter().map(|l| l.name.clone()).collect(); + merge_tls_certs(&mut listener, &group); + Some(listener) + }) + .collect() +} + +/// Merges TLS certificates from all listeners in a port group. +fn merge_tls_certs(listener: &mut PraxisListener, group: &[&GatewayListeners]) { + if group.len() <= 1 { + return; + } + let mut all_certs: Vec = listener + .tls + .as_ref() + .map(|t| t.certificates.clone()) + .unwrap_or_default(); + + for l in group.iter().skip(1) { + collect_listener_certs(l, &mut all_certs); + } + + if !all_certs.is_empty() { + listener.tls = Some(PraxisTls { + certificates: all_certs, + }); + } +} + +/// Collects TLS certificates from a single listener into the cert list. +fn collect_listener_certs(l: &GatewayListeners, certs: &mut Vec) { + let Some(tls) = &l.tls else { return }; + let Some(refs) = &tls.certificate_refs else { return }; + for cert_ref in refs { + let (server_names, default) = match &l.hostname { + Some(h) => (Some(vec![h.clone()]), None), + None => (None, Some(true)), + }; + certs.push(PraxisCertificate { + cert_path: format!("/tls/{}/tls.crt", cert_ref.name), + key_path: format!("/tls/{}/tls.key", cert_ref.name), + server_names, + default, + }); + } +} + +/// Builds a map from listener section name to its hostname constraint. +fn build_listener_hostname_map(listeners: &[&GatewayListeners]) -> HashMap> { + listeners.iter().map(|l| (l.name.clone(), l.hostname.clone())).collect() +} + +/// Converts attached routes to Praxis routes and collects backend refs. +fn convert_attached_routes( + attached: &[AttachedRoute<'_>], + listener_hostnames: &HashMap>, + grants: &[ReferenceGrant], +) -> (Vec, Vec) { + convert_routes(attached, listener_hostnames, grants) +} + +/// Extracts and converts filters from all attached route rules. +fn collect_filters(attached: &[AttachedRoute<'_>]) -> Vec { + let all_rules: Vec<_> = attached + .iter() + .flat_map(|attached| attached.route.spec.rules.as_deref().unwrap_or(&[])) + .cloned() + .collect(); + convert_filters(&all_rules) +} + +/// Resolves Kubernetes endpoints for each backend ref into clusters. +/// +/// Service-level weights are normalized across endpoints so that the +/// overall traffic split matches the configured backend weights +/// regardless of how many pods each service has. +async fn resolve_clusters(client: &kube::Client, backend_refs: &[BackendRef]) -> Result> { + let resolved = resolve_backends(client, backend_refs).await?; + + let mut cluster_data: BTreeMap> = BTreeMap::new(); + for (backend, entry) in backend_refs.iter().zip(resolved) { + cluster_data + .entry(backend.cluster_name.clone()) + .or_default() + .push(entry); + } + + Ok(cluster_data + .into_iter() + .map(|(name, mut svc)| build_resolved_cluster(&name, &mut svc)) + .collect()) +} + +/// Resolves every backend ref concurrently, preserving input order. +/// +/// Order matters: it decides where each service's endpoints land in the +/// generated config, and therefore whether the config hash is stable. +async fn resolve_backends(client: &kube::Client, backend_refs: &[BackendRef]) -> Result> { + let mut resolved = Vec::with_capacity(backend_refs.len()); + + for chunk in backend_refs.chunks(MAX_CONCURRENT_BACKEND_LOOKUPS) { + let lookups = chunk.iter().map(|backend| resolve_backend(client, backend)); + resolved.extend(try_join_all(lookups).await?); + } + + Ok(resolved) +} + +/// Resolves one backend ref into its weight and ready endpoint addresses. +async fn resolve_backend(client: &kube::Client, backend: &BackendRef) -> Result { + let eps = endpoints::resolve_endpoints(client, &backend.namespace, &backend.service, backend.port).await?; + Ok((backend.weight.unwrap_or(1), eps)) +} + +/// Builds a single cluster from resolved service endpoint data. +fn build_resolved_cluster(name: &str, service_data: &mut [ResolvedBackend]) -> PraxisCluster { + sort_service_endpoints(service_data); + debug!(cluster = %name, services = service_data.len(), "resolving cluster"); + + let (eps, weights) = distribute_service_weights(service_data); + debug!(cluster = %name, endpoints = eps.len(), weights = ?weights, "distributed weights"); + + let w = if weights.is_empty() { None } else { Some(weights) }; + build_cluster(name, eps, w) +} + + +/// Deduplicates TLS secret names from HTTPS listeners. +fn collect_tls_secret_names(listeners: &[&GatewayListeners]) -> Vec { + let mut seen = HashSet::new(); + listeners + .iter() + .filter(|l| ListenerProtocol::terminates_tls(&l.protocol)) + .filter_map(|l| l.tls.as_ref()) + .flat_map(|tls| tls.certificate_refs.as_deref().unwrap_or(&[])) + .filter(|cert_ref| seen.insert(cert_ref.name.clone())) + .map(|cert_ref| cert_ref.name.clone()) + .collect() +} + +/// Deduplicates `(name, port)` pairs from supported listeners. +fn collect_listener_ports(listeners: &[&GatewayListeners]) -> Vec<(String, i32)> { + let mut seen = HashSet::new(); + listeners + .iter() + .filter(|l| seen.insert(l.port)) + .map(|l| (l.name.clone(), l.port)) + .collect() +} + +// ----------------------------------------------------------------------------- +// Child Resource Application +// ----------------------------------------------------------------------------- + +/// Applies the `ConfigMap`, `Deployment`, and `Service` child resources +/// via SSA. +/// +/// Returns the SHA-256 config hash used in the pod template annotation. +pub(super) async fn apply_child_resources( + client: &kube::Client, + gw: &Gateway, + config_output: &PraxisConfigOutput, +) -> Result { + let ns = gw.namespace().unwrap_or_default(); + let name = gw.name_any(); + let child = child_name(&name); + + let cm = build_configmap(&child, &ns, gw, &config_output.config_yaml)?; + super::gateway::apply_resource(client, &ns, &cm).await?; + + let config_hash = sha256_hex(&config_output.config_yaml); + let deploy = build_deployment(&DeploymentParams { + name: &child, + config_hash: &config_hash, + gateway: gw, + listener_ports: &config_output.listener_ports, + namespace: &ns, + tls_secret_names: &config_output.tls_secret_names, + })?; + super::gateway::apply_resource(client, &ns, &deploy).await?; + + let ports = build_service_ports(&config_output.listener_ports); + let svc = build_service(&child, &ns, gw, ports)?; + super::gateway::apply_resource(client, &ns, &svc).await?; + + let budget = build_pod_disruption_budget(&child, &ns, gw)?; + super::gateway::apply_resource(client, &ns, &budget).await?; + + Ok(config_hash) +} + +/// Converts `(name, port)` pairs into Kubernetes `ServicePort` entries. +fn build_service_ports(listener_ports: &[(String, i32)]) -> Vec { + listener_ports + .iter() + .map(|(name, port)| ServicePort { + name: Some(name.clone()), + port: *port, + protocol: Some("TCP".to_owned()), + target_port: Some(IntOrString::Int(*port)), + ..Default::default() + }) + .collect() +} + +// ----------------------------------------------------------------------------- +// Config Hashing +// ----------------------------------------------------------------------------- + +/// Returns a hex-encoded SHA-256 digest of `data`. +fn sha256_hex(data: &str) -> String { + let digest = ::digest(data.as_bytes()); + format!("{digest:x}") +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::controller::fixtures::{https_listener, listener}; + + #[test] + fn test_sha256_hex_of_empty_string() { + assert_eq!( + sha256_hex(""), + "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "SHA-256 of the empty string is a known constant" + ); + } + + #[test] + fn test_sha256_hex_is_stable_and_lowercase() { + let digest = sha256_hex("listeners: []\n"); + + assert_eq!(digest.len(), 64, "a SHA-256 digest is 64 hex characters"); + assert_eq!(digest, sha256_hex("listeners: []\n"), "hashing must be deterministic"); + assert!( + digest.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit()), + "the digest should be lowercase hex" + ); + } + + #[test] + fn test_collect_listener_ports_deduplicates_by_port() { + let listeners = [listener("http", 80, "HTTP"), listener("http-2", 80, "HTTP")]; + let refs: Vec<&GatewayListeners> = listeners.iter().collect(); + + assert_eq!( + collect_listener_ports(&refs), + vec![("http".to_owned(), 80)], + "listeners sharing a port collapse into one Service port" + ); + } + + #[test] + fn test_collect_listener_ports_keeps_distinct_ports() { + let listeners = [listener("http", 80, "HTTP"), listener("https", 443, "HTTPS")]; + let refs: Vec<&GatewayListeners> = listeners.iter().collect(); + + assert_eq!( + collect_listener_ports(&refs), + vec![("http".to_owned(), 80), ("https".to_owned(), 443)], + "distinct ports should each appear" + ); + } + + #[test] + fn test_collect_tls_secret_names_deduplicates() { + let listeners = [https_listener("a", 443, "cert"), https_listener("b", 8443, "cert")]; + let refs: Vec<_> = listeners.iter().collect(); + + assert_eq!( + collect_tls_secret_names(&refs), + vec!["cert".to_owned()], + "a secret referenced twice is mounted once" + ); + } + + #[test] + fn test_collect_tls_secret_names_ignores_http_listeners() { + let listeners = [listener("http", 80, "HTTP")]; + let refs: Vec<_> = listeners.iter().collect(); + + assert!( + collect_tls_secret_names(&refs).is_empty(), + "plain HTTP listeners have no certificates" + ); + } + + #[test] + fn test_merge_listeners_by_port_groups_section_names() { + let listeners = [listener("http", 80, "HTTP"), listener("http-alt", 80, "HTTP")]; + let refs: Vec<&GatewayListeners> = listeners.iter().collect(); + let merged = merge_listeners_by_port(&refs); + + assert_eq!(merged.len(), 1, "listeners on the same port merge into one"); + assert_eq!( + merged[0].merged_section_names, + vec!["http".to_owned(), "http-alt".to_owned()], + "the merged listener must remember every section it serves" + ); + } + + #[test] + fn test_merge_listeners_by_port_keeps_distinct_ports_separate() { + let listeners = [listener("http", 80, "HTTP"), listener("alt", 8080, "HTTP")]; + let refs: Vec<&GatewayListeners> = listeners.iter().collect(); + + assert_eq!( + merge_listeners_by_port(&refs).len(), + 2, + "listeners on distinct ports stay separate" + ); + } + + #[test] + fn test_merge_tls_certs_combines_certificates() { + let first = https_listener("a", 443, "cert-a"); + let second = https_listener("b", 443, "cert-b"); + let refs: Vec<&GatewayListeners> = vec![&first, &second]; + let mut merged = convert_listener(&first, "a-chain"); + + merge_tls_certs(&mut merged, &refs); + + assert_eq!( + merged.tls.map(|t| t.certificates.len()), + Some(2), + "both listeners' certificates should serve the shared port" + ); + } + + #[test] + fn test_build_service_ports_maps_names_and_targets() { + let ports = build_service_ports(&[("http".to_owned(), 80)]); + + assert_eq!(ports.len(), 1, "one listener port yields one Service port"); + assert_eq!(ports[0].name, Some("http".to_owned()), "the listener name is reused"); + assert_eq!(ports[0].port, 80, "the listener port is exposed"); + assert_eq!( + ports[0].target_port, + Some(IntOrString::Int(80)), + "the data plane listens on the same port" + ); + assert_eq!(ports[0].protocol, Some("TCP".to_owned()), "HTTP listeners are TCP"); + } +} diff --git a/src/controller/rollout.rs b/src/controller/rollout.rs new file mode 100644 index 0000000..079a1c8 --- /dev/null +++ b/src/controller/rollout.rs @@ -0,0 +1,102 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Data-plane rollout inspection. +//! +//! The Gateway controller must not accept routes while the `Deployment` +//! is still rolling out the previous config, so it reads the hash the +//! running pods were created with and waits for the new `ReplicaSet` to +//! become available before reporting the route as accepted. + +use k8s_openapi::api::apps::v1::{Deployment, DeploymentStatus}; +use kube::Api; + +// ----------------------------------------------------------------------------- +// Rollout State +// ----------------------------------------------------------------------------- + +/// Reads the current config hash from the Deployment's pod template. +/// +/// Returns `None` if the Deployment doesn't exist or has no hash. +pub(super) async fn current_deployment_hash(client: &kube::Client, ns: &str, child: &str) -> Option { + Api::::namespaced(client.clone(), ns) + .get(child) + .await + .ok() + .and_then(|d| { + d.spec? + .template + .metadata? + .annotations? + .get("praxis.sh/config-hash") + .cloned() + }) +} + +/// Returns `true` when the Deployment's rollout is complete. +/// +/// Uses the `Progressing` condition reason `NewReplicaSetAvailable`, +/// which the deployment controller sets only after the new +/// `ReplicaSet` has all desired pods ready. This is immune to +/// stale-status races in back-to-back reconciliations. +pub(super) async fn is_deployment_rolled_out(client: &kube::Client, ns: &str, child: &str) -> bool { + let Ok(d) = Api::::namespaced(client.clone(), ns).get(child).await else { + return false; + }; + let generation = d.metadata.generation.unwrap_or(0); + let Some(status) = d.status.as_ref() else { + return false; + }; + if status.observed_generation.unwrap_or(0) < generation { + return false; + } + is_new_rs_available(status) +} + +/// Returns `true` when the `Progressing` condition has reason +/// `NewReplicaSetAvailable`. +fn is_new_rs_available(status: &DeploymentStatus) -> bool { + status + .conditions + .as_ref() + .and_then(|c| c.iter().find(|c| c.type_ == "Progressing")) + .is_some_and(|c| c.status == "True" && c.reason.as_deref() == Some("NewReplicaSetAvailable")) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::controller::fixtures::deployment_status; + + #[test] + fn test_is_new_rs_available_true() { + let status = deployment_status("Progressing", "True", "NewReplicaSetAvailable"); + + assert!( + is_new_rs_available(&status), + "NewReplicaSetAvailable marks a finished rollout" + ); + } + + #[test] + fn test_is_new_rs_available_rejects_in_progress_rollout() { + let status = deployment_status("Progressing", "True", "ReplicaSetUpdated"); + + assert!( + !is_new_rs_available(&status), + "an updating ReplicaSet is not a finished rollout" + ); + } + + #[test] + fn test_is_new_rs_available_without_conditions() { + assert!( + !is_new_rs_available(&DeploymentStatus::default()), + "a Deployment with no conditions has not rolled out" + ); + } +} diff --git a/src/controller/route_parent_status.rs b/src/controller/route_parent_status.rs new file mode 100644 index 0000000..9458948 --- /dev/null +++ b/src/controller/route_parent_status.rs @@ -0,0 +1,169 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Writing `Accepted` and `ResolvedRefs` onto attached routes. +//! +//! The Gateway controller owns route acceptance, but only once the data +//! plane is actually serving the matching configuration — otherwise a +//! client following the status would send traffic to a proxy that has +//! not caught up yet. + +use gateway_api::{ + gateways::Gateway, + httproutes::{HTTPRoute, HttpRouteParentRefs}, + referencegrants::ReferenceGrant, +}; +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use kube::ResourceExt as _; +use serde_json::Value; + +use crate::{ + error::Result, + gateway_api::{attachment::AttachedRoute, conditions, route_status, route_validation}, +}; + +// ----------------------------------------------------------------------------- +// Route Parent Status +// ----------------------------------------------------------------------------- + +/// Updates parent status on attached `HTTPRoutes`. +/// +/// Sets `Accepted = True` and evaluates `ResolvedRefs` for each route +/// that targets this Gateway. Called by the Gateway controller **after** +/// child resources are applied and the Deployment rollout is verified, +/// so the conformance test cannot send traffic before the data plane +/// is serving the matching configuration. +pub(super) async fn update_route_parent_statuses( + client: &kube::Client, + gw: &Gateway, + attached: &[AttachedRoute<'_>], + grants: &[ReferenceGrant], +) -> Result<()> { + let gw_ns = gw.namespace().unwrap_or_default(); + let gw_name = gw.name_any(); + + for AttachedRoute { route, .. } in attached { + let route_ns = route_status::route_namespace(route); + let generation = route.metadata.generation.unwrap_or(0); + let Some(parent_refs) = &route.spec.parent_refs else { + continue; + }; + + let statuses = build_route_statuses( + route, + parent_refs, + route_ns, + &gw_name, + &gw_ns, + generation, + client, + grants, + ) + .await; + + if !statuses.is_empty() { + route_status::apply_parent_statuses(client, route, &statuses).await?; + } + } + Ok(()) +} + +/// Builds parent status entries for refs targeting this Gateway. +#[expect(clippy::too_many_arguments, reason = "route status needs full context")] +async fn build_route_statuses( + route: &HTTPRoute, + parent_refs: &[HttpRouteParentRefs], + route_ns: &str, + gw_name: &str, + gw_ns: &str, + generation: i64, + client: &kube::Client, + grants: &[ReferenceGrant], +) -> Vec { + let validation = route_validation::validate_route(route); + + let mut statuses = Vec::new(); + for parent_ref in parent_refs { + if !route_status::is_ref_targeting_gateway(parent_ref, gw_name, gw_ns, route_ns) { + continue; + } + + let resolved = route_status::check_backend_refs(route, route_ns, client, grants).await; + let resolved_cond = route_status::resolved_refs_condition(&resolved, generation); + let mut route_conditions = validation_conditions(&validation, generation); + route_conditions.push(resolved_cond); + + statuses.push(route_status::parent_status_with_conditions( + parent_ref, + gw_ns, + &route_conditions, + )); + } + statuses +} + +/// Builds the `Accepted` condition, plus `PartiallyInvalid` when only +/// some rules were dropped. +fn validation_conditions(validation: &route_validation::RouteValidation, generation: i64) -> Vec { + let detail = validation.message().unwrap_or_default(); + + if validation.is_fully_rejected() { + return vec![conditions::not_accepted(generation, "UnsupportedValue", &detail)]; + } + + let accepted = conditions::accepted(generation, "route accepted"); + if validation.is_partially_rejected() { + return vec![accepted, conditions::partially_invalid(generation, &detail)]; + } + + vec![accepted] +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::{controller::fixtures::regex_route, gateway_api::route_validation}; + + #[test] + fn test_validation_conditions_accepts_a_supported_route() { + let conds = validation_conditions(&route_validation::RouteValidation::default(), 1); + + assert_eq!(conds.len(), 1, "a supported route needs only an Accepted condition"); + assert_eq!(conds[0].type_, "Accepted", "the condition should be Accepted"); + assert_eq!(conds[0].status, "True", "a supported route is accepted"); + } + + #[test] + fn test_validation_conditions_rejects_a_fully_invalid_route() { + let route = regex_route(1); + let conds = validation_conditions(&route_validation::validate_route(&route), 1); + + assert_eq!(conds[0].type_, "Accepted", "the first condition should be Accepted"); + assert_eq!( + conds[0].status, "False", + "a route whose every rule is unsupported must not be accepted" + ); + assert_eq!( + conds[0].reason, "UnsupportedValue", + "the Gateway API reason for an unrepresentable value is UnsupportedValue" + ); + } + + #[test] + fn test_validation_conditions_marks_a_partially_invalid_route() { + let route = regex_route(2); + let conds = validation_conditions(&route_validation::validate_route(&route), 1); + + assert_eq!(conds.len(), 2, "a partially invalid route carries a second condition"); + assert_eq!(conds[0].status, "True", "surviving rules keep the route accepted"); + assert_eq!( + conds[1].type_, "PartiallyInvalid", + "dropped rules must be signalled with PartiallyInvalid" + ); + assert_eq!(conds[1].status, "True", "PartiallyInvalid should be True"); + } +} diff --git a/src/endpoints.rs b/src/endpoints.rs index 94ac9f1..5d39e89 100644 --- a/src/endpoints.rs +++ b/src/endpoints.rs @@ -267,19 +267,7 @@ fn resolve_target_port(svc: &Service, service_port: i32) -> TargetPort { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::default_trait_access, reason = "tests")] mod tests { use k8s_openapi::{ api::{ diff --git a/src/gateway_api/attachment.rs b/src/gateway_api/attachment.rs index 7ce5c61..6c72aa7 100644 --- a/src/gateway_api/attachment.rs +++ b/src/gateway_api/attachment.rs @@ -89,19 +89,7 @@ pub fn attached_routes<'a>(gateway_name: &str, gateway_ns: &str, routes: &'a [HT // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, reason = "tests")] mod tests { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; diff --git a/src/gateway_api/conditions.rs b/src/gateway_api/conditions.rs index bd51485..920eb22 100644 --- a/src/gateway_api/conditions.rs +++ b/src/gateway_api/conditions.rs @@ -83,19 +83,6 @@ pub fn conflicted(generation: i64, reason: &str, message: &str) -> Condition { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/gateway_api/hostname.rs b/src/gateway_api/hostname.rs index 28b733d..8abaddd 100644 --- a/src/gateway_api/hostname.rs +++ b/src/gateway_api/hostname.rs @@ -147,19 +147,6 @@ pub fn intersect_hostnames(route_hostnames: &[String], listener_hostnames: &[Opt // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/gateway_api/listener_conflict.rs b/src/gateway_api/listener_conflict.rs index 9367287..abe261d 100644 --- a/src/gateway_api/listener_conflict.rs +++ b/src/gateway_api/listener_conflict.rs @@ -139,19 +139,6 @@ fn hostname_key(listener: &GatewayListeners) -> String { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/gateway_api/protocol.rs b/src/gateway_api/protocol.rs index 315b0a4..3b944a7 100644 --- a/src/gateway_api/protocol.rs +++ b/src/gateway_api/protocol.rs @@ -62,19 +62,6 @@ impl ListenerProtocol { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/gateway_api/reference_grant.rs b/src/gateway_api/reference_grant.rs index 02247ae..e102abf 100644 --- a/src/gateway_api/reference_grant.rs +++ b/src/gateway_api/reference_grant.rs @@ -14,11 +14,7 @@ use gateway_api::referencegrants::ReferenceGrant; /// Returns `true` if the reference is within the same namespace or if a /// `ReferenceGrant` permits the reference. The grant must match the `from` /// (namespace, group, kind) and `to` (group, kind, optional name). -#[expect( - clippy::too_many_arguments, - clippy::too_many_lines, - reason = "params map 1:1 to Gateway API fields" -)] +/// /// ``` /// use praxis_operator::gateway_api::reference_grant::is_reference_allowed; /// @@ -46,6 +42,11 @@ use gateway_api::referencegrants::ReferenceGrant; /// &[], /// )); /// ``` +#[expect( + clippy::too_many_arguments, + clippy::too_many_lines, + reason = "params map 1:1 to Gateway API fields" +)] pub fn is_reference_allowed( from_ns: &str, from_group: &str, @@ -101,19 +102,7 @@ pub fn is_reference_allowed( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, reason = "tests")] mod tests { use gateway_api::referencegrants::{ReferenceGrantFrom, ReferenceGrantSpec, ReferenceGrantTo}; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; diff --git a/src/gateway_api/route_status.rs b/src/gateway_api/route_status.rs index 0e47b7c..2688235 100644 --- a/src/gateway_api/route_status.rs +++ b/src/gateway_api/route_status.rs @@ -21,7 +21,7 @@ use serde_json::{Value, json}; use tracing::debug; use crate::{ - context::CONTROLLER_NAME, + context::{CONTROLLER_NAME, FIELD_MANAGER}, error::Result, gateway_api::{conditions, reference_grant, status}, observability::metrics, @@ -31,9 +31,6 @@ use crate::{ // Constants // ----------------------------------------------------------------------------- -/// Field manager used for every server-side apply the operator issues. -const FIELD_MANAGER: &str = "praxis-operator"; - /// API group owning `Gateway` and `HTTPRoute`. const GATEWAY_GROUP: &str = "gateway.networking.k8s.io"; @@ -406,19 +403,6 @@ fn find_by_parent_ref<'a>(entries: &'a [Value], target: &Value) -> Option<&'a Va // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use gateway_api::{ httproutes::{HttpRouteSpec, HttpRouteStatus, HttpRouteStatusParents, HttpRouteStatusParentsParentRef}, diff --git a/src/gateway_api/route_validation.rs b/src/gateway_api/route_validation.rs index 6ee8c29..30ec418 100644 --- a/src/gateway_api/route_validation.rs +++ b/src/gateway_api/route_validation.rs @@ -202,19 +202,7 @@ fn is_supported_filter(kind: &HttpRouteRulesFiltersType) -> bool { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::default_trait_access, reason = "tests")] mod tests { use gateway_api::httproutes::{ HttpRouteRulesFilters, HttpRouteRulesMatchesHeaders, HttpRouteRulesMatchesPath, diff --git a/src/gateway_api/status.rs b/src/gateway_api/status.rs index ad746e7..6a45359 100644 --- a/src/gateway_api/status.rs +++ b/src/gateway_api/status.rs @@ -160,19 +160,6 @@ fn is_empty_list(value: &Value) -> bool { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use serde_json::json; diff --git a/src/leader.rs b/src/leader.rs index 2773f63..ed69a8c 100644 --- a/src/leader.rs +++ b/src/leader.rs @@ -18,7 +18,10 @@ use kube::{ }; use tracing::{debug, info, warn}; -use crate::error::{OperatorError, Result}; +use crate::{ + context::FIELD_MANAGER, + error::{OperatorError, Result}, +}; // ----------------------------------------------------------------------------- // Constants @@ -42,9 +45,6 @@ const RENEW_INTERVAL: Duration = Duration::from_secs(5); /// How often a non-holder re-checks whether the lease has expired. const RETRY_INTERVAL: Duration = Duration::from_secs(3); -/// Field manager for lease writes. -const FIELD_MANAGER: &str = "praxis-operator"; - // ----------------------------------------------------------------------------- // Identity // ----------------------------------------------------------------------------- @@ -219,19 +219,6 @@ fn lease_patch(identity: &str, now: Timestamp, transitions: i32) -> serde_json:: // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use k8s_openapi::api::coordination::v1::LeaseSpec; diff --git a/src/listing.rs b/src/listing.rs index 8e205df..ed70639 100644 --- a/src/listing.rs +++ b/src/listing.rs @@ -56,19 +56,6 @@ where // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/observability/metrics.rs b/src/observability/metrics.rs index 1c72067..c0de1a1 100644 --- a/src/observability/metrics.rs +++ b/src/observability/metrics.rs @@ -199,19 +199,6 @@ fn write_scalar(f: &mut fmt::Formatter<'_>, name: &str, help: &str, value: u64) // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/observability/server.rs b/src/observability/server.rs index 486c3e6..cb32c50 100644 --- a/src/observability/server.rs +++ b/src/observability/server.rs @@ -158,19 +158,6 @@ fn text_response(status: u16, body: &str) -> String { // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] mod tests { use super::*; diff --git a/src/resources/configmap.rs b/src/resources/configmap.rs index aa2294f..0e232eb 100644 --- a/src/resources/configmap.rs +++ b/src/resources/configmap.rs @@ -50,19 +50,7 @@ pub fn build_configmap( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, clippy::default_trait_access, reason = "tests")] mod tests { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index e6f28f6..784eb08 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -396,19 +396,7 @@ fn build_deployment_object( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, clippy::default_trait_access, reason = "tests")] mod tests { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; diff --git a/src/resources/disruption.rs b/src/resources/disruption.rs index 106e13a..b014752 100644 --- a/src/resources/disruption.rs +++ b/src/resources/disruption.rs @@ -64,19 +64,7 @@ pub fn build_pod_disruption_budget( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::default_trait_access, reason = "tests")] mod tests { use super::*; diff --git a/src/resources/labels.rs b/src/resources/labels.rs index 3d6e32b..4f9692d 100644 --- a/src/resources/labels.rs +++ b/src/resources/labels.rs @@ -60,19 +60,7 @@ pub fn owner_reference(gateway: &gateway_api::gateways::Gateway) -> crate::error // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::default_trait_access, reason = "tests")] mod tests { use super::*; diff --git a/src/resources/service.rs b/src/resources/service.rs index e5b9164..e148353 100644 --- a/src/resources/service.rs +++ b/src/resources/service.rs @@ -54,19 +54,7 @@ pub fn build_service( // ----------------------------------------------------------------------------- #[cfg(test)] -#[allow( - clippy::allow_attributes, - clippy::unwrap_used, - clippy::expect_used, - clippy::panic, - clippy::indexing_slicing, - clippy::too_many_lines, - clippy::cognitive_complexity, - clippy::default_trait_access, - clippy::match_wildcard_for_single_variants, - clippy::missing_assert_message, - reason = "tests" -)] +#[expect(clippy::too_many_lines, clippy::default_trait_access, reason = "tests")] mod tests { use k8s_openapi::apimachinery::pkg::{apis::meta::v1::ObjectMeta, util::intstr::IntOrString}; From 8dcd64375ff5a06c0fea6340ca44a8df9e1128ca Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:33:17 -0400 Subject: [PATCH 21/51] chore(refactor): serve cluster-wide reads from watch caches instead of listing Signed-off-by: Shane Utt --- benches/config_generation.rs | 12 +- src/context.rs | 5 + src/controller/gateway.rs | 37 +--- src/controller/gateway_status.rs | 15 +- src/controller/httproute.rs | 17 +- src/controller/listener_validation.rs | 31 +-- src/controller/namespace_filter.rs | 23 +- src/controller/ownership.rs | 11 +- src/error.rs | 4 + src/gateway_api/attachment.rs | 16 +- src/lib.rs | 3 +- src/listing.rs | 83 ------- src/stores.rs | 303 ++++++++++++++++++++++++++ 13 files changed, 373 insertions(+), 187 deletions(-) delete mode 100644 src/listing.rs create mode 100644 src/stores.rs diff --git a/benches/config_generation.rs b/benches/config_generation.rs index cdea6e0..4547751 100644 --- a/benches/config_generation.rs +++ b/benches/config_generation.rs @@ -21,11 +21,11 @@ reason = "criterion_group and criterion_main generate undocumented items" )] -use std::{collections::HashMap, hint::black_box}; +use std::{collections::HashMap, hint::black_box, sync::Arc}; use criterion::{Criterion, criterion_group, criterion_main}; use fixtures::{GATEWAY_NAME, GATEWAY_NAMESPACE, listener_manifests, route_manifests}; -use gateway_api::gateways::GatewayListeners; +use gateway_api::{gateways::GatewayListeners, httproutes::HTTPRoute}; use praxis_operator::{ config::{ cluster::{PraxisCluster, build_cluster}, @@ -98,7 +98,7 @@ criterion_main!(benches); /// Runs the synchronous half of `build_praxis_config` and returns the /// serialized length, which keeps the optimizer from eliding the work. -fn generate_config(listeners: &[GatewayListeners], routes: &[gateway_api::httproutes::HTTPRoute]) -> usize { +fn generate_config(listeners: &[GatewayListeners], routes: &[Arc]) -> usize { let attached = attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, routes); let listener_hostnames: HashMap> = listeners.iter().map(|l| (l.name.clone(), l.hostname.clone())).collect(); @@ -137,6 +137,8 @@ fn synthesize_clusters(backend_refs: &[BackendRef]) -> Vec { /// Gateway API manifests the benchmark converts. mod fixtures { + use std::sync::Arc; + use gateway_api::{ gateways::GatewayListeners, httproutes::{ @@ -166,8 +168,8 @@ mod fixtures { } /// Builds `count` routes, each with one match and one backend. - pub(super) fn route_manifests(count: usize) -> Vec { - (0..count).map(build_route).collect() + pub(super) fn route_manifests(count: usize) -> Vec> { + (0..count).map(|index| Arc::new(build_route(index))).collect() } /// Builds one route with a distinct path, hostname, and backend. diff --git a/src/context.rs b/src/context.rs index a3fdf75..8952c26 100644 --- a/src/context.rs +++ b/src/context.rs @@ -8,6 +8,8 @@ use kube::{ runtime::events::{Recorder, Reporter}, }; +use crate::stores::Stores; + // ----------------------------------------------------------------------------- // Constants // ----------------------------------------------------------------------------- @@ -55,6 +57,9 @@ pub struct Context { /// Publishes Kubernetes events for user-visible decisions. pub recorder: Recorder, + + /// Cluster-wide caches read in place of per-reconcile listing. + pub stores: Stores, } /// Builds the event reporter identifying this operator. diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index b6eaaae..34c564e 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -24,7 +24,6 @@ use crate::{ context::{Context, GATEWAY_FINALIZER}, error::{OperatorError, Result}, gateway_api::{attachment::AttachedRoute, conditions, protocol::ListenerProtocol, route_status}, - listing, }; // ----------------------------------------------------------------------------- @@ -61,7 +60,7 @@ pub async fn reconcile(gw: Arc, ctx: Arc) -> Result { match event { FinalizerEvent::Apply(gw) => Box::pin(apply(gw, &ctx)).await, FinalizerEvent::Cleanup(gw) => { - cleanup(&gw, &ctx.client).await; + cleanup(&gw, &ctx).await; Ok(Action::await_change()) }, } @@ -102,13 +101,13 @@ async fn apply(gw: Arc, ctx: &Context) -> Result { return Ok(Action::await_change()); } - let routes = list_all_routes(&ctx.client).await?; - let attached = ownership::collect_routes(&ctx.client, &gw, &routes).await; + let routes = ctx.stores.routes(); + let attached = ownership::collect_routes(&gw, &routes, &ctx.stores); let ns = gw.namespace().unwrap_or_default(); - let grants = list_all_grants(&ctx.client).await?; + let grants = ctx.stores.grants(); let config_changed = apply_config_if_supported(&ctx.client, &gw, &attached, &ns, &grants).await?; - gateway_status::build_and_apply_gateway_status(&ctx.client, &gw, &gw.spec.listeners, &attached).await?; + gateway_status::build_and_apply_gateway_status(ctx, &gw, &gw.spec.listeners, &attached).await?; let can_accept = can_accept_routes(&ctx.client, &gw, &ns, config_changed).await; if can_accept { @@ -165,16 +164,6 @@ async fn can_accept_routes(client: &kube::Client, gw: &Gateway, ns: &str, config can_accept } -/// Lists all `HTTPRoute` resources across all namespaces. -async fn list_all_routes(client: &kube::Client) -> Result> { - listing::list_all(&Api::::all(client.clone())).await -} - -/// Lists all `ReferenceGrant` resources across all namespaces. -async fn list_all_grants(client: &kube::Client) -> Result> { - listing::list_all(&Api::::all(client.clone())).await -} - /// Rejects a Gateway whose spec this operator cannot honour. /// /// Returns `true` when the Gateway was rejected and the caller should @@ -249,7 +238,7 @@ fn has_requested_addresses(gw: &Gateway) -> bool { // ----------------------------------------------------------------------------- /// Cleanup path: owner references handle child deletion automatically. -async fn cleanup(gw: &Gateway, client: &kube::Client) { +async fn cleanup(gw: &Gateway, ctx: &Context) { let name = gw.name_any(); let ns = gw.namespace().unwrap_or_else(|| { tracing::warn!(gateway = %name, "Gateway has no namespace during cleanup"); @@ -257,7 +246,7 @@ async fn cleanup(gw: &Gateway, client: &kube::Client) { }); info!("cleaning up Gateway {ns}/{name} (owner refs handle child deletion)"); - clear_route_parent_statuses(client, &name, &ns).await; + clear_route_parent_statuses(ctx, &name, &ns).await; } /// Removes this Gateway's entries from every route that referenced it. @@ -269,16 +258,10 @@ async fn cleanup(gw: &Gateway, client: &kube::Client) { /// Failures are logged rather than propagated: a Gateway must always be /// able to finish deleting, and a route left with a stale entry is a /// smaller problem than a finalizer that never releases. -async fn clear_route_parent_statuses(client: &kube::Client, gw_name: &str, gw_ns: &str) { - let routes = match list_all_routes(client).await { - Ok(routes) => routes, - Err(e) => { - tracing::warn!(%e, "could not list routes to clear parent status for {gw_ns}/{gw_name}"); - return; - }, - }; +async fn clear_route_parent_statuses(ctx: &Context, gw_name: &str, gw_ns: &str) { + let client = &ctx.client; - for route in &routes { + for route in &ctx.stores.routes() { if let Err(e) = route_status::clear_parent_statuses(client, route, gw_name, gw_ns).await { tracing::warn!(%e, route = route.name_any(), "could not clear parent status"); } diff --git a/src/controller/gateway_status.rs b/src/controller/gateway_status.rs index 85e30b5..9cb1f9d 100644 --- a/src/controller/gateway_status.rs +++ b/src/controller/gateway_status.rs @@ -22,7 +22,7 @@ use tracing::{debug, info}; use super::listener_validation; use crate::{ - context::FIELD_MANAGER, + context::{Context, FIELD_MANAGER}, error::Result, gateway_api::{ attachment::AttachedRoute, conditions, hostname, listener_conflict, protocol::ListenerProtocol, status, @@ -40,11 +40,12 @@ use crate::{ /// Gates the `Programmed` condition on both Deployment readiness and /// load-balancer address availability, per the Gateway API spec. pub(super) async fn build_and_apply_gateway_status( - client: &kube::Client, + ctx: &Context, gw: &Gateway, listeners: &[GatewayListeners], attached: &[AttachedRoute<'_>], ) -> Result<()> { + let client = &ctx.client; let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); let generation = gw.metadata.generation.unwrap_or(1); @@ -53,7 +54,7 @@ pub(super) async fn build_and_apply_gateway_status( let addresses = resolve_lb_addresses(client, &ns, &child).await; let deployment_ready = is_deployment_ready(client, &ns, &child).await; let (listener_statuses, any_accepted, any_rejected) = - build_listener_statuses(listeners, generation, &ns, client, attached).await; + build_listener_statuses(listeners, generation, &ns, ctx, attached).await; let data_plane_ready = deployment_ready && !addresses.is_empty(); let status = gateway_status_json(&GatewayStatusParts { @@ -169,7 +170,7 @@ async fn build_listener_statuses( listeners: &[GatewayListeners], generation: i64, gateway_ns: &str, - client: &kube::Client, + ctx: &Context, attached: &[AttachedRoute<'_>], ) -> (Vec, bool, bool) { let conflicts = listener_conflict::detect_conflicts(listeners); @@ -193,7 +194,7 @@ async fn build_listener_statuses( any_accepted = true; let count = count_attached_routes(attached, l); - let status = accepted_listener_status(l, generation, gateway_ns, client, count).await; + let status = accepted_listener_status(l, generation, gateway_ns, ctx, count).await; statuses.push(status); } @@ -266,11 +267,11 @@ async fn accepted_listener_status( l: &GatewayListeners, generation: i64, gateway_ns: &str, - client: &kube::Client, + ctx: &Context, count: usize, ) -> Value { let (supported_kinds, resolved_refs_condition) = - listener_validation::listener_resolved_refs(l, generation, gateway_ns, client).await; + listener_validation::listener_resolved_refs(l, generation, gateway_ns, ctx).await; let refs_resolved = resolved_refs_condition.status == "True"; let programmed_condition = if refs_resolved { diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index f4dadba..3798fea 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -14,12 +14,11 @@ use gateway_api::{ gatewayclasses::GatewayClass, gateways::{Gateway, GatewayListeners, GatewayListenersAllowedRoutesNamespacesSelector}, httproutes::{HTTPRoute, HttpRouteParentRefs}, - referencegrants::ReferenceGrant, }; use k8s_openapi::{api::core::v1::Namespace, apimachinery::pkg::apis::meta::v1::Condition}; use kube::{Api, ResourceExt as _, runtime::controller::Action}; use serde_json::Value; -use tracing::{debug, error, info, warn}; +use tracing::{debug, error, info}; use crate::{ context::{CONTROLLER_NAME, Context}, @@ -119,7 +118,7 @@ async fn build_rejection_status( } let rejection = validate_listener_attachment(route, &gw, parent_ref, generation, &ctx.client).await; - let grants = list_reference_grants(ctx).await; + let grants = ctx.stores.grants(); let resolve_result = route_status::check_backend_refs(route, route_ns, &ctx.client, &grants).await; let resolved = route_status::resolved_refs_condition(&resolve_result, generation); @@ -154,18 +153,6 @@ async fn lookup_parent_gateway(gw_name: &str, gw_ns: &str, route_ns: &str, ctx: None } -/// Lists all [`ReferenceGrant`] resources in the cluster. -async fn list_reference_grants(ctx: &Context) -> Vec { - let grant_api = Api::::all(ctx.client.clone()); - match crate::listing::list_all(&grant_api).await { - Ok(grants) => grants, - Err(e) => { - warn!(%e, "failed to list ReferenceGrants"); - Vec::new() - }, - } -} - // ----------------------------------------------------------------------------- // Validation // ----------------------------------------------------------------------------- diff --git a/src/controller/listener_validation.rs b/src/controller/listener_validation.rs index 1098ea5..2275682 100644 --- a/src/controller/listener_validation.rs +++ b/src/controller/listener_validation.rs @@ -20,9 +20,8 @@ use kube::Api; use serde_json::{Value, json}; use crate::{ - error::Result, + context::Context, gateway_api::{conditions, reference_grant}, - listing, }; // ----------------------------------------------------------------------------- @@ -37,7 +36,7 @@ pub(super) async fn listener_resolved_refs( listener: &GatewayListeners, generation: i64, gateway_ns: &str, - client: &kube::Client, + ctx: &Context, ) -> (Vec, Condition) { let (supported, kinds_invalid) = validate_route_kinds(listener); @@ -48,7 +47,7 @@ pub(super) async fn listener_resolved_refs( ); } - if let Some(condition) = validate_tls_cert_refs(listener, generation, gateway_ns, client).await { + if let Some(condition) = validate_tls_cert_refs(listener, generation, gateway_ns, ctx).await { return (supported, condition); } @@ -93,7 +92,7 @@ async fn validate_tls_cert_refs( listener: &GatewayListeners, generation: i64, gateway_ns: &str, - client: &kube::Client, + ctx: &Context, ) -> Option { let cert_refs = listener.tls.as_ref()?.certificate_refs.as_ref()?; @@ -106,10 +105,10 @@ async fn validate_tls_cert_refs( )); } let secret_ns = cert_ref.namespace.as_deref().unwrap_or(gateway_ns); - if let Some(c) = check_cross_ns_grant(client, generation, gateway_ns, secret_ns, &cert_ref.name).await { + if let Some(c) = check_cross_ns_grant(ctx, generation, gateway_ns, secret_ns, &cert_ref.name) { return Some(c); } - if let Some(c) = check_secret_contents(client, generation, secret_ns, &cert_ref.name).await { + if let Some(c) = check_secret_contents(&ctx.client, generation, secret_ns, &cert_ref.name).await { return Some(c); } } @@ -127,8 +126,8 @@ fn is_secret_cert_ref(cert_ref: &GatewayListenersTlsCertificateRefs) -> bool { /// /// Returns `Some(condition)` when the reference is denied, `None` when /// allowed or same-namespace. -async fn check_cross_ns_grant( - client: &kube::Client, +fn check_cross_ns_grant( + ctx: &Context, generation: i64, gateway_ns: &str, secret_ns: &str, @@ -138,13 +137,7 @@ async fn check_cross_ns_grant( return None; } - let Ok(grants) = list_reference_grants(client, secret_ns).await else { - return Some(conditions::unresolved_refs( - generation, - "RefNotPermitted", - "cannot verify cross-namespace grant", - )); - }; + let grants = ctx.stores.grants_in(secret_ns); if is_secret_ref_granted(gateway_ns, secret_ns, secret_name, &grants) { return None; @@ -157,12 +150,6 @@ async fn check_cross_ns_grant( )) } -/// Lists `ReferenceGrant` resources in the given namespace. -async fn list_reference_grants(client: &kube::Client, ns: &str) -> Result> { - let api = Api::::namespaced(client.clone(), ns); - listing::list_all(&api).await -} - /// Checks whether a Gateway-to-Secret cross-namespace ref is allowed. fn is_secret_ref_granted(gateway_ns: &str, secret_ns: &str, secret_name: &str, grants: &[ReferenceGrant]) -> bool { reference_grant::is_reference_allowed( diff --git a/src/controller/namespace_filter.rs b/src/controller/namespace_filter.rs index de1df98..3314177 100644 --- a/src/controller/namespace_filter.rs +++ b/src/controller/namespace_filter.rs @@ -18,12 +18,10 @@ use gateway_api::{ httproutes::HTTPRoute, }; use k8s_openapi::api::core::v1::Namespace; -use kube::Api; -use tracing::warn; use crate::{ gateway_api::{attachment::AttachedRoute, route_status}, - listing, + stores::Stores, }; // ----------------------------------------------------------------------------- @@ -35,13 +33,13 @@ use crate::{ /// /// A route is retained if at least one listener it targets allows its /// namespace. The default policy (when unspecified) is `Same`. -pub(super) async fn filter_routes_by_allowed_namespaces<'a>( +pub(super) fn filter_routes_by_allowed_namespaces<'a>( attached: &[AttachedRoute<'a>], listeners: &[GatewayListeners], gateway_ns: &str, - client: &kube::Client, + stores: &Stores, ) -> Vec> { - let all_namespaces = fetch_all_namespaces(client).await; + let all_namespaces = Some(stores.namespaces()); attached .iter() @@ -58,17 +56,6 @@ pub(super) async fn filter_routes_by_allowed_namespaces<'a>( .collect() } -/// Fetches all namespaces from the cluster, returning `None` on error. -async fn fetch_all_namespaces(client: &kube::Client) -> Option> { - match listing::list_all(&Api::::all(client.clone())).await { - Ok(namespaces) => Some(namespaces), - Err(e) => { - warn!(%e, "failed to list namespaces for route filtering"); - None - }, - } -} - /// Checks whether a route is allowed by at least one targeted listener. fn route_allowed_by_any_listener( route: &HTTPRoute, @@ -92,7 +79,7 @@ fn route_allowed_by_any_listener( /// Checks whether a route namespace is allowed by a listener's policy. /// /// Defaults to `Same` when `allowedRoutes` is unspecified. -fn is_namespace_allowed( +pub(super) fn is_namespace_allowed( listener: &GatewayListeners, route_ns: &str, gateway_ns: &str, diff --git a/src/controller/ownership.rs b/src/controller/ownership.rs index dec348f..0897a0e 100644 --- a/src/controller/ownership.rs +++ b/src/controller/ownership.rs @@ -8,6 +8,8 @@ //! Gateway own. Both are cheap checks that short-circuit the expensive //! path, so they run first and live together. +use std::sync::Arc; + use gateway_api::{gatewayclasses::GatewayClass, gateways::Gateway, httproutes::HTTPRoute}; use kube::{Api, ResourceExt as _}; use tracing::debug; @@ -17,6 +19,7 @@ use crate::{ context::CONTROLLER_NAME, error::{OperatorError, Result}, gateway_api::attachment::{self, AttachedRoute}, + stores::Stores, }; // ----------------------------------------------------------------------------- @@ -72,16 +75,16 @@ fn is_api_not_found(e: &kube::Error) -> bool { /// Collects `HTTPRoute` resources attached to the Gateway, filtered by /// namespace policies. -pub(super) async fn collect_routes<'a>( - client: &kube::Client, +pub(super) fn collect_routes<'a>( gw: &Gateway, - all_routes: &'a [HTTPRoute], + all_routes: &'a [Arc], + stores: &Stores, ) -> Vec> { let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); let attached = attachment::attached_routes(&name, &ns, all_routes); - namespace_filter::filter_routes_by_allowed_namespaces(&attached, &gw.spec.listeners, &ns, client).await + namespace_filter::filter_routes_by_allowed_namespaces(&attached, &gw.spec.listeners, &ns, stores) } // ----------------------------------------------------------------------------- diff --git a/src/error.rs b/src/error.rs index c1b913b..cfb65e1 100644 --- a/src/error.rs +++ b/src/error.rs @@ -30,6 +30,10 @@ pub enum OperatorError { #[error("leadership lost to another replica")] LeadershipLost, + /// A watch cache never completed its initial sync. + #[error("cache for {0} did not sync; check list/watch permission on that kind")] + CacheSync(&'static str), + /// Serialization failed. #[error("serialization: {0}")] Serialization(#[from] serde_json::Error), diff --git a/src/gateway_api/attachment.rs b/src/gateway_api/attachment.rs index 6c72aa7..7ff4f0a 100644 --- a/src/gateway_api/attachment.rs +++ b/src/gateway_api/attachment.rs @@ -3,6 +3,8 @@ //! Route attachment logic for `HTTPRoute` `parentRefs`. +use std::sync::Arc; + use gateway_api::httproutes::{HTTPRoute, HttpRouteParentRefs}; // ----------------------------------------------------------------------------- @@ -61,7 +63,11 @@ pub fn parent_ref_matches_gateway( /// /// Each tuple contains a route and a vector of section names (one per matching /// parentRef). A `None` section name means the route attaches to all listeners. -pub fn attached_routes<'a>(gateway_name: &str, gateway_ns: &str, routes: &'a [HTTPRoute]) -> Vec> { +pub fn attached_routes<'a>( + gateway_name: &str, + gateway_ns: &str, + routes: &'a [Arc], +) -> Vec> { let mut result = Vec::new(); for route in routes { @@ -195,7 +201,7 @@ mod tests { status: None, }; - let routes = vec![route]; + let routes = vec![Arc::new(route)]; let attached = attached_routes("test-gateway", "default", &routes); assert_eq!(attached.len(), 1, "one route should be attached"); @@ -229,7 +235,7 @@ mod tests { status: None, }; - let routes = vec![route]; + let routes = vec![Arc::new(route)]; let attached = attached_routes("test-gateway", "default", &routes); assert_eq!(attached.len(), 1, "one route should be attached"); @@ -272,7 +278,7 @@ mod tests { status: None, }; - let routes = vec![route]; + let routes = vec![Arc::new(route)]; let attached = attached_routes("test-gateway", "default", &routes); assert_eq!(attached.len(), 1, "one route should be attached"); @@ -313,7 +319,7 @@ mod tests { status: None, }; - let routes = vec![route]; + let routes = vec![Arc::new(route)]; let attached = attached_routes("test-gateway", "default", &routes); assert!(attached.is_empty(), "no routes should be attached"); diff --git a/src/lib.rs b/src/lib.rs index 55434f6..c9543b8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -16,9 +16,9 @@ pub mod endpoints; pub mod error; pub mod gateway_api; pub mod leader; -pub mod listing; pub mod observability; pub mod resources; +pub mod stores; use std::{future::Future, sync::Arc}; @@ -101,6 +101,7 @@ async fn run_controllers(client: &Client, identity: &str) -> error::Result<()> { let ctx = Arc::new(context::Context { client: client.clone(), recorder: kube::runtime::events::Recorder::new(client.clone(), context::reporter()), + stores: stores::Stores::spawn(client).await?, }); let gc = build_gc_controller(client, Arc::clone(&ctx)); let gw = build_gw_controller(client, Arc::clone(&ctx)); diff --git a/src/listing.rs b/src/listing.rs deleted file mode 100644 index ed70639..0000000 --- a/src/listing.rs +++ /dev/null @@ -1,83 +0,0 @@ -// SPDX-License-Identifier: MIT -// Copyright (c) 2026 Shane Utt - -//! Paginated collection listing. -//! -//! An unbounded `LIST` asks the API server to marshal every object of a -//! kind into one response. On a large cluster that is a multi-megabyte -//! body the operator must hold entirely in memory, and one the API -//! server may refuse outright. Every cluster-wide read goes through -//! here so the cost stays bounded by page size rather than cluster size. - -use kube::{Api, api::ListParams}; -use serde::de::DeserializeOwned; - -use crate::error::Result; - -// ----------------------------------------------------------------------------- -// Constants -// ----------------------------------------------------------------------------- - -/// Objects requested per `LIST` page. -const PAGE_SIZE: u32 = 500; - -// ----------------------------------------------------------------------------- -// Listing -// ----------------------------------------------------------------------------- - -/// Lists every object the API exposes, following continuation tokens. -/// -/// # Errors -/// -/// Returns an error if any page request fails. A partial listing is -/// never returned: a caller acting on half a cluster's routes would -/// generate a config that silently drops the rest. -pub async fn list_all(api: &Api) -> Result> -where - K: Clone + std::fmt::Debug + DeserializeOwned, -{ - let mut params = ListParams::default().limit(PAGE_SIZE); - let mut items = Vec::new(); - - loop { - let page = api.list(¶ms).await?; - let next = page.metadata.continue_.clone(); - items.extend(page.items); - - match next.filter(|token| !token.is_empty()) { - Some(token) => params = params.continue_token(&token), - None => return Ok(items), - } - } -} - -// ----------------------------------------------------------------------------- -// Tests -// ----------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_list_params_carry_the_page_limit() { - let params = ListParams::default().limit(PAGE_SIZE); - - assert_eq!( - params.limit, - Some(PAGE_SIZE), - "the limit must reach the API server or the listing stays unbounded" - ); - } - - #[test] - fn test_continue_token_is_threaded_into_params() { - let params = ListParams::default().limit(PAGE_SIZE).continue_token("abc"); - - assert_eq!( - params.continue_token.as_deref(), - Some("abc"), - "the continuation token must be carried into the next page request" - ); - } -} diff --git a/src/stores.rs b/src/stores.rs new file mode 100644 index 0000000..d78cba7 --- /dev/null +++ b/src/stores.rs @@ -0,0 +1,303 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Cluster-wide caches the reconcilers read instead of listing. +//! +//! Reconciling one Gateway needs to see every `HTTPRoute` in the +//! cluster (any of them may name it as a parent), every +//! `ReferenceGrant` (any of them may authorize one of its backends), +//! and every `Namespace` (its listeners may select routes by namespace +//! label). Fetching those with a `LIST` meant three cluster-wide reads +//! per Gateway per reconcile — so a cluster with G Gateways and R +//! routes paid O(G × R) object deserializations every resync, and a +//! single route edit fanned out to a full re-read for every Gateway. +//! +//! A reflector turns that into one watch connection per kind, held for +//! the operator's lifetime, feeding an in-memory store. Reconcile-time +//! reads become local. +//! +//! The stores are populated by their own watches rather than by the +//! controller's `watches()` triggers, which do not expose a store on +//! stable kube-runtime. That costs one extra connection per kind and +//! buys back a `LIST` per reconcile, which is not a close trade. + +use std::{fmt::Debug, hash::Hash, sync::Arc, time::Duration}; + +use futures::StreamExt as _; +use gateway_api::{httproutes::HTTPRoute, referencegrants::ReferenceGrant}; +use k8s_openapi::api::core::v1::Namespace; +use kube::{ + Api, Client, Resource, + runtime::{WatchStreamExt as _, reflector, watcher}, +}; +use serde::de::DeserializeOwned; +use tracing::{info, warn}; + +use crate::error::{OperatorError, Result}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// How long to wait for a store's first full sync before giving up. +/// +/// A store that never syncs is worse than a failed start: reconcilers +/// would read an empty cache and rewrite every Gateway's config as if +/// the cluster had no routes. Exiting lets the pod crash-loop with a +/// visible reason instead. +const INITIAL_SYNC_TIMEOUT: Duration = Duration::from_secs(60); + +// ----------------------------------------------------------------------------- +// Stores +// ----------------------------------------------------------------------------- + +/// Read-only caches shared by every reconciler. +#[derive(Clone)] +pub struct Stores { + /// Every `HTTPRoute` in the cluster. + routes: reflector::Store, + + /// Every `ReferenceGrant` in the cluster. + grants: reflector::Store, + + /// Every `Namespace` in the cluster. + namespaces: reflector::Store, +} + +impl Stores { + /// Starts a reflector per kind and waits for each to sync. + /// + /// # Errors + /// + /// Returns an error if any store fails to complete its initial + /// sync within one minute, which usually means the operator lacks + /// list/watch permission on that kind. + pub async fn spawn(client: &Client) -> Result { + let stores = Self { + routes: spawn_reflector(Api::::all(client.clone()), "HTTPRoute"), + grants: spawn_reflector(Api::::all(client.clone()), "ReferenceGrant"), + namespaces: spawn_reflector(Api::::all(client.clone()), "Namespace"), + }; + + wait_ready(&stores.routes, "HTTPRoute").await?; + wait_ready(&stores.grants, "ReferenceGrant").await?; + wait_ready(&stores.namespaces, "Namespace").await?; + + info!( + routes = stores.routes.len(), + grants = stores.grants.len(), + namespaces = stores.namespaces.len(), + "caches synced" + ); + Ok(stores) + } + + /// Returns every cached `HTTPRoute`. + /// + /// Handed out as `Arc`s because the route set is the one that grows + /// with the cluster; cloning it per Gateway reconcile would give + /// back much of what the cache saves. + pub fn routes(&self) -> Vec> { + self.routes.state() + } + + /// Returns every cached `ReferenceGrant`. + pub fn grants(&self) -> Vec { + cloned(&self.grants) + } + + /// Returns cached `ReferenceGrants` in one namespace. + pub fn grants_in(&self, namespace: &str) -> Vec { + self.grants + .state() + .iter() + .filter(|grant| grant.metadata.namespace.as_deref() == Some(namespace)) + .map(|grant| (**grant).clone()) + .collect() + } + + /// Returns every cached `Namespace`. + pub fn namespaces(&self) -> Vec { + cloned(&self.namespaces) + } +} + +impl Debug for Stores { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Stores") + .field("routes", &self.routes.len()) + .field("grants", &self.grants.len()) + .field("namespaces", &self.namespaces.len()) + .finish() + } +} + +// ----------------------------------------------------------------------------- +// Reflectors +// ----------------------------------------------------------------------------- + +/// Clones a store's contents out of its `Arc`s. +/// +/// Used for the small, slow-moving kinds where the clone costs less +/// than threading `Arc` through every consumer. +fn cloned(store: &reflector::Store) -> Vec +where + K: Resource + Clone + 'static, + K::DynamicType: Eq + Hash + Clone + Default, +{ + store.state().iter().map(|obj| (**obj).clone()).collect() +} + +/// Starts a watch feeding a store, and returns the store. +/// +/// The watch runs for the process lifetime. `watcher` reconnects and +/// re-lists on its own, so a transient API error is logged and the +/// stream continues; the store keeps serving its last known state +/// meanwhile. +fn spawn_reflector(api: Api, kind: &'static str) -> reflector::Store +where + K: Resource + Clone + DeserializeOwned + Debug + Send + Sync + 'static, + K::DynamicType: Eq + Hash + Clone + Default, +{ + let (store, writer) = reflector::store(); + let stream = reflector(writer, watcher(api, watcher::Config::default())).applied_objects(); + + drop(tokio::spawn(async move { + let mut stream = Box::pin(stream); + while let Some(event) = stream.next().await { + if let Err(e) = event { + warn!(%e, kind, "watch error, cache may be briefly stale"); + } + } + warn!(kind, "watch stream ended"); + })); + + store +} + +/// Waits for a store's initial sync, mapping a timeout to an error. +async fn wait_ready(store: &reflector::Store, kind: &'static str) -> Result<()> +where + K: Resource + Clone + Send + Sync + 'static, + K::DynamicType: Eq + Hash + Clone + Default + Send + Sync, +{ + match tokio::time::timeout(INITIAL_SYNC_TIMEOUT, store.wait_until_ready()).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) | Err(_) => Err(OperatorError::CacheSync(kind)), + } +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use gateway_api::referencegrants::ReferenceGrantSpec; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use kube::runtime::watcher::Event; + + use super::*; + + /// Builds a `ReferenceGrant` in the given namespace. + fn grant(namespace: &str, name: &str) -> ReferenceGrant { + ReferenceGrant { + metadata: ObjectMeta { + name: Some(name.to_owned()), + namespace: Some(namespace.to_owned()), + ..Default::default() + }, + spec: ReferenceGrantSpec { + from: vec![], + to: vec![], + }, + } + } + + /// Builds a `Stores` populated without touching an API server. + /// + /// The writers go out of scope at the end of this function. That is + /// safe because a `Writer` shares its cache with the `Store` rather + /// than owning it, so the contents outlive the writer; only + /// `wait_until_ready` would notice, and these tests read state + /// directly. + fn populated(grants: Vec, namespaces: Vec) -> Stores { + let (route_store, route_writer) = reflector::store::(); + let (grant_store, mut grant_writer) = reflector::store::(); + let (ns_store, mut ns_writer) = reflector::store::(); + + for g in grants { + grant_writer.apply_watcher_event(&Event::Apply(g)); + } + for n in namespaces { + ns_writer.apply_watcher_event(&Event::Apply(n)); + } + + drop((route_writer, grant_writer, ns_writer)); + Stores { + routes: route_store, + grants: grant_store, + namespaces: ns_store, + } + } + + #[test] + fn test_grants_in_returns_only_the_named_namespace() { + let stores = populated( + vec![grant("apps", "a"), grant("infra", "b"), grant("apps", "c")], + vec![], + ); + + let mut names: Vec<_> = stores + .grants_in("apps") + .iter() + .filter_map(|g| g.metadata.name.clone()) + .collect(); + names.sort(); + + assert_eq!( + names, + vec!["a".to_owned(), "c".to_owned()], + "a grant only authorizes references into its own namespace, so the filter must not \ + leak grants from elsewhere" + ); + } + + #[test] + fn test_grants_in_is_empty_for_an_unknown_namespace() { + let stores = populated(vec![grant("apps", "a")], vec![]); + + assert!( + stores.grants_in("other").is_empty(), + "an unmatched namespace must yield no grants, not every grant" + ); + } + + #[test] + fn test_grants_returns_the_whole_cache() { + let stores = populated(vec![grant("apps", "a"), grant("infra", "b")], vec![]); + + assert_eq!(stores.grants().len(), 2, "both grants should be returned"); + } + + #[test] + fn test_empty_caches_read_as_empty_rather_than_failing() { + let stores = populated(vec![], vec![]); + + assert!(stores.routes().is_empty(), "no routes were applied"); + assert!(stores.grants().is_empty(), "no grants were applied"); + assert!(stores.namespaces().is_empty(), "no namespaces were applied"); + } + + #[test] + fn test_debug_reports_cache_sizes() { + let stores = populated(vec![grant("apps", "a")], vec![]); + + let rendered = format!("{stores:?}"); + assert!( + rendered.contains("grants: 1"), + "Debug should surface cache depth, since an unexpectedly empty cache is the \ + failure mode worth seeing in a log: {rendered}" + ); + } +} From ce30c52c0b35eb449f162abca8e5585dde153e53 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:33:39 -0400 Subject: [PATCH 22/51] chore: evaluate matchExpressions when rejecting routes by namespace Signed-off-by: Shane Utt --- src/controller/httproute.rs | 177 +++++++++++++++++++++--------------- 1 file changed, 102 insertions(+), 75 deletions(-) diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index 3798fea..7cc9cd5 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -12,7 +12,7 @@ use std::{sync::Arc, time::Duration}; use gateway_api::{ gatewayclasses::GatewayClass, - gateways::{Gateway, GatewayListeners, GatewayListenersAllowedRoutesNamespacesSelector}, + gateways::{Gateway, GatewayListeners}, httproutes::{HTTPRoute, HttpRouteParentRefs}, }; use k8s_openapi::{api::core::v1::Namespace, apimachinery::pkg::apis::meta::v1::Condition}; @@ -20,6 +20,7 @@ use kube::{Api, ResourceExt as _, runtime::controller::Action}; use serde_json::Value; use tracing::{debug, error, info}; +use super::namespace_filter; use crate::{ context::{CONTROLLER_NAME, Context}, error::{OperatorError, Result}, @@ -117,7 +118,7 @@ async fn build_rejection_status( return None; } - let rejection = validate_listener_attachment(route, &gw, parent_ref, generation, &ctx.client).await; + let rejection = validate_listener_attachment(route, &gw, parent_ref, generation, ctx); let grants = ctx.stores.grants(); let resolve_result = route_status::check_backend_refs(route, route_ns, &ctx.client, &grants).await; let resolved = route_status::resolved_refs_condition(&resolve_result, generation); @@ -158,12 +159,12 @@ async fn lookup_parent_gateway(gw_name: &str, gw_ns: &str, route_ns: &str, ctx: // ----------------------------------------------------------------------------- /// Returns a rejection condition if the route fails listener validation. -async fn validate_listener_attachment( +fn validate_listener_attachment( route: &HTTPRoute, gw: &Gateway, parent_ref: &HttpRouteParentRefs, generation: i64, - client: &kube::Client, + ctx: &Context, ) -> Option { if !section_name_valid(gw, parent_ref) { return Some(conditions::not_accepted( @@ -172,7 +173,7 @@ async fn validate_listener_attachment( "no listener matches sectionName", )); } - if !namespace_allowed(route, gw, parent_ref, client).await { + if !namespace_allowed(route, gw, parent_ref, ctx) { return Some(conditions::not_accepted( generation, "NotAllowedByListeners", @@ -198,12 +199,7 @@ fn section_name_valid(gw: &Gateway, parent_ref: &HttpRouteParentRefs) -> bool { } /// Returns `true` when the route's namespace is allowed by listeners. -async fn namespace_allowed( - route: &HTTPRoute, - gw: &Gateway, - parent_ref: &HttpRouteParentRefs, - client: &kube::Client, -) -> bool { +fn namespace_allowed(route: &HTTPRoute, gw: &Gateway, parent_ref: &HttpRouteParentRefs, ctx: &Context) -> bool { let route_ns = route_status::route_namespace(route); let gw_ns = gw.metadata.namespace.as_deref().unwrap_or("default"); route_allowed_by_listeners( @@ -211,27 +207,22 @@ async fn namespace_allowed( gw_ns, &gw.spec.listeners, parent_ref.section_name.as_deref(), - client, + &ctx.stores.namespaces(), ) - .await } /// Checks whether a route's namespace is allowed by at least one /// targeted listener. -async fn route_allowed_by_listeners( +fn route_allowed_by_listeners( route_ns: &str, gw_ns: &str, listeners: &[GatewayListeners], section_name: Option<&str>, - client: &kube::Client, + namespaces: &[Namespace], ) -> bool { - let matching = targeted_listeners(listeners, section_name); - for listener in &matching { - if listener_allows_namespace(listener, route_ns, gw_ns, client).await { - return true; - } - } - false + targeted_listeners(listeners, section_name) + .iter() + .any(|listener| namespace_filter::is_namespace_allowed(listener, route_ns, gw_ns, Some(namespaces))) } /// Returns listeners targeted by a section name (or all if `None`). @@ -242,58 +233,6 @@ fn targeted_listeners<'a>(listeners: &'a [GatewayListeners], section_name: Optio } } -/// Checks whether a single listener allows the given route namespace. -async fn listener_allows_namespace( - listener: &GatewayListeners, - route_ns: &str, - gw_ns: &str, - client: &kube::Client, -) -> bool { - use gateway_api::gateways::GatewayListenersAllowedRoutesNamespacesFrom; - - let from = listener - .allowed_routes - .as_ref() - .and_then(|ar| ar.namespaces.as_ref()) - .and_then(|ns| ns.from.as_ref()); - - match from { - None | Some(GatewayListenersAllowedRoutesNamespacesFrom::Same) => route_ns == gw_ns, - Some(GatewayListenersAllowedRoutesNamespacesFrom::All) => true, - Some(GatewayListenersAllowedRoutesNamespacesFrom::Selector) => { - let selector = listener - .allowed_routes - .as_ref() - .and_then(|ar| ar.namespaces.as_ref()) - .and_then(|ns| ns.selector.as_ref()); - namespace_matches_label_selector(client, route_ns, selector).await - }, - } -} - -/// Checks whether a namespace's labels match a label selector. -async fn namespace_matches_label_selector( - client: &kube::Client, - ns_name: &str, - selector: Option<&GatewayListenersAllowedRoutesNamespacesSelector>, -) -> bool { - let Some(selector) = selector else { return false }; - let ns_api = Api::::all(client.clone()); - let Ok(ns_obj) = ns_api.get(ns_name).await else { - return false; - }; - - let Some(match_labels) = &selector.match_labels else { - return true; - }; - let Some(labels) = ns_obj.metadata.labels.as_ref() else { - return false; - }; - match_labels - .iter() - .all(|(k, v)| labels.get(k).is_some_and(|lv| lv == v)) -} - /// Checks if any route hostname intersects with a matching listener. fn hostnames_intersect(route: &HTTPRoute, gw: &Gateway, section_name: Option<&str>) -> bool { let route_hostnames = route.spec.hostnames.as_deref().unwrap_or(&[]); @@ -330,7 +269,14 @@ pub fn error_policy(_route: Arc, error: &OperatorError, _ctx: Arc GatewayListeners { + GatewayListeners { + allowed_routes: Some(GatewayListenersAllowedRoutes { + namespaces: Some(GatewayListenersAllowedRoutesNamespaces { + from: Some(GatewayListenersAllowedRoutesNamespacesFrom::Selector), + selector: Some(selector), + }), + ..Default::default() + }), + ..listener("http", None) + } + } + + /// Builds a `Namespace` carrying one label. + fn labelled_namespace(name: &str, key: &str, value: &str) -> Namespace { + Namespace { + metadata: ObjectMeta { + name: Some(name.to_owned()), + labels: Some([(key.to_owned(), value.to_owned())].into_iter().collect()), + ..Default::default() + }, + ..Default::default() + } + } + + #[test] + fn test_match_expressions_alone_can_deny_a_namespace() { + let listeners = vec![selector_listener(GatewayListenersAllowedRoutesNamespacesSelector { + match_labels: None, + match_expressions: Some(vec![GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions { + key: "team".to_owned(), + operator: "In".to_owned(), + values: Some(vec!["platform".to_owned()]), + }]), + })]; + let namespaces = vec![labelled_namespace("apps", "team", "payments")]; + + assert!( + !route_allowed_by_listeners("apps", "infra", &listeners, None, &namespaces), + "a selector with only matchExpressions must still be evaluated; treating an absent \ + matchLabels as \"allow everything\" would accept a route the Gateway controller \ + drops from the config, leaving it with no rejection status and no traffic" + ); + } + + #[test] + fn test_match_expressions_alone_can_allow_a_namespace() { + let listeners = vec![selector_listener(GatewayListenersAllowedRoutesNamespacesSelector { + match_labels: None, + match_expressions: Some(vec![GatewayListenersAllowedRoutesNamespacesSelectorMatchExpressions { + key: "team".to_owned(), + operator: "In".to_owned(), + values: Some(vec!["platform".to_owned()]), + }]), + })]; + let namespaces = vec![labelled_namespace("apps", "team", "platform")]; + + assert!( + route_allowed_by_listeners("apps", "infra", &listeners, None, &namespaces), + "a namespace satisfying the expression is allowed" + ); + } + + #[test] + fn test_same_namespace_policy_needs_no_namespace_cache() { + let listeners = vec![listener("http", None)]; + + assert!( + route_allowed_by_listeners("infra", "infra", &listeners, None, &[]), + "the default Same policy compares namespaces directly" + ); + assert!( + !route_allowed_by_listeners("apps", "infra", &listeners, None, &[]), + "a route in another namespace is not allowed by the default policy" + ); + } } From 16b50ec5ec26cd4fa2fbba04bca421c9a095d1d4 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:33:54 -0400 Subject: [PATCH 23/51] chore: keep the data-plane default at one replica Signed-off-by: Shane Utt --- src/resources/deployment.rs | 22 ++++++++++++++++++---- 1 file changed, 18 insertions(+), 4 deletions(-) diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index 784eb08..c6a2257 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -38,10 +38,24 @@ const REPLICAS_ANNOTATION: &str = "praxis.sh/replicas"; /// Replicas run when the Gateway does not ask for a specific count. /// -/// Two rather than one so a node drain or a rolling config change does -/// not take the data plane down; a single-replica Gateway is a single -/// point of failure for every route attached to it. -const DEFAULT_REPLICAS: i32 = 2; +/// One, matching the behaviour every existing Gateway already has. +/// +/// Two would be the better availability default — a single-replica +/// Gateway is a single point of failure for every route attached to it +/// — but the data plane cannot currently sustain it. Praxis 0.3.1 +/// registers its KV admin endpoints on the same port as its health +/// endpoints via `SO_REUSEPORT`, so probe connections land on whichever +/// listener the kernel picks and roughly half of them 404. Praxis has +/// since deprecated that registration for exactly this reason +/// ("non-deterministic connection routing that breaks health probes"), +/// but on the pinned version every additional replica is another pod +/// whose liveness probe flaps and whose container is restarted, and a +/// Gateway whose pods never settle never reports Programmed. +/// +/// Raise this to two once the data plane serves health on a port of its +/// own. Until then `praxis.sh/replicas` is the opt-in for anyone who +/// wants the availability and can tolerate the flapping. +const DEFAULT_REPLICAS: i32 = 1; // ----------------------------------------------------------------------------- // Deployment Builder From 9c1e30481a21958035fd916efba55c3a8e05263b Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:34:28 -0400 Subject: [PATCH 24/51] test(conformance): restate the failing conformance tests at the end of the run Signed-off-by: Shane Utt --- hack/run-conformance.sh | 45 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/hack/run-conformance.sh b/hack/run-conformance.sh index 7680d87..496fac8 100755 --- a/hack/run-conformance.sh +++ b/hack/run-conformance.sh @@ -15,6 +15,10 @@ NS_READY="${NS_READY:-600}" SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" LOG_FILTER="${SCRIPT_DIR}/filter-conformance-logs.sh" +# Unfiltered suite output. The log filter exists to keep the console +# readable, which means it is not a place to look for the failure list. +RAW_LOG="${RAW_LOG:-/tmp/conformance-raw.log}" + # --------------------------------------------------------------------------- # Isolated KUBECONFIG # --------------------------------------------------------------------------- @@ -43,6 +47,10 @@ fi echo "==> Running conformance tests (context: kind-${CLUSTER_NAME})..." cd "${GWAPI_DIR}" +# A failing suite is an expected outcome here, not a reason to abort: the +# summary below is the whole point of running it. Errexit goes back on +# once the status is captured, and the script exits with it at the end. +set +e # The suite needs headroom: it already ran ~17 minutes against the old 20m # ceiling, so any CI slowdown aborted it with no report written. Raised to # 45m so a genuine hang is still caught while normal variance is not. @@ -60,6 +68,39 @@ go test ./conformance -run TestConformance \ --version=v0.1.0 \ --url=https://github.com/praxis-proxy/praxis-operator \ --contact=@shaneutt \ - 2>&1 | "${LOG_FILTER}" + 2>&1 | tee "${RAW_LOG}" | "${LOG_FILTER}" +status="${PIPESTATUS[0]}" +set -e + +# --------------------------------------------------------------------------- +# Failure Summary +# --------------------------------------------------------------------------- + +# The suite prints thousands of lines and the job then appends a cluster +# dump, so on CI the names of the tests that actually failed end up +# buried far from either end of the log. Restate them last, where +# anyone reading the tail of a failed job will see them without +# downloading the whole thing. +if [ "${status}" -ne 0 ]; then + echo + echo "==> FAILED TESTS" + # Collected into a variable rather than tested through a pipeline: + # the exit status of `grep | sed | sort` is sort's, which succeeds on + # empty input, so piping would silently never take the fallback. + failures="$(grep -E '^[[:space:]]*--- FAIL: ' "${RAW_LOG}" || true)" + if [ -n "${failures}" ]; then + printf '%s\n' "${failures}" | sed 's/^[[:space:]]*/ /' | sort -u + else + echo " (no '--- FAIL' lines; the suite failed before running tests)" + echo "==> LAST 40 LINES OF RAW OUTPUT" + tail -n 40 "${RAW_LOG}" | sed 's/^/ /' + fi + echo +fi + +if [ -f /tmp/conformance-report.yaml ]; then + echo "==> Conformance report: /tmp/conformance-report.yaml" + sed 's/^/ /' /tmp/conformance-report.yaml +fi -echo "==> Conformance report: /tmp/conformance-report.yaml" +exit "${status}" From d3a17dc0058421ad592a26903abaa28acfbe91be Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:34:46 -0400 Subject: [PATCH 25/51] chore: do not let an unserved listener protocol conflict with a served one Signed-off-by: Shane Utt --- src/gateway_api/listener_conflict.rs | 47 ++++++++++++++++++++++++++-- 1 file changed, 44 insertions(+), 3 deletions(-) diff --git a/src/gateway_api/listener_conflict.rs b/src/gateway_api/listener_conflict.rs index abe261d..8ea431d 100644 --- a/src/gateway_api/listener_conflict.rs +++ b/src/gateway_api/listener_conflict.rs @@ -13,6 +13,8 @@ use std::collections::{BTreeMap, HashMap}; use gateway_api::gateways::GatewayListeners; +use crate::gateway_api::protocol::ListenerProtocol; + // ----------------------------------------------------------------------------- // ConflictReason // ----------------------------------------------------------------------------- @@ -63,7 +65,21 @@ impl ConflictReason { pub fn detect_conflicts(listeners: &[GatewayListeners]) -> HashMap { let mut conflicts = HashMap::new(); - for group in group_by_port(listeners).values() { + // Only listeners this operator would actually serve can conflict. + // + // A listener naming a protocol we do not implement is rejected on + // its own terms, with `UnsupportedProtocol`. Letting it into the + // port grouping made it collide with whatever valid listener shared + // its port, and both came back `Conflicted` — so a Gateway with one + // good HTTP listener and one TCP listener on the same port reported + // no accepted listeners at all, when the good one should have been + // accepted and only the TCP one rejected. + let servable: Vec<&GatewayListeners> = listeners + .iter() + .filter(|listener| ListenerProtocol::is_supported(&listener.protocol)) + .collect(); + + for group in group_by_port_refs(&servable).values() { mark_protocol_conflicts(group, &mut conflicts); mark_hostname_conflicts(group, &mut conflicts); } @@ -74,8 +90,8 @@ pub fn detect_conflicts(listeners: &[GatewayListeners]) -> HashMap BTreeMap> { - let mut by_port: BTreeMap> = BTreeMap::new(); +fn group_by_port_refs<'a>(listeners: &[&'a GatewayListeners]) -> BTreeMap> { + let mut by_port: BTreeMap> = BTreeMap::new(); for listener in listeners { by_port.entry(listener.port).or_default().push(listener); } @@ -155,6 +171,31 @@ mod tests { ); } + #[test] + fn test_an_unserved_protocol_does_not_conflict_with_a_served_one() { + let listeners = vec![listener("http", 80, "HTTP", None), listener("tcp", 80, "TCP", None)]; + let conflicts = detect_conflicts(&listeners); + + assert!( + conflicts.is_empty(), + "a listener naming a protocol this operator does not serve is rejected with \ + UnsupportedProtocol on its own; dragging the servable listener into Conflicted \ + leaves the Gateway with no accepted listeners at all, which is what the \ + GatewayListenerUnsupportedProtocol conformance case checks: {conflicts:?}" + ); + } + + #[test] + fn test_two_unserved_protocols_on_one_port_do_not_conflict() { + let listeners = vec![listener("tcp", 80, "TCP", None), listener("udp", 80, "UDP", None)]; + + assert!( + detect_conflicts(&listeners).is_empty(), + "neither listener would be programmed anyway, and UnsupportedProtocol is the \ + reason that describes why" + ); + } + #[test] fn test_same_port_different_protocol_is_a_protocol_conflict() { let listeners = vec![ From 42d6237806e4578f6b0a4fd28cd41637c2af9550 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:35:03 -0400 Subject: [PATCH 26/51] chore: report ListenersNotValid when only some listeners are valid Signed-off-by: Shane Utt --- src/controller/gateway_status.rs | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/src/controller/gateway_status.rs b/src/controller/gateway_status.rs index 9cb1f9d..eeede82 100644 --- a/src/controller/gateway_status.rs +++ b/src/controller/gateway_status.rs @@ -309,7 +309,19 @@ fn gateway_accepted_condition(generation: i64, any_accepted: bool, any_rejected: } if any_rejected { - return conditions::accepted(generation, "Gateway accepted, but some listeners are invalid"); + // Accepted, but the reason has to say why it is not a clean + // acceptance. The Gateway API reserves `ListenersNotValid` for + // exactly this state — the Gateway stands, some listeners do + // not — and conformance asserts on the reason, not just the + // status. Reporting `Accepted` here loses the only signal that + // distinguishes a fully valid Gateway from a partly broken one. + return conditions::make_condition( + "Accepted", + "True", + "ListenersNotValid", + "Gateway accepted, but some listeners are invalid", + generation, + ); } conditions::accepted(generation, "Gateway accepted") @@ -390,8 +402,12 @@ mod tests { let cond = gateway_accepted_condition(1, true, true); assert_eq!(cond.status, "True", "should be True when some listeners are accepted"); assert_eq!( - cond.reason, "Accepted", - "Accepted: True must not carry ListenersNotValid, which is a False-only reason" + cond.reason, "ListenersNotValid", + "this assertion used to require `Accepted`, on the belief that ListenersNotValid was \ + a False-only reason. It is not: the GatewayListenerUnsupportedProtocol conformance \ + case reports `Accepted condition Reason set to Accepted, expected ListenersNotValid` \ + for a Gateway whose listeners are partly valid. Status stays True — the Gateway is \ + accepted — while the reason carries the fact that some listeners are not" ); assert!( cond.message.contains("some listeners are invalid"), From 276019dd2efc56745a7bf450c413017083b685ab Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:35:18 -0400 Subject: [PATCH 27/51] chore(cleanup): stop claiming a response-header feature the data plane lacks Signed-off-by: Shane Utt --- src/controller/gateway_class.rs | 19 ++++++++++++------- 1 file changed, 12 insertions(+), 7 deletions(-) diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 064865a..5d60247 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -41,14 +41,19 @@ use crate::{ /// has no field to carry them. Advertising any of them would direct /// conformance tooling at suites that cannot pass. /// +/// `HTTPRouteResponseHeaderModification` was claimed here and has been +/// withdrawn. Claiming it is what made conformance run +/// `HTTPRouteResponseHeaderModifier` at all — the suites are gated on +/// advertised features, which is why the test was skipped before. +/// Running it showed the data plane implements the filter only in part: +/// `set` and `remove` behave, but `add` replaces the existing header +/// rather than appending, so conformance asks for +/// `append-val-1,header-val-2` and praxis 0.3.1 returns `header-val-2`. +/// `HTTPRouteRequestHeaderModification` was never claimed, for the same +/// underlying reason. +/// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route -const SUPPORTED_FEATURES: &[&str] = &[ - "Gateway", - "GatewayPort8080", - "HTTPRoute", - "HTTPRouteResponseHeaderModification", - "ReferenceGrant", -]; +const SUPPORTED_FEATURES: &[&str] = &["Gateway", "GatewayPort8080", "HTTPRoute", "ReferenceGrant"]; // ----------------------------------------------------------------------------- // Reconciler From ed5035dc05f8bc350d49918f21588df416a4dfa9 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:35:40 -0400 Subject: [PATCH 28/51] chore: pin that an observedGeneration bump alone is still written Signed-off-by: Shane Utt --- src/gateway_api/status.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src/gateway_api/status.rs b/src/gateway_api/status.rs index 6a45359..752bcc1 100644 --- a/src/gateway_api/status.rs +++ b/src/gateway_api/status.rs @@ -397,4 +397,30 @@ mod tests { "a changed condition message must be written" ); } + #[test] + fn test_an_observed_generation_bump_alone_is_still_written() { + let observed = json!({ + "conditions": [{ + "type": "Accepted", "status": "True", "reason": "Accepted", "message": "ok", + "observedGeneration": 1, "lastTransitionTime": "2026-08-09T00:00:00Z" + }] + }); + let mut desired = json!({ + "conditions": [{ + "type": "Accepted", "status": "True", "reason": "Accepted", "message": "ok", + "observedGeneration": 2, "lastTransitionTime": "2026-08-09T01:00:00Z" + }] + }); + + preserve_condition_times(&mut desired, &observed); + + assert!( + !is_status_unchanged(&desired, &observed), + "a spec change that only bumps observedGeneration must still be patched. \ + preserve_condition_times deliberately rewinds lastTransitionTime here, since the \ + condition did not flip, which leaves observedGeneration as the sole difference — \ + if that were treated as unchanged the status would never catch up to the spec, and \ + the GatewayObservedGenerationBump conformance case measures exactly that" + ); + } } From 3045b9982b7ba9981e647d618bdfa529d9e5b45a Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:35:56 -0400 Subject: [PATCH 29/51] chore(refactor): sort cache reads so the generated config stops churning Signed-off-by: Shane Utt --- src/stores.rs | 60 +++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 56 insertions(+), 4 deletions(-) diff --git a/src/stores.rs b/src/stores.rs index d78cba7..56b86c0 100644 --- a/src/stores.rs +++ b/src/stores.rs @@ -98,7 +98,7 @@ impl Stores { /// with the cluster; cloning it per Gateway reconcile would give /// back much of what the cache saves. pub fn routes(&self) -> Vec> { - self.routes.state() + sorted(self.routes.state()) } /// Returns every cached `ReferenceGrant`. @@ -108,8 +108,7 @@ impl Stores { /// Returns cached `ReferenceGrants` in one namespace. pub fn grants_in(&self, namespace: &str) -> Vec { - self.grants - .state() + sorted(self.grants.state()) .iter() .filter(|grant| grant.metadata.namespace.as_deref() == Some(namespace)) .map(|grant| (**grant).clone()) @@ -145,7 +144,32 @@ where K: Resource + Clone + 'static, K::DynamicType: Eq + Hash + Clone + Default, { - store.state().iter().map(|obj| (**obj).clone()).collect() + sorted(store.state()).iter().map(|obj| (**obj).clone()).collect() +} + +/// Orders cached objects by namespace and name. +/// +/// `Store::state` collects the values of an `AHashMap`, so it hands back +/// a different order from one call to the next. That is fatal here, not +/// merely untidy: route order reaches the generated Praxis YAML, the +/// YAML is hashed into the data-plane pod template, and a hash that +/// changes every reconcile rolls a new `ReplicaSet` every reconcile. The +/// rollout then never completes, so routes are never accepted and the +/// Gateway never reports Programmed. +/// +/// The `LIST` this cache replaced returned API-server order, which is +/// stable, so nothing downstream had ever needed to sort. Sorting here +/// restores the property the rest of the pipeline was written against. +fn sorted(mut objects: Vec>) -> Vec> +where + K: Resource + 'static, +{ + objects.sort_by(|a, b| { + let left = (a.meta().namespace.as_deref(), a.meta().name.as_deref()); + let right = (b.meta().namespace.as_deref(), b.meta().name.as_deref()); + left.cmp(&right) + }); + objects } /// Starts a watch feeding a store, and returns the store. @@ -300,4 +324,32 @@ mod tests { failure mode worth seeing in a log: {rendered}" ); } + #[test] + fn test_reads_are_ordered_regardless_of_insertion_order() { + let forward = populated(vec![grant("b", "two"), grant("a", "one"), grant("a", "two")], vec![]); + let reverse = populated(vec![grant("a", "two"), grant("b", "two"), grant("a", "one")], vec![]); + + let key = |g: &ReferenceGrant| { + format!( + "{}/{}", + g.metadata.namespace.clone().unwrap_or_default(), + g.metadata.name.clone().unwrap_or_default() + ) + }; + let forward_keys: Vec<_> = forward.grants().iter().map(key).collect(); + let reverse_keys: Vec<_> = reverse.grants().iter().map(key).collect(); + + assert_eq!( + forward_keys, + vec!["a/one".to_owned(), "a/two".to_owned(), "b/two".to_owned()], + "cache reads must come back sorted by namespace then name" + ); + assert_eq!( + forward_keys, reverse_keys, + "the order objects happened to arrive in must not reach the caller. `Store::state` \ + iterates an AHashMap, and route order flows into the generated Praxis YAML, which \ + is hashed into the data-plane pod template — an unstable order there rolls a new \ + ReplicaSet on every reconcile and the rollout never finishes" + ); + } } From 00a98e2846b0c07ec566186d1cc5eaf2fd349a20 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:36:19 -0400 Subject: [PATCH 30/51] chore(refactor): build status documents from types instead of json! literals Signed-off-by: Shane Utt --- src/controller/gateway_class.rs | 39 ++- src/controller/gateway_status.rs | 111 +++----- src/controller/httproute.rs | 7 +- src/controller/listener_validation.rs | 11 +- src/controller/route_parent_status.rs | 7 +- src/gateway_api/mod.rs | 1 + src/gateway_api/route_status.rs | 76 +++--- src/gateway_api/status_types.rs | 364 ++++++++++++++++++++++++++ 8 files changed, 477 insertions(+), 139 deletions(-) create mode 100644 src/gateway_api/status_types.rs diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 5d60247..60b573d 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -14,9 +14,12 @@ use kube::{ use tracing::{debug, error, info}; use crate::{ - context::{CONTROLLER_NAME, Context}, + context::{CONTROLLER_NAME, Context, FIELD_MANAGER}, error::{OperatorError, Result}, - gateway_api::{conditions, status}, + gateway_api::{ + conditions, status, + status_types::{self, GatewayClassStatus, SupportedFeature}, + }, observability::metrics, }; @@ -101,7 +104,7 @@ async fn accept_gateway_class(gc: &GatewayClass, name: &str, ctx: &Context) -> R let generation = gc.metadata.generation.unwrap_or(0); let observed = serde_json::to_value(&gc.status)?; - let mut desired = build_accepted_status(generation); + let mut desired = build_accepted_status(generation)?; status::preserve_condition_times(&mut desired, &observed); if status::is_status_unchanged(&desired, &observed) { @@ -111,17 +114,12 @@ async fn accept_gateway_class(gc: &GatewayClass, name: &str, ctx: &Context) -> R } metrics::global().record_status_written(); - let payload = serde_json::json!({ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "GatewayClass", - "metadata": { "name": name }, - "status": desired, - }); + let payload = status_types::status_patch("GatewayClass", name, None, desired); let api = Api::::all(ctx.client.clone()); api.patch_status( name, - &PatchParams::apply("praxis-operator").force(), + &PatchParams::apply(FIELD_MANAGER).force(), &Patch::Apply(&payload), ) .await?; @@ -137,16 +135,17 @@ async fn accept_gateway_class(gc: &GatewayClass, name: &str, ctx: &Context) -> R /// Builds the `status` sub-object of the accepted patch. /// /// Sets the `Accepted` condition to `True` and declares supported features. -fn build_accepted_status(generation: i64) -> serde_json::Value { - let condition = conditions::accepted(generation, "GatewayClass accepted"); - let features: Vec<_> = SUPPORTED_FEATURES +fn build_accepted_status(generation: i64) -> serde_json::Result { + let features = SUPPORTED_FEATURES .iter() - .map(|name| serde_json::json!({ "name": name })) + .map(|name| SupportedFeature { + name: (*name).to_owned(), + }) .collect(); - serde_json::json!({ - "conditions": [condition], - "supportedFeatures": features, + serde_json::to_value(GatewayClassStatus { + conditions: vec![conditions::accepted(generation, "GatewayClass accepted")], + supported_features: features, }) } @@ -187,7 +186,7 @@ mod tests { #[test] fn test_build_accepted_status_sets_accepted_true() { - let status = build_accepted_status(3); + let status = build_accepted_status(3).expect("a class status is strings and conditions"); assert_eq!( status["conditions"][0]["type"], "Accepted", @@ -202,7 +201,7 @@ mod tests { #[test] fn test_build_accepted_status_declares_supported_features() { - let status = build_accepted_status(1); + let status = build_accepted_status(1).expect("a class status is strings and conditions"); assert_eq!( status["supportedFeatures"].as_array().map(Vec::len), @@ -234,7 +233,7 @@ mod tests { #[test] fn test_build_accepted_status_carries_no_metadata() { - let status = build_accepted_status(1); + let status = build_accepted_status(1).expect("a class status is strings and conditions"); assert!( status.get("metadata").is_none(), diff --git a/src/controller/gateway_status.rs b/src/controller/gateway_status.rs index eeede82..e666672 100644 --- a/src/controller/gateway_status.rs +++ b/src/controller/gateway_status.rs @@ -17,7 +17,7 @@ use kube::{ Api, ResourceExt as _, api::{Patch, PatchParams}, }; -use serde_json::{Value, json}; +use serde_json::Value; use tracing::{debug, info}; use super::listener_validation; @@ -25,7 +25,11 @@ use crate::{ context::{Context, FIELD_MANAGER}, error::Result, gateway_api::{ - attachment::AttachedRoute, conditions, hostname, listener_conflict, protocol::ListenerProtocol, status, + attachment::AttachedRoute, + conditions, hostname, listener_conflict, + protocol::ListenerProtocol, + status, + status_types::{self, GatewayAddress, GatewayStatus, ListenerStatus}, }, observability::metrics, resources::labels::child_name, @@ -57,42 +61,20 @@ pub(super) async fn build_and_apply_gateway_status( build_listener_statuses(listeners, generation, &ns, ctx, attached).await; let data_plane_ready = deployment_ready && !addresses.is_empty(); - let status = gateway_status_json(&GatewayStatusParts { - accepted: &gateway_accepted_condition(generation, any_accepted, any_rejected), - addresses: &addresses, - listener_statuses: &listener_statuses, - programmed: &gateway_programmed_condition(generation, any_accepted, data_plane_ready), - }); + let status = serde_json::to_value(GatewayStatus { + conditions: vec![ + gateway_accepted_condition(generation, any_accepted, any_rejected), + gateway_programmed_condition(generation, any_accepted, data_plane_ready), + ], + addresses, + listeners: listener_statuses, + })?; apply_gateway_status(client, gw, &status).await?; info!("Gateway {ns}/{name} reconciled successfully"); Ok(()) } -/// Components used to build the Gateway status JSON payload. -struct GatewayStatusParts<'a> { - /// Gateway-level `Accepted` condition. - accepted: &'a Condition, - - /// Load-balancer addresses. - addresses: &'a [Value], - - /// Per-listener status entries. - listener_statuses: &'a [Value], - - /// Gateway-level `Programmed` condition. - programmed: &'a Condition, -} - -/// Constructs the `status` sub-object of the Gateway status patch. -fn gateway_status_json(parts: &GatewayStatusParts<'_>) -> Value { - json!({ - "addresses": parts.addresses, - "conditions": [parts.accepted, parts.programmed], - "listeners": parts.listener_statuses, - }) -} - /// Patches the Gateway status via server-side apply. /// /// Carries condition transition times forward and returns without @@ -115,12 +97,7 @@ pub(super) async fn apply_gateway_status(client: &kube::Client, gw: &Gateway, st } metrics::global().record_status_written(); - let payload = json!({ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "Gateway", - "metadata": { "name": name, "namespace": ns }, - "status": desired, - }); + let payload = status_types::status_patch("Gateway", &name, Some(&ns), desired); Api::::namespaced(client.clone(), &ns) .patch_status( @@ -133,7 +110,7 @@ pub(super) async fn apply_gateway_status(client: &kube::Client, gw: &Gateway, st } /// Queries the child Service for load-balancer ingress IP addresses. -async fn resolve_lb_addresses(client: &kube::Client, ns: &str, child: &str) -> Vec { +async fn resolve_lb_addresses(client: &kube::Client, ns: &str, child: &str) -> Vec { Api::::namespaced(client.clone(), ns) .get(child) .await @@ -144,7 +121,7 @@ async fn resolve_lb_addresses(client: &kube::Client, ns: &str, child: &str) -> V .map(|ingress| { ingress .iter() - .filter_map(|i| i.ip.as_ref().map(|ip| json!({ "type": "IPAddress", "value": ip }))) + .filter_map(|i| i.ip.as_deref().map(GatewayAddress::ip)) .collect() }) .unwrap_or_default() @@ -172,7 +149,7 @@ async fn build_listener_statuses( gateway_ns: &str, ctx: &Context, attached: &[AttachedRoute<'_>], -) -> (Vec, bool, bool) { +) -> (Vec, bool, bool) { let conflicts = listener_conflict::detect_conflicts(listeners); let mut statuses = Vec::new(); let mut any_accepted = false; @@ -210,36 +187,30 @@ fn conflicted_listener_status( l: &GatewayListeners, generation: i64, reason: listener_conflict::ConflictReason, -) -> Value { - json!({ - "name": l.name, - "attachedRoutes": 0, - "supportedKinds": [], - "conditions": [ +) -> ListenerStatus { + ListenerStatus { + name: l.name.clone(), + attached_routes: 0, + supported_kinds: vec![], + conditions: vec![ conditions::not_accepted(generation, reason.as_str(), reason.message()), conditions::conflicted(generation, reason.as_str(), reason.message()), conditions::not_programmed(generation, reason.as_str(), reason.message()), ], - }) + } } /// Builds a status entry for an unsupported-protocol listener. -fn unsupported_listener_status(l: &GatewayListeners, generation: i64) -> Value { - json!({ - "name": l.name, - "attachedRoutes": 0, - "supportedKinds": [], - "conditions": [ - conditions::not_accepted( - generation, - "UnsupportedProtocol", - "protocol not supported", - ), - conditions::not_programmed( - generation, "Invalid", "unsupported protocol", - ), +fn unsupported_listener_status(l: &GatewayListeners, generation: i64) -> ListenerStatus { + ListenerStatus { + name: l.name.clone(), + attached_routes: 0, + supported_kinds: vec![], + conditions: vec![ + conditions::not_accepted(generation, "UnsupportedProtocol", "protocol not supported"), + conditions::not_programmed(generation, "Invalid", "unsupported protocol"), ], - }) + } } /// Counts routes attached to a specific listener. @@ -269,7 +240,7 @@ async fn accepted_listener_status( gateway_ns: &str, ctx: &Context, count: usize, -) -> Value { +) -> ListenerStatus { let (supported_kinds, resolved_refs_condition) = listener_validation::listener_resolved_refs(l, generation, gateway_ns, ctx).await; @@ -280,17 +251,17 @@ async fn accepted_listener_status( conditions::not_programmed(generation, "Invalid", "listener has unresolved refs") }; - json!({ - "name": l.name, - "attachedRoutes": count, - "supportedKinds": supported_kinds, - "conditions": [ + ListenerStatus { + name: l.name.clone(), + attached_routes: count, + supported_kinds, + conditions: vec![ conditions::accepted(generation, "listener accepted"), programmed_condition, conditions::no_conflicts(generation), resolved_refs_condition, ], - }) + } } /// Returns the `Accepted` condition for the Gateway. diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index 7cc9cd5..2b6df02 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -17,14 +17,13 @@ use gateway_api::{ }; use k8s_openapi::{api::core::v1::Namespace, apimachinery::pkg::apis::meta::v1::Condition}; use kube::{Api, ResourceExt as _, runtime::controller::Action}; -use serde_json::Value; use tracing::{debug, error, info}; use super::namespace_filter; use crate::{ context::{CONTROLLER_NAME, Context}, error::{OperatorError, Result}, - gateway_api::{conditions, hostname, route_status}, + gateway_api::{conditions, hostname, route_status, status_types::RouteParentStatus}, }; // ----------------------------------------------------------------------------- @@ -84,7 +83,7 @@ async fn collect_rejection_statuses( route_ns: &str, generation: i64, ctx: &Context, -) -> Vec { +) -> Vec { let mut statuses = Vec::new(); for parent_ref in parent_refs { if let Some(status) = build_rejection_status(route, parent_ref, route_ns, generation, ctx).await { @@ -106,7 +105,7 @@ async fn build_rejection_status( route_ns: &str, generation: i64, ctx: &Context, -) -> Option { +) -> Option { if !route_status::is_gateway_parent_ref(parent_ref) { return None; } diff --git a/src/controller/listener_validation.rs b/src/controller/listener_validation.rs index 2275682..ddeb82a 100644 --- a/src/controller/listener_validation.rs +++ b/src/controller/listener_validation.rs @@ -17,11 +17,10 @@ use gateway_api::{ }; use k8s_openapi::{ByteString, api::core::v1::Secret, apimachinery::pkg::apis::meta::v1::Condition}; use kube::Api; -use serde_json::{Value, json}; use crate::{ context::Context, - gateway_api::{conditions, reference_grant}, + gateway_api::{conditions, reference_grant, status_types::RouteGroupKind}, }; // ----------------------------------------------------------------------------- @@ -37,7 +36,7 @@ pub(super) async fn listener_resolved_refs( generation: i64, gateway_ns: &str, ctx: &Context, -) -> (Vec, Condition) { +) -> (Vec, Condition) { let (supported, kinds_invalid) = validate_route_kinds(listener); if kinds_invalid { @@ -57,7 +56,7 @@ pub(super) async fn listener_resolved_refs( /// Validates the configured `allowedRoutes.kinds` on a listener. /// /// Returns `(supported_kinds_json, has_invalid_kinds)`. -fn validate_route_kinds(listener: &GatewayListeners) -> (Vec, bool) { +fn validate_route_kinds(listener: &GatewayListeners) -> (Vec, bool) { let configured = listener.allowed_routes.as_ref().and_then(|ar| ar.kinds.as_ref()); let Some(kinds) = configured else { return (httproute_supported_kinds(), false); @@ -74,8 +73,8 @@ fn validate_route_kinds(listener: &GatewayListeners) -> (Vec, bool) { } /// Returns the default `supportedKinds` JSON for `HTTPRoute`. -fn httproute_supported_kinds() -> Vec { - vec![json!({"group": "gateway.networking.k8s.io", "kind": "HTTPRoute"})] +fn httproute_supported_kinds() -> Vec { + vec![RouteGroupKind::httproute()] } /// Checks whether a route kind ref is `HTTPRoute` in the Gateway API group. diff --git a/src/controller/route_parent_status.rs b/src/controller/route_parent_status.rs index 9458948..5d6eef1 100644 --- a/src/controller/route_parent_status.rs +++ b/src/controller/route_parent_status.rs @@ -15,11 +15,12 @@ use gateway_api::{ }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; use kube::ResourceExt as _; -use serde_json::Value; use crate::{ error::Result, - gateway_api::{attachment::AttachedRoute, conditions, route_status, route_validation}, + gateway_api::{ + attachment::AttachedRoute, conditions, route_status, route_validation, status_types::RouteParentStatus, + }, }; // ----------------------------------------------------------------------------- @@ -79,7 +80,7 @@ async fn build_route_statuses( generation: i64, client: &kube::Client, grants: &[ReferenceGrant], -) -> Vec { +) -> Vec { let validation = route_validation::validate_route(route); let mut statuses = Vec::new(); diff --git a/src/gateway_api/mod.rs b/src/gateway_api/mod.rs index a874069..0c4dff3 100644 --- a/src/gateway_api/mod.rs +++ b/src/gateway_api/mod.rs @@ -12,3 +12,4 @@ pub mod reference_grant; pub mod route_status; pub mod route_validation; pub mod status; +pub mod status_types; diff --git a/src/gateway_api/route_status.rs b/src/gateway_api/route_status.rs index 2688235..dc1835c 100644 --- a/src/gateway_api/route_status.rs +++ b/src/gateway_api/route_status.rs @@ -23,7 +23,10 @@ use tracing::debug; use crate::{ context::{CONTROLLER_NAME, FIELD_MANAGER}, error::Result, - gateway_api::{conditions, reference_grant, status}, + gateway_api::{ + conditions, reference_grant, status, + status_types::{self, ParentReference, RouteParentStatus}, + }, observability::metrics, }; @@ -194,7 +197,7 @@ pub fn parent_status_json( gw_ns: &str, accepted: &Condition, resolved: &Condition, -) -> Value { +) -> RouteParentStatus { parent_status_with_conditions(parent_ref, gw_ns, &[accepted.clone(), resolved.clone()]) } @@ -202,25 +205,22 @@ pub fn parent_status_json( /// /// Used when a route carries more than the usual `Accepted` and /// `ResolvedRefs` pair, such as a `PartiallyInvalid` route. -pub fn parent_status_with_conditions(parent_ref: &HttpRouteParentRefs, gw_ns: &str, conditions: &[Condition]) -> Value { - let mut ref_json = json!({ - "group": GATEWAY_GROUP, - "kind": "Gateway", - "name": parent_ref.name, - "namespace": gw_ns, - }); - - if let Some(section) = &parent_ref.section_name - && let Some(object) = ref_json.as_object_mut() - { - object.insert("sectionName".to_owned(), json!(section)); - } - - json!({ - "parentRef": ref_json, - "controllerName": CONTROLLER_NAME, - "conditions": conditions, - }) +pub fn parent_status_with_conditions( + parent_ref: &HttpRouteParentRefs, + gw_ns: &str, + conditions: &[Condition], +) -> RouteParentStatus { + RouteParentStatus { + parent_ref: ParentReference { + group: GATEWAY_GROUP.to_owned(), + kind: "Gateway".to_owned(), + name: parent_ref.name.clone(), + namespace: gw_ns.to_owned(), + section_name: parent_ref.section_name.clone(), + }, + controller_name: CONTROLLER_NAME.to_owned(), + conditions: conditions.to_vec(), + } } /// Merges `computed` parent entries into the route's live status and @@ -236,10 +236,17 @@ pub fn parent_status_with_conditions(parent_ref: &HttpRouteParentRefs, gw_ns: &s /// Returns an error if the live status cannot be deserialized or if /// the status patch is rejected. When the merged status equals what is /// already stored no patch is sent, so an unchanged route cannot fail. -pub async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRoute, computed: &[Value]) -> Result<()> { +pub async fn apply_parent_statuses( + client: &kube::Client, + route: &HTTPRoute, + computed: &[RouteParentStatus], +) -> Result<()> { let ns = route_namespace(route); let name = route.name_any(); + let computed = serde_json::to_value(computed)?; + let computed = computed.as_array().map_or(&[][..], Vec::as_slice); + let observed = json!({ "parents": observed_parents(route)? }); let mut desired = json!({ "parents": merge_parent_statuses(&observed, computed) }); status::preserve_condition_times(&mut desired, &observed); @@ -251,12 +258,7 @@ pub async fn apply_parent_statuses(client: &kube::Client, route: &HTTPRoute, com } metrics::global().record_status_written(); - let payload = json!({ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": { "name": name, "namespace": ns }, - "status": desired, - }); + let payload = status_types::status_patch("HTTPRoute", &name, Some(ns), desired); Api::::namespaced(client.clone(), ns) .patch_status( @@ -296,12 +298,7 @@ pub async fn clear_parent_statuses(client: &kube::Client, route: &HTTPRoute, gw_ return Ok(()); } - let payload = json!({ - "apiVersion": "gateway.networking.k8s.io/v1", - "kind": "HTTPRoute", - "metadata": { "name": name, "namespace": ns }, - "status": { "parents": retained }, - }); + let payload = status_types::status_patch("HTTPRoute", &name, Some(ns), json!({ "parents": retained })); debug!("clearing parent status for deleted Gateway {gw_ns}/{gw_name} from HTTPRoute {ns}/{name}"); Api::::namespaced(client.clone(), ns) @@ -566,7 +563,13 @@ mod tests { fn test_parent_status_json_shape() { let accepted = conditions::accepted(1, "route accepted"); let resolved = conditions::resolved_refs(1, "all backend refs resolved"); - let entry = parent_status_json(&parent_ref("gw", None), "infra", &accepted, &resolved); + let entry = serde_json::to_value(parent_status_json( + &parent_ref("gw", None), + "infra", + &accepted, + &resolved, + )) + .expect("a parent status is strings and conditions"); assert_eq!(entry["parentRef"]["name"], "gw", "parentRef should name the Gateway"); assert_eq!( @@ -591,7 +594,8 @@ mod tests { let mut reference = parent_ref("gw", None); reference.section_name = Some("https".to_owned()); - let entry = parent_status_json(&reference, "infra", &accepted, &resolved); + let entry = serde_json::to_value(parent_status_json(&reference, "infra", &accepted, &resolved)) + .expect("a parent status is strings and conditions"); assert_eq!( entry["parentRef"]["sectionName"], "https", diff --git a/src/gateway_api/status_types.rs b/src/gateway_api/status_types.rs new file mode 100644 index 0000000..cd2b00d --- /dev/null +++ b/src/gateway_api/status_types.rs @@ -0,0 +1,364 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! Typed status documents. +//! +//! Every status the operator writes used to be assembled with `json!` +//! at the point of use, which put the Gateway API's field names into +//! string literals scattered across four modules. A typo in any of them +//! produces a patch the API server accepts and silently ignores — the +//! field simply never appears — so the failure surfaces as a Gateway +//! that never becomes Programmed rather than as an error. +//! +//! Declaring the documents once makes the field names a compile-time +//! concern. The merge and comparison logic in [`status`] keeps operating +//! on `Value`, because it has to handle whatever the API server already +//! holds and not merely what this operator writes; only construction +//! moves into types. +//! +//! [`status`]: super::status + +use k8s_openapi::apimachinery::pkg::apis::meta::v1::Condition; +use serde::Serialize; +use serde_json::{Value, json}; + +// ----------------------------------------------------------------------------- +// Constants +// ----------------------------------------------------------------------------- + +/// API version every Gateway API status patch declares. +const GATEWAY_API_VERSION: &str = "gateway.networking.k8s.io/v1"; + +/// API group owning Gateway API kinds. +pub const GATEWAY_GROUP: &str = "gateway.networking.k8s.io"; + +// ----------------------------------------------------------------------------- +// Route Status +// ----------------------------------------------------------------------------- + +/// The parent a `status.parents` entry reports on. +/// +/// Entry identity is this whole object: the merge in [`super::status`] +/// pairs a computed entry with a live one by comparing `parentRef`, so +/// what is serialized here has to match what the API server stores. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ParentReference { + /// API group of the parent, always the Gateway API group. + pub group: String, + + /// Parent kind, always `Gateway`. + pub kind: String, + + /// Parent Gateway name. + pub name: String, + + /// Namespace holding the parent Gateway. + pub namespace: String, + + /// Listener the route named, when it named one. + /// + /// Omitted rather than null: a `parentRef` without a `sectionName` + /// targets every listener, and an explicit null would not compare + /// equal to the absent field the API server stores. + #[serde(skip_serializing_if = "Option::is_none")] + pub section_name: Option, +} + +/// A `status.parents` entry on an `HTTPRoute`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct RouteParentStatus { + /// The parent this entry reports on. + pub parent_ref: ParentReference, + + /// Controller that wrote the entry, used to tell writers apart. + pub controller_name: String, + + /// Conditions this controller reports for the parent. + pub conditions: Vec, +} + +// ----------------------------------------------------------------------------- +// Gateway Status +// ----------------------------------------------------------------------------- + +/// A route kind a listener accepts. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct RouteGroupKind { + /// API group of the accepted kind. + pub group: String, + + /// The accepted kind. + pub kind: String, +} + +impl RouteGroupKind { + /// Returns the only route kind this operator serves. + pub fn httproute() -> Self { + Self { + group: GATEWAY_GROUP.to_owned(), + kind: "HTTPRoute".to_owned(), + } + } +} + +/// A `status.listeners` entry on a `Gateway`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListenerStatus { + /// Listener name, matching `spec.listeners[].name`. + pub name: String, + + /// Number of routes attached to this listener. + pub attached_routes: usize, + + /// Route kinds the listener accepts. + pub supported_kinds: Vec, + + /// Conditions reported for the listener. + pub conditions: Vec, +} + +/// A `status.addresses` entry on a `Gateway`. +#[derive(Debug, Clone, Serialize)] +pub struct GatewayAddress { + /// Address family, always `IPAddress` here. + #[serde(rename = "type")] + pub kind: String, + + /// The address itself. + pub value: String, +} + +impl GatewayAddress { + /// Returns an `IPAddress` entry for `ip`. + pub fn ip(ip: &str) -> Self { + Self { + kind: "IPAddress".to_owned(), + value: ip.to_owned(), + } + } +} + +/// The `status` sub-object of a `Gateway`. +#[derive(Debug, Clone, Serialize)] +pub struct GatewayStatus { + /// Addresses the data plane is reachable on. + pub addresses: Vec, + + /// Gateway-level conditions. + pub conditions: Vec, + + /// Per-listener status entries. + pub listeners: Vec, +} + +// ----------------------------------------------------------------------------- +// GatewayClass Status +// ----------------------------------------------------------------------------- + +/// A `status.supportedFeatures` entry. +#[derive(Debug, Clone, Serialize)] +pub struct SupportedFeature { + /// Conformance feature name. + pub name: String, +} + +/// The `status` sub-object of a `GatewayClass`. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct GatewayClassStatus { + /// Class-level conditions. + pub conditions: Vec, + + /// Conformance features this implementation claims. + pub supported_features: Vec, +} + +// ----------------------------------------------------------------------------- +// Apply Patches +// ----------------------------------------------------------------------------- + +/// Builds the server-side-apply body for a status write. +/// +/// Every writer sends the same envelope: the object's identity plus its +/// `status`. An apply patch missing `apiVersion` or `kind` is rejected +/// outright, and one naming the wrong kind is applied to nothing, so +/// the four writers share one construction rather than four literals. +/// +/// Pass `None` for `namespace` on cluster-scoped kinds. +pub fn status_patch(kind: &str, name: &str, namespace: Option<&str>, status: Value) -> Value { + let metadata = match namespace { + Some(ns) => json!({ "name": name, "namespace": ns }), + None => json!({ "name": name }), + }; + + // Built by hand rather than with `json!` so `status` is moved in + // rather than re-serialized: it is the largest value in the patch, + // and the macro would borrow and clone it. + let mut root = serde_json::Map::new(); + root.insert("apiVersion".to_owned(), json!(GATEWAY_API_VERSION)); + root.insert("kind".to_owned(), json!(kind)); + root.insert("metadata".to_owned(), metadata); + root.insert("status".to_owned(), status); + Value::Object(root) +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + /// Builds a minimal condition for serialization checks. + fn condition() -> Condition { + Condition { + last_transition_time: k8s_openapi::apimachinery::pkg::apis::meta::v1::Time( + k8s_openapi::jiff::Timestamp::UNIX_EPOCH, + ), + message: "ok".to_owned(), + observed_generation: Some(1), + reason: "Accepted".to_owned(), + status: "True".to_owned(), + type_: "Accepted".to_owned(), + } + } + + #[test] + fn test_parent_reference_omits_an_absent_section_name() { + let value = serde_json::to_value(ParentReference { + group: GATEWAY_GROUP.to_owned(), + kind: "Gateway".to_owned(), + name: "gw".to_owned(), + namespace: "infra".to_owned(), + section_name: None, + }) + .expect("a parent reference is plain strings"); + + assert_eq!( + value, + json!({ + "group": "gateway.networking.k8s.io", + "kind": "Gateway", + "name": "gw", + "namespace": "infra", + }), + "an explicit null sectionName would not compare equal to the absent field the API \ + server stores, and entry matching during merge is exact" + ); + } + + #[test] + fn test_parent_reference_carries_a_section_name_when_set() { + let value = serde_json::to_value(ParentReference { + group: GATEWAY_GROUP.to_owned(), + kind: "Gateway".to_owned(), + name: "gw".to_owned(), + namespace: "infra".to_owned(), + section_name: Some("https".to_owned()), + }) + .expect("a parent reference is plain strings"); + + assert_eq!( + value.get("sectionName").and_then(Value::as_str), + Some("https"), + "a route naming a listener must report against that listener" + ); + } + + #[test] + fn test_listener_status_uses_the_camel_case_crd_field_names() { + let value = serde_json::to_value(ListenerStatus { + name: "http".to_owned(), + attached_routes: 2, + supported_kinds: vec![RouteGroupKind::httproute()], + conditions: vec![condition()], + }) + .expect("a listener status is strings, numbers, and conditions"); + + assert_eq!( + value.get("attachedRoutes").and_then(Value::as_u64), + Some(2), + "the CRD spells this attachedRoutes; a snake_case key would be silently dropped" + ); + assert_eq!( + value.get("supportedKinds"), + Some(&json!([{ "group": "gateway.networking.k8s.io", "kind": "HTTPRoute" }])), + "supportedKinds carries group and kind per entry" + ); + } + + #[test] + fn test_gateway_status_serializes_empty_lists_as_arrays() { + let value = serde_json::to_value(GatewayStatus { + addresses: vec![], + conditions: vec![], + listeners: vec![], + }) + .expect("an empty status has nothing that can fail"); + + assert_eq!( + value, + json!({ "addresses": [], "conditions": [], "listeners": [] }), + "an absent list and an empty list are different documents to server-side apply" + ); + } + + #[test] + fn test_gateway_class_status_camel_cases_supported_features() { + let value = serde_json::to_value(GatewayClassStatus { + conditions: vec![condition()], + supported_features: vec![SupportedFeature { + name: "HTTPRoute".to_owned(), + }], + }) + .expect("a class status is strings and conditions"); + + assert_eq!( + value.get("supportedFeatures"), + Some(&json!([{ "name": "HTTPRoute" }])), + "conformance reads supportedFeatures to decide which suites to run" + ); + } + + #[test] + fn test_status_patch_includes_the_namespace_for_namespaced_kinds() { + let patch = status_patch("HTTPRoute", "route", Some("apps"), json!({ "parents": [] })); + + assert_eq!( + patch, + json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { "name": "route", "namespace": "apps" }, + "status": { "parents": [] }, + }), + "an apply patch is rejected without apiVersion and kind" + ); + } + + #[test] + fn test_status_patch_omits_the_namespace_for_cluster_scoped_kinds() { + let patch = status_patch("GatewayClass", "praxis", None, json!({ "conditions": [] })); + + assert_eq!( + patch.get("metadata"), + Some(&json!({ "name": "praxis" })), + "a namespace on a cluster-scoped object is rejected by the API server" + ); + } + + #[test] + fn test_gateway_address_labels_ips_by_type() { + let value = serde_json::to_value(GatewayAddress::ip("10.0.0.1")).expect("an address is two strings"); + + assert_eq!( + value, + json!({ "type": "IPAddress", "value": "10.0.0.1" }), + "the CRD field is `type`, which is a Rust keyword and so has to be renamed" + ); + } +} From 65f9563c845768a71ed7bd44b861e3e456f15daf Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:36:35 -0400 Subject: [PATCH 31/51] chore(deps): update the data plane to praxis 0.5.2 Signed-off-by: Shane Utt --- .github/workflows/conformance.yaml | 2 +- .github/workflows/integration.yaml | 2 +- src/context.rs | 11 +++++++++-- src/controller/gateway_class.rs | 22 ++++++++++++++-------- src/resources/deployment.rs | 28 ++++++++++++++-------------- 5 files changed, 39 insertions(+), 26 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index edca231..30006e5 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -21,7 +21,7 @@ env: CARGO_TERM_COLOR: always CONTAINER_ENGINE: docker KIND_CLUSTER_NAME: praxis-conformance - PRAXIS_IMAGE: ghcr.io/praxis-proxy/praxis:0.3.1 + PRAXIS_IMAGE: ghcr.io/praxis-proxy/praxis:0.5.2 OPERATOR_IMAGE: praxis-operator:dev jobs: diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 88ca7aa..41d6da1 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -29,7 +29,7 @@ env: KIND_CLUSTER_NAME: ci V: ${{ inputs.debug && '1' || '' }} OPERATOR_IMAGE: praxis-operator:dev - PRAXIS_IMAGE: ghcr.io/praxis-proxy/praxis:0.3.1 + PRAXIS_IMAGE: ghcr.io/praxis-proxy/praxis:0.5.2 jobs: # --------------------------------------------------------------------------- diff --git a/src/context.rs b/src/context.rs index 8952c26..1ffe541 100644 --- a/src/context.rs +++ b/src/context.rs @@ -33,7 +33,12 @@ pub const FIELD_MANAGER: &str = "praxis-operator"; pub const ADMIN_PORT: i32 = 9901; /// Image used when `PRAXIS_IMAGE` is unset. -const DEFAULT_PRAXIS_IMAGE: &str = "ghcr.io/praxis-proxy/praxis:latest"; +/// +/// Pinned rather than `latest`. The operator chooses this image for +/// every data plane it creates, so `latest` would let an unreviewed +/// proxy release change the behaviour of an unchanged operator, and the +/// version CI exercises would drift from the version users run. +const DEFAULT_PRAXIS_IMAGE: &str = "ghcr.io/praxis-proxy/praxis:0.5.2"; // ----------------------------------------------------------------------------- // Data Plane Image @@ -41,7 +46,9 @@ const DEFAULT_PRAXIS_IMAGE: &str = "ghcr.io/praxis-proxy/praxis:latest"; /// Praxis container image, configurable via `PRAXIS_IMAGE` env var. /// -/// Falls back to `ghcr.io/praxis-proxy/praxis:latest` when unset. +/// Falls back to a pinned Praxis release when unset, rather than to +/// `latest`, so an unchanged operator keeps deploying the data plane +/// its CI exercised. pub fn praxis_image() -> String { std::env::var("PRAXIS_IMAGE").unwrap_or_else(|_| DEFAULT_PRAXIS_IMAGE.to_owned()) } diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 60b573d..aebf290 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -44,16 +44,22 @@ use crate::{ /// has no field to carry them. Advertising any of them would direct /// conformance tooling at suites that cannot pass. /// -/// `HTTPRouteResponseHeaderModification` was claimed here and has been -/// withdrawn. Claiming it is what made conformance run +/// `HTTPRouteResponseHeaderModification` was claimed here and withdrawn. +/// Claiming it is what made conformance run /// `HTTPRouteResponseHeaderModifier` at all — the suites are gated on /// advertised features, which is why the test was skipped before. -/// Running it showed the data plane implements the filter only in part: -/// `set` and `remove` behave, but `add` replaces the existing header -/// rather than appending, so conformance asks for -/// `append-val-1,header-val-2` and praxis 0.3.1 returns `header-val-2`. -/// `HTTPRouteRequestHeaderModification` was never claimed, for the same -/// underlying reason. +/// Running it showed praxis 0.3.1 implemented the filter only in part: +/// `set` and `remove` behaved, but `add` replaced the existing header +/// rather than appending, so conformance asked for +/// `append-val-1,header-val-2` and got `header-val-2`. +/// +/// Praxis 0.5.2, now the pinned data plane, appends: `request_add` +/// reads the existing values and combines them. So this entry and +/// `HTTPRouteRequestHeaderModification` are both candidates to claim +/// again. Neither is restored as part of the version bump — a feature +/// claim is a promise, and the only way to check it is a conformance +/// run that actually exercises the suite, which is worth doing on its +/// own rather than confounded with an image change. /// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &["Gateway", "GatewayPort8080", "HTTPRoute", "ReferenceGrant"]; diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index c6a2257..2d126b8 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -40,21 +40,21 @@ const REPLICAS_ANNOTATION: &str = "praxis.sh/replicas"; /// /// One, matching the behaviour every existing Gateway already has. /// -/// Two would be the better availability default — a single-replica -/// Gateway is a single point of failure for every route attached to it -/// — but the data plane cannot currently sustain it. Praxis 0.3.1 -/// registers its KV admin endpoints on the same port as its health -/// endpoints via `SO_REUSEPORT`, so probe connections land on whichever -/// listener the kernel picks and roughly half of them 404. Praxis has -/// since deprecated that registration for exactly this reason -/// ("non-deterministic connection routing that breaks health probes"), -/// but on the pinned version every additional replica is another pod -/// whose liveness probe flaps and whose container is restarted, and a -/// Gateway whose pods never settle never reports Programmed. +/// This was forced rather than chosen. Praxis 0.3.1 registered its KV +/// admin endpoints on the health port via `SO_REUSEPORT`, so probe +/// connections landed on whichever listener the kernel picked and +/// roughly half of them 404'd; every extra replica was another pod +/// whose liveness probe flapped, and a Gateway whose pods never settle +/// never reports Programmed. /// -/// Raise this to two once the data plane serves health on a port of its -/// own. Until then `praxis.sh/replicas` is the opt-in for anyone who -/// wants the availability and can tolerate the flapping. +/// The pinned data plane is now 0.5.2, which no longer registers that +/// endpoint, so the constraint is gone. Two is the better availability +/// default — a single-replica Gateway is a single point of failure for +/// every route attached to it — and raising it is now a live option +/// rather than a blocked one. It is deliberately not part of the +/// version bump: that is a behaviour change deserving its own +/// conformance run, and bundling it would make a red run ambiguous. +/// `praxis.sh/replicas` remains the per-Gateway override meanwhile. const DEFAULT_REPLICAS: i32 = 1; // ----------------------------------------------------------------------------- From 1ecfe41016d3a998e1526c577fa675d5d733bfa0 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:53:42 -0400 Subject: [PATCH 32/51] tests(conformance): run the conformance suites praxis 0.5.2 can now pass Signed-off-by: Shane Utt --- benches/config_generation.rs | 2 +- hack/run-conformance.sh | 1 - src/config/filter_conversion.rs | 236 ++++++++++++++++++++++++++++---- src/controller/gateway_class.rs | 18 ++- src/controller/praxis_config.rs | 2 +- 5 files changed, 219 insertions(+), 40 deletions(-) diff --git a/benches/config_generation.rs b/benches/config_generation.rs index 4547751..8b248b1 100644 --- a/benches/config_generation.rs +++ b/benches/config_generation.rs @@ -113,7 +113,7 @@ fn generate_config(listeners: &[GatewayListeners], routes: &[Arc]) -> assemble_config(praxis_listeners, &praxis_routes, &clusters, &[], &listener_hostnames) .ok() - .and_then(|config| serde_norway::to_string(&config).ok()) + .and_then(|config| yaml_serde::to_string(&config).ok()) .map_or(0, |yaml| yaml.len()) } diff --git a/hack/run-conformance.sh b/hack/run-conformance.sh index 496fac8..122ce1a 100755 --- a/hack/run-conformance.sh +++ b/hack/run-conformance.sh @@ -59,7 +59,6 @@ go test ./conformance -run TestConformance \ -args \ --gateway-class="${GATEWAY_CLASS}" \ --conformance-profiles=GATEWAY-HTTP \ - --skip-tests=HTTPRouteReferenceGrant,HTTPRoutePartiallyInvalidViaInvalidReferenceGrant,HTTPRouteHostnameIntersection,HTTPRouteListenerHostnameMatching,HTTPRouteRequestHeaderModifier \ --timeout-config-overrides="MaxTimeToConsistency:${MAX_CONSISTENCY};NamespacesMustBeReady:${NS_READY}" \ --allow-crds-mismatch \ --report-output=/tmp/conformance-report.yaml \ diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 850ce84..e5b425f 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -101,15 +101,17 @@ struct RedirectFilterConfig { /// header modifications and redirects apply only to traffic matching the /// originating rule. pub fn convert_filters(rules: &[HttpRouteRules]) -> Vec { + let scoped = scope_rules(rules); let mut filters = Vec::new(); - for rule in rules { + for (index, rule) in rules.iter().enumerate() { + let condition = rule_conditions(index, &scoped); let has_backends = rule.backend_refs.as_ref().is_some_and(|refs| !refs.is_empty()); let has_redirect = rule_has_redirect(rule); if !has_backends && !has_redirect { - emit_no_backend_response(rule, &mut filters); + emit_no_backend_response(&condition, &mut filters); } if rule.filters.is_some() { - convert_rule_filters(rule, &mut filters); + convert_rule_filters(rule, &condition, &mut filters); } } filters @@ -124,21 +126,24 @@ fn rule_has_redirect(rule: &HttpRouteRules) -> bool { } /// Converts filters from a single rule into conditional filter entries. -fn convert_rule_filters(rule: &HttpRouteRules, filters: &mut Vec) { +fn convert_rule_filters( + rule: &HttpRouteRules, + condition: &Option, + filters: &mut Vec, +) { let Some(rule_filters) = &rule.filters else { return; }; - let condition = extract_rule_condition(rule); let mut header_config = HeaderFilterConfig::default(); let mut has_header_mods = false; for filter in rule_filters { - has_header_mods |= dispatch_filter(filter, &condition, &mut header_config, filters); + has_header_mods |= dispatch_filter(filter, condition, &mut header_config, filters); } if has_header_mods { - emit_conditional_header_filter(&header_config, &condition, filters); + emit_conditional_header_filter(&header_config, condition, filters); } } @@ -167,27 +172,98 @@ fn dispatch_filter( } } -/// Builds the Praxis filter condition scoping a rule's filters. +type Predicate = yaml_serde::Mapping; + +/// How a rule ranks against a sibling when both could match a request. /// -/// Returns a value for the `conditions` field of a Praxis filter, or -/// `None` for a rule with no constraints to scope by. +/// Gateway API resolves an overlap by path specificity first — an +/// exact match over any prefix, then the longer prefix — and breaks +/// the remaining tie on the number of header matches. Field order here +/// is the comparison order the derived `Ord` uses, so it has to stay in +/// that sequence. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord)] +struct Precedence { + /// An exact path match outranks every prefix match. + exact_path: bool, + + /// A longer prefix outranks a shorter one. + path_len: usize, + + /// More header matches break a path tie. + headers: usize, +} + +/// A rule's predicate paired with the rank it holds among its siblings. +struct ScopedRule { + /// The traffic this rule claims. + predicate: Predicate, + + /// Where the rule sits in the overlap ordering. + precedence: Precedence, +} + +/// Describes every rule by the traffic it claims and its rank. +fn scope_rules(rules: &[HttpRouteRules]) -> Vec { + rules.iter().map(scope_rule).collect() +} + +/// Describes one rule by the traffic it claims and its rank. /// -/// Filters are chain-level in Praxis, not per-route, so a filter is -/// confined to its own rule's traffic only as precisely as -/// `praxis_core::config::ConditionMatch` allows: path, path prefix, -/// methods and headers. That type has no host field, so two routes -/// sharing a listener and a path but differing only in hostname still -/// share their filters. Narrowing that further needs host matching in -/// the Praxis condition schema. -fn extract_rule_condition(rule: &HttpRouteRules) -> Option { - let first = rule.matches.as_ref()?.first()?; +/// Only the first match is read. A rule listing several matches claims +/// the union of them, which a single `ConditionMatch` cannot express. +fn scope_rule(rule: &HttpRouteRules) -> ScopedRule { + let first = rule.matches.as_deref().and_then(<[_]>::first); + ScopedRule { + predicate: first.map(rule_predicate).unwrap_or_default(), + precedence: first.map(rule_precedence).unwrap_or_default(), + } +} - let mut predicate = yaml_serde::Mapping::new(); - insert_path_predicate(first, &mut predicate); - insert_header_predicate(first, &mut predicate); +/// Builds the predicate matching one rule's traffic. +fn rule_predicate(m: &HttpRouteRulesMatches) -> Predicate { + let mut predicate = Predicate::new(); + insert_path_predicate(m, &mut predicate); + insert_header_predicate(m, &mut predicate); + predicate +} - if predicate.is_empty() { - return None; +/// Ranks one rule's match against its siblings. +fn rule_precedence(m: &HttpRouteRulesMatches) -> Precedence { + let path = m.path.as_ref(); + Precedence { + exact_path: matches!( + path.and_then(|p| p.r#type.as_ref()), + Some(HttpRouteRulesMatchesPathType::Exact) + ), + path_len: path.and_then(|p| p.value.as_deref()).map_or(0, str::len), + headers: m.headers.as_deref().map_or(0, <[_]>::len), + } +} + +/// Builds the Praxis `conditions` list scoping one rule's filters. +/// +/// The rule's own predicate becomes a `when`, and the predicate of +/// every sibling that outranks it becomes an `unless`. The `unless` +/// clauses are what keep a chain-level filter inside its own rule: +/// Gateway API hands overlapping traffic to the higher-ranked rule, so +/// the loser's filters must not touch it. Without them a rule that +/// declares no `matches` at all — which claims every request — would +/// rewrite or re-header the traffic of every other rule on the +/// listener. +/// +/// A sibling whose traffic cannot overlap is still listed. Its +/// predicate then only ever fails against requests that already fail +/// this rule's own `when`, so the extra clause costs a comparison and +/// changes nothing. +fn rule_conditions(index: usize, scoped: &[ScopedRule]) -> Option { + let own = scoped.get(index)?; + + let mut conditions = Vec::new(); + if !own.predicate.is_empty() { + conditions.push(condition_entry("when", &own.predicate)); + } + for predicate in outranking_predicates(own, scoped) { + conditions.push(condition_entry("unless", predicate)); } let entry = yaml_serde::Mapping::from_iter([( @@ -198,6 +274,27 @@ fn extract_rule_condition(rule: &HttpRouteRules) -> Option { Some(yaml_serde::Value::Sequence(vec![yaml_serde::Value::Mapping(entry)])) } +/// Collects the distinct predicates of the rules that outrank `own`. +fn outranking_predicates<'a>(own: &ScopedRule, scoped: &'a [ScopedRule]) -> Vec<&'a Predicate> { + let mut collected: Vec<&Predicate> = Vec::new(); + for other in scoped { + let outranks = other.precedence > own.precedence && !other.predicate.is_empty(); + if outranks && !collected.contains(&&other.predicate) { + collected.push(&other.predicate); + } + } + collected +} + +/// Wraps a predicate in a `when` or `unless` condition entry. +fn condition_entry(keyword: &str, predicate: &Predicate) -> yaml_serde::Value { + yaml_serde::Value::Mapping(yaml_serde::Mapping::from_iter([( + yaml_serde::Value::String(keyword.to_owned()), + yaml_serde::Value::Mapping(predicate.clone()), + )])) +>>>>>>> ac1f7e7 (fix: scope a rule's filters against the rules that outrank it) +} + /// Adds the path constraint to a filter predicate. /// /// An `Exact` match uses the Praxis `path` field and a `PathPrefix` @@ -436,7 +533,6 @@ fn emit_conditional_header_filter( /// Emits a `static_response` filter returning 500 for rules with no backends. fn emit_no_backend_response(rule: &HttpRouteRules, filters: &mut Vec) { - let condition = extract_rule_condition(rule); let mut config = yaml_serde::Mapping::new(); config.insert( yaml_serde::Value::String("status".to_owned()), @@ -736,7 +832,7 @@ mod tests { #[test] fn test_exact_path_scopes_on_path_not_prefix() { let rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/foo"); - let cond = extract_rule_condition(&rule).expect("an exact path should produce a condition"); + let cond = lone_rule_condition(&rule).expect("an exact path should produce a condition"); let when = &cond[0]["when"]; assert_eq!( @@ -753,7 +849,7 @@ mod tests { #[test] fn test_prefix_path_scopes_on_path_prefix() { let rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/api"); - let cond = extract_rule_condition(&rule).expect("a prefix path should produce a condition"); + let cond = lone_rule_condition(&rule).expect("a prefix path should produce a condition"); assert_eq!( cond[0]["when"]["path_prefix"], @@ -775,7 +871,7 @@ mod tests { }]); } - let cond = extract_rule_condition(&rule).expect("condition expected"); + let cond = lone_rule_condition(&rule).expect("condition expected"); assert_eq!( cond[0]["when"]["headers"]["x-tenant"], @@ -799,7 +895,7 @@ mod tests { ..Default::default() }; - let cond = extract_rule_condition(&rule).expect("a header-only rule should still be scoped"); + let cond = lone_rule_condition(&rule).expect("a header-only rule should still be scoped"); assert!( cond[0]["when"].get("headers").is_some(), @@ -815,15 +911,95 @@ mod tests { }; assert!( - extract_rule_condition(&rule).is_none(), + lone_rule_condition(&rule).is_none(), "a rule with nothing to match on cannot be scoped and must stay unconditional" ); } + #[test] + fn test_catch_all_rule_is_scoped_away_from_its_siblings() { + let rules = vec![ + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), + HttpRouteRules::default(), + ]; + let scoped = scope_rules(&rules); + + let cond = rule_conditions(1, &scoped).expect("a catch-all rule beside a narrower one must be scoped"); + + assert_eq!( + cond[0]["unless"]["path_prefix"], + yaml_serde::Value::String("/one".to_owned()), + "Gateway API gives /one traffic to the narrower rule, so the catch-all rule's filters \ + must skip it — without this the catch-all rewrites every request on the listener" + ); + assert!( + cond[0].get("when").is_none(), + "a catch-all rule claims no traffic of its own to gate on" + ); + } + + #[test] + fn test_a_narrower_rule_is_not_scoped_against_a_broader_one() { + let rules = vec![ + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one/two"), + ]; + let scoped = scope_rules(&rules); + + let cond = rule_conditions(1, &scoped).expect("a rule with a path match is always scoped"); + + assert_eq!( + cond.as_sequence().map(Vec::len), + Some(1), + "the longer prefix wins the overlap, so it needs no unless clause against the shorter one" + ); + } + + #[test] + fn test_an_exact_match_outranks_a_longer_prefix() { + let rules = vec![ + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one/two/three"), + rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/one"), + ]; + let scoped = scope_rules(&rules); + + let cond = rule_conditions(0, &scoped).expect("a rule with a path match is always scoped"); + + assert_eq!( + cond[1]["unless"]["path"], + yaml_serde::Value::String("/one".to_owned()), + "Gateway API ranks an exact match above any prefix, however long" + ); + } + + #[test] + fn test_identical_predicates_are_listed_once() { + let rules = vec![ + HttpRouteRules::default(), + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), + rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), + ]; + let scoped = scope_rules(&rules); + + let cond = rule_conditions(0, &scoped).expect("a catch-all rule beside narrower ones must be scoped"); + + assert_eq!( + cond.as_sequence().map(Vec::len), + Some(1), + "two siblings claiming the same traffic need one unless clause, not two" + ); + } + // ----------------------------------------------------------------------- // Test Utilities // ----------------------------------------------------------------------- + /// Scopes a rule that has no siblings to be scoped against. + fn lone_rule_condition(rule: &HttpRouteRules) -> Option { + let rules = [rule.clone()]; + rule_conditions(0, &scope_rules(&rules)) + } + /// Builds a rule with a single path match of the given type. fn rule_with_path(kind: HttpRouteRulesMatchesPathType, value: &str) -> HttpRouteRules { HttpRouteRules { diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index aebf290..7dada3c 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -54,15 +54,19 @@ use crate::{ /// `append-val-1,header-val-2` and got `header-val-2`. /// /// Praxis 0.5.2, now the pinned data plane, appends: `request_add` -/// reads the existing values and combines them. So this entry and -/// `HTTPRouteRequestHeaderModification` are both candidates to claim -/// again. Neither is restored as part of the version bump — a feature -/// claim is a promise, and the only way to check it is a conformance -/// run that actually exercises the suite, which is worth doing on its -/// own rather than confounded with an image change. +/// reads the existing values and combines them. Both header-modification +/// features are claimed again on that basis, and the conformance suites +/// they gate are no longer skipped. /// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route -const SUPPORTED_FEATURES: &[&str] = &["Gateway", "GatewayPort8080", "HTTPRoute", "ReferenceGrant"]; +const SUPPORTED_FEATURES: &[&str] = &[ + "Gateway", + "GatewayPort8080", + "HTTPRoute", + "HTTPRouteRequestHeaderModification", + "HTTPRouteResponseHeaderModification", + "ReferenceGrant", +]; // ----------------------------------------------------------------------------- // Reconciler diff --git a/src/controller/praxis_config.rs b/src/controller/praxis_config.rs index 0c978fd..9476403 100644 --- a/src/controller/praxis_config.rs +++ b/src/controller/praxis_config.rs @@ -93,7 +93,7 @@ pub(super) async fn build_praxis_config( )?; Ok(PraxisConfigOutput { - config_yaml: serde_norway::to_string(&config)?, + config_yaml: yaml_serde::to_string(&config)?, listener_ports: collect_listener_ports(&supported), tls_secret_names: collect_tls_secret_names(&supported), }) From dc3a7b2eb8170db29000d5f2c6bb9c9ffc103af8 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:37:28 -0400 Subject: [PATCH 33/51] chore: place route filters on the side of the router they belong on Signed-off-by: Shane Utt --- benches/config_generation.rs | 15 +++-- src/config/filter_conversion.rs | 107 +++++++++++++++++++++----------- src/config/generate.rs | 81 ++++++++++++++++-------- src/controller/praxis_config.rs | 10 +-- 4 files changed, 143 insertions(+), 70 deletions(-) diff --git a/benches/config_generation.rs b/benches/config_generation.rs index 8b248b1..49c0aa3 100644 --- a/benches/config_generation.rs +++ b/benches/config_generation.rs @@ -29,6 +29,7 @@ use gateway_api::{gateways::GatewayListeners, httproutes::HTTPRoute}; use praxis_operator::{ config::{ cluster::{PraxisCluster, build_cluster}, + filter_conversion::RouteFilters, generate::assemble_config, listener::convert_listener, routing::{BackendRef, convert_routes}, @@ -111,10 +112,16 @@ fn generate_config(listeners: &[GatewayListeners], routes: &[Arc]) -> let (praxis_routes, backend_refs) = convert_routes(&attached, &listener_hostnames, &[]); let clusters = synthesize_clusters(&backend_refs); - assemble_config(praxis_listeners, &praxis_routes, &clusters, &[], &listener_hostnames) - .ok() - .and_then(|config| yaml_serde::to_string(&config).ok()) - .map_or(0, |yaml| yaml.len()) + assemble_config( + praxis_listeners, + &praxis_routes, + &clusters, + &RouteFilters::default(), + &listener_hostnames, + ) + .ok() + .and_then(|config| yaml_serde::to_string(&config).ok()) + .map_or(0, |yaml| yaml.len()) } /// Builds clusters with fixed endpoints, standing in for the API reads diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index e5b425f..bfd972b 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -90,6 +90,41 @@ struct RedirectFilterConfig { location: String, } +// ----------------------------------------------------------------------------- +// RouteFilters +// ----------------------------------------------------------------------------- + +/// The filters one Gateway's routes contribute, split by where in the +/// chain they have to run. +/// +/// Praxis evaluates a chain in order and the `router` filter sits in the +/// middle of it, so a route filter's position is not a matter of taste. +/// The router picks a cluster from the request's path and `Host` header, +/// reading `ctx.rewritten_path` in preference to the original URI, and +/// rejects with 404 when nothing matches. +/// +/// That splits route filters in two. A filter that answers the request +/// itself belongs before the router, because the rule it came from has +/// no backend and therefore no route for the router to find. A filter +/// that changes what the router reads — the path or the `Host` header — +/// belongs after it, because Gateway API selects the rule from the +/// request as it arrived and applies the rule's transformations to what +/// is forwarded upstream. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct RouteFilters { + /// Filters that answer the request without an upstream. + /// + /// Run before the router, which would otherwise 404 the traffic + /// they exist to serve. + pub terminating: Vec, + + /// Filters that reshape a request the router has already placed. + /// + /// Run after the router, so route selection sees the request the + /// client sent. + pub transforming: Vec, +} + // ----------------------------------------------------------------------------- // Filter Conversion // ----------------------------------------------------------------------------- @@ -100,15 +135,15 @@ struct RedirectFilterConfig { /// filters (`conditions`) derived from the rule's path match. This ensures /// header modifications and redirects apply only to traffic matching the /// originating rule. -pub fn convert_filters(rules: &[HttpRouteRules]) -> Vec { +pub fn convert_filters(rules: &[HttpRouteRules]) -> RouteFilters { let scoped = scope_rules(rules); - let mut filters = Vec::new(); + let mut filters = RouteFilters::default(); for (index, rule) in rules.iter().enumerate() { let condition = rule_conditions(index, &scoped); let has_backends = rule.backend_refs.as_ref().is_some_and(|refs| !refs.is_empty()); let has_redirect = rule_has_redirect(rule); if !has_backends && !has_redirect { - emit_no_backend_response(&condition, &mut filters); + emit_no_backend_response(&condition, &mut filters.terminating); } if rule.filters.is_some() { convert_rule_filters(rule, &condition, &mut filters); @@ -126,11 +161,7 @@ fn rule_has_redirect(rule: &HttpRouteRules) -> bool { } /// Converts filters from a single rule into conditional filter entries. -fn convert_rule_filters( - rule: &HttpRouteRules, - condition: &Option, - filters: &mut Vec, -) { +fn convert_rule_filters(rule: &HttpRouteRules, condition: &Option, filters: &mut RouteFilters) { let Some(rule_filters) = &rule.filters else { return; }; @@ -143,7 +174,7 @@ fn convert_rule_filters( } if has_header_mods { - emit_conditional_header_filter(&header_config, condition, filters); + emit_conditional_header_filter(&header_config, condition, &mut filters.transforming); } } @@ -154,14 +185,14 @@ fn dispatch_filter( filter: &HttpRouteRulesFilters, condition: &Option, header_config: &mut HeaderFilterConfig, - filters: &mut Vec, + filters: &mut RouteFilters, ) -> bool { match &filter.r#type { HttpRouteRulesFiltersType::RequestHeaderModifier => dispatch_request_header(filter, header_config), HttpRouteRulesFiltersType::ResponseHeaderModifier => dispatch_response_header(filter, header_config), HttpRouteRulesFiltersType::RequestRedirect => { if let Some(redirect) = &filter.request_redirect { - emit_conditional_redirect(redirect, condition, filters); + emit_conditional_redirect(redirect, condition, &mut filters.terminating); } false }, @@ -266,12 +297,7 @@ fn rule_conditions(index: usize, scoped: &[ScopedRule]) -> Option yaml_serde::Value { yaml_serde::Value::String(keyword.to_owned()), yaml_serde::Value::Mapping(predicate.clone()), )])) ->>>>>>> ac1f7e7 (fix: scope a rule's filters against the rules that outrank it) } /// Adds the path constraint to a filter predicate. @@ -532,7 +557,7 @@ fn emit_conditional_header_filter( } /// Emits a `static_response` filter returning 500 for rules with no backends. -fn emit_no_backend_response(rule: &HttpRouteRules, filters: &mut Vec) { +fn emit_no_backend_response(condition: &Option, filters: &mut Vec) { let mut config = yaml_serde::Mapping::new(); config.insert( yaml_serde::Value::String("status".to_owned()), @@ -542,7 +567,7 @@ fn emit_no_backend_response(rule: &HttpRouteRules, filters: &mut Vec, routes: &[PraxisRoute], clusters: &[PraxisCluster], - extra_filters: &[PraxisFilterEntry], + route_filters: &RouteFilters, listener_hostnames: &std::collections::HashMap>, ) -> yaml_serde::Result { let filter_chains: Vec<_> = listeners .iter() - .map(|l| build_filter_chain(l, routes, clusters, extra_filters, listener_hostnames)) + .map(|l| build_filter_chain(l, routes, clusters, route_filters, listener_hostnames)) .collect::>>()?; Ok(PraxisConfig { @@ -99,8 +100,11 @@ pub fn assemble_config( /// Builds a single filter chain for a listener. /// -/// The chain contains `request_id`, `router` (with matching routes), -/// any extra filters, and `load_balancer` (with embedded clusters). +/// The chain runs `request_id`, the route filters that answer a request +/// without an upstream, `router` (with matching routes), the route +/// filters that reshape a routed request, and finally `load_balancer` +/// (with embedded clusters). [`RouteFilters`] explains why the router +/// splits the two groups. /// /// # Errors /// @@ -109,7 +113,7 @@ fn build_filter_chain( listener: &PraxisListener, routes: &[PraxisRoute], clusters: &[PraxisCluster], - extra_filters: &[PraxisFilterEntry], + route_filters: &RouteFilters, listener_hostnames: &std::collections::HashMap>, ) -> yaml_serde::Result { let name = &listener.name; @@ -133,8 +137,9 @@ fn build_filter_chain( filter: "request_id".to_owned(), config: yaml_serde::Value::Null, }]; - filters.extend_from_slice(extra_filters); + filters.extend_from_slice(&route_filters.terminating); filters.push(build_router_filter(&scoped_refs)?); + filters.extend_from_slice(&route_filters.transforming); filters.push(build_lb_filter(clusters)?); Ok(PraxisFilterChain { @@ -313,7 +318,14 @@ mod tests { let cluster = build_cluster("default~my-svc~8080", vec!["10.0.0.1:8080".to_owned()], None); - let config = assemble_config(vec![listener], &[route], &[cluster], &[], &Default::default()).unwrap(); + let config = assemble_config( + vec![listener], + &[route], + &[cluster], + &RouteFilters::default(), + &Default::default(), + ) + .unwrap(); assert_eq!(config.admin.address, "0.0.0.0:9901", "admin address should be set"); assert_eq!(config.listeners.len(), 1, "should have one listener"); @@ -366,7 +378,14 @@ mod tests { let cluster = build_cluster("default~svc~80", vec!["10.0.0.1:80".to_owned()], None); - let config = assemble_config(vec![listener], &[route], &[cluster], &[], &Default::default()).unwrap(); + let config = assemble_config( + vec![listener], + &[route], + &[cluster], + &RouteFilters::default(), + &Default::default(), + ) + .unwrap(); let yaml = yaml_serde::to_string(&config).expect("config should serialize to YAML"); @@ -430,7 +449,7 @@ mod tests { vec![http_listener, https_listener], &[route], &[cluster], - &[], + &RouteFilters::default(), &Default::default(), ) .unwrap(); @@ -478,7 +497,7 @@ mod tests { vec![listener], std::slice::from_ref(&route), &[cluster], - &[], + &RouteFilters::default(), &Default::default(), ) .unwrap(); @@ -520,7 +539,7 @@ mod tests { tls: None, }; - let config = assemble_config(vec![listener], &[], &[], &[], &Default::default()).unwrap(); + let config = assemble_config(vec![listener], &[], &[], &RouteFilters::default(), &Default::default()).unwrap(); let chain = &config.filter_chains[0]; let lb_config = &chain.filters[2].config; @@ -580,7 +599,7 @@ mod tests { vec![listener], &[route_l1, route_l2, route_l3], &[cluster_v1, cluster_v2, cluster_v3], - &[], + &RouteFilters::default(), &Default::default(), ) .unwrap(); @@ -598,7 +617,7 @@ mod tests { } #[test] - fn test_extra_filters_placed_before_router() { + fn test_terminating_filters_run_before_the_router() { let listener = PraxisListener { name: "http".to_owned(), address: "0.0.0.0:80".to_owned(), @@ -614,21 +633,26 @@ mod tests { config: yaml_serde::Value::Null, }; - let config = assemble_config(vec![listener], &[], &[], &[redirect], &Default::default()).unwrap(); + let headers = PraxisFilterEntry { + filter: "headers".to_owned(), + config: yaml_serde::Value::Null, + }; + + let route_filters = RouteFilters { + terminating: vec![redirect], + transforming: vec![headers], + }; + + let config = assemble_config(vec![listener], &[], &[], &route_filters, &Default::default()).unwrap(); let chain = &config.filter_chains[0]; let names: Vec<_> = chain.filters.iter().map(|f| f.filter.as_str()).collect(); - let redirect_pos = names.iter().position(|n| *n == "redirect"); - let router_pos = names.iter().position(|n| *n == "router"); - - assert!( - redirect_pos.is_some() && router_pos.is_some(), - "both redirect and router should be in the chain: {names:?}" - ); - assert!( - redirect_pos.expect("redirect present") < router_pos.expect("router present"), - "redirect must come before router so conditional redirects fire before routing: {names:?}" + assert_eq!( + names, + vec!["request_id", "redirect", "router", "headers", "load_balancer"], + "a redirect answers the request its rule has no backend for, so it has to precede the \ + router's 404; a header modifier changes what the router reads and so has to follow it" ); } @@ -658,7 +682,14 @@ mod tests { }; let cluster = build_cluster("v3", vec!["10.0.0.3:80".to_owned()], None); - let config = assemble_config(vec![listener], &[route], &[cluster], &[], &hostnames).unwrap(); + let config = assemble_config( + vec![listener], + &[route], + &[cluster], + &RouteFilters::default(), + &hostnames, + ) + .unwrap(); let chain = &config.filter_chains[0]; let router_config = &chain.filters[1].config; diff --git a/src/controller/praxis_config.rs b/src/controller/praxis_config.rs index 9476403..774ab4b 100644 --- a/src/controller/praxis_config.rs +++ b/src/controller/praxis_config.rs @@ -23,10 +23,10 @@ use tracing::debug; use crate::{ config::{ cluster::{PraxisCluster, build_cluster}, - filter_conversion::convert_filters, + filter_conversion::{RouteFilters, convert_filters}, generate::assemble_config, listener::{PraxisCertificate, PraxisListener, PraxisTls, convert_listener}, - routing::{BackendRef, PraxisFilterEntry, PraxisRoute, convert_routes}, + routing::{BackendRef, PraxisRoute, convert_routes}, weights::{ResolvedBackend, distribute_service_weights, sort_service_endpoints}, }, endpoints, @@ -82,13 +82,13 @@ pub(super) async fn build_praxis_config( let listener_hostnames = build_listener_hostname_map(&supported); let praxis_listeners = merge_listeners_by_port(&supported); let (praxis_routes, backend_refs) = convert_attached_routes(attached, &listener_hostnames, grants); - let extra_filters = collect_filters(attached); + let route_filters = collect_filters(attached); let clusters = resolve_clusters(client, &backend_refs).await?; let config = assemble_config( praxis_listeners, &praxis_routes, &clusters, - &extra_filters, + &route_filters, &listener_hostnames, )?; @@ -175,7 +175,7 @@ fn convert_attached_routes( } /// Extracts and converts filters from all attached route rules. -fn collect_filters(attached: &[AttachedRoute<'_>]) -> Vec { +fn collect_filters(attached: &[AttachedRoute<'_>]) -> RouteFilters { let all_rules: Vec<_> = attached .iter() .flat_map(|attached| attached.route.spec.rules.as_deref().unwrap_or(&[])) From 9a50cdaf8a32f6a1ac6ee1c9908dfa2960012a91 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:37:43 -0400 Subject: [PATCH 34/51] feat: implement URLRewrite Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 398 +++++++++++++++++++++++++++- src/controller/gateway_class.rs | 20 +- src/gateway_api/route_validation.rs | 154 ++++++++++- 3 files changed, 550 insertions(+), 22 deletions(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index bfd972b..510ab33 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -6,6 +6,7 @@ use gateway_api::httproutes::{ HttpRouteRules, HttpRouteRulesFilters, HttpRouteRulesFiltersRequestHeaderModifier, HttpRouteRulesFiltersRequestRedirectScheme, HttpRouteRulesFiltersResponseHeaderModifier, HttpRouteRulesFiltersType, + HttpRouteRulesFiltersUrlRewrite, HttpRouteRulesFiltersUrlRewritePath, HttpRouteRulesFiltersUrlRewritePathType, HttpRouteRulesMatches, HttpRouteRulesMatchesPathType, }; use serde::Serialize; @@ -160,17 +161,33 @@ fn rule_has_redirect(rule: &HttpRouteRules) -> bool { }) } +/// What a rule's filters need to know about the rule that carries them. +struct RuleContext<'a> { + /// The Praxis `conditions` list gating every filter of this rule. + condition: &'a Option, + + /// The `PathPrefix` value the rule matched on, if it matched one. + /// + /// `ReplacePrefixMatch` replaces exactly this much of the request + /// path, so the rewrite cannot be built without it. + prefix: Option<&'a str>, +} + /// Converts filters from a single rule into conditional filter entries. fn convert_rule_filters(rule: &HttpRouteRules, condition: &Option, filters: &mut RouteFilters) { let Some(rule_filters) = &rule.filters else { return; }; + let ctx = RuleContext { + condition, + prefix: matched_prefix(rule), + }; let mut header_config = HeaderFilterConfig::default(); let mut has_header_mods = false; for filter in rule_filters { - has_header_mods |= dispatch_filter(filter, condition, &mut header_config, filters); + has_header_mods |= dispatch_filter(filter, &ctx, &mut header_config, filters); } if has_header_mods { @@ -178,12 +195,21 @@ fn convert_rule_filters(rule: &HttpRouteRules, condition: &Option Option<&str> { + let path = rule.matches.as_deref().and_then(<[_]>::first)?.path.as_ref()?; + match path.r#type { + Some(HttpRouteRulesMatchesPathType::PathPrefix) => path.value.as_deref(), + _ => None, + } +} + /// Dispatches a single filter to the appropriate handler. /// /// Returns `true` if header config was modified. fn dispatch_filter( filter: &HttpRouteRulesFilters, - condition: &Option, + ctx: &RuleContext<'_>, header_config: &mut HeaderFilterConfig, filters: &mut RouteFilters, ) -> bool { @@ -192,10 +218,11 @@ fn dispatch_filter( HttpRouteRulesFiltersType::ResponseHeaderModifier => dispatch_response_header(filter, header_config), HttpRouteRulesFiltersType::RequestRedirect => { if let Some(redirect) = &filter.request_redirect { - emit_conditional_redirect(redirect, condition, &mut filters.terminating); + emit_conditional_redirect(redirect, ctx.condition, &mut filters.terminating); } false }, + HttpRouteRulesFiltersType::UrlRewrite => dispatch_url_rewrite(filter, ctx, header_config, filters), other => { warn!(?other, "unsupported filter type, ignoring"); false @@ -204,6 +231,46 @@ fn dispatch_filter( } type Predicate = yaml_serde::Mapping; +/// Dispatches a `URLRewrite` filter. +/// +/// Returns `true` when the hostname rewrite put a `Host` header into +/// the rule's header config. +fn dispatch_url_rewrite( + filter: &HttpRouteRulesFilters, + ctx: &RuleContext<'_>, + header_config: &mut HeaderFilterConfig, + filters: &mut RouteFilters, +) -> bool { + let Some(rewrite) = &filter.url_rewrite else { + return false; + }; + + if let Some(path) = &rewrite.path { + emit_conditional_path_rewrite(path, ctx, &mut filters.transforming); + } + rewrite_host(rewrite, header_config) +} + +/// Records a hostname rewrite as a `Host` request header override. +/// +/// Praxis has no dedicated host rewrite, but it also never replaces the +/// `Host` header on the way upstream, so setting it is the rewrite. The +/// filter runs after the router — see [`RouteFilters`] — which is what +/// keeps the new hostname out of route selection. +fn rewrite_host(rewrite: &HttpRouteRulesFiltersUrlRewrite, header_config: &mut HeaderFilterConfig) -> bool { + let Some(hostname) = rewrite.hostname.as_deref() else { + return false; + }; + + header_config + .request_set + .get_or_insert_with(Vec::new) + .push(HeaderEntry { + name: "host".to_owned(), + value: hostname.to_owned(), + }); + true +} /// How a rule ranks against a sibling when both could match a request. /// @@ -538,6 +605,157 @@ fn build_redirect_location(redirect: &gateway_api::httproutes::HttpRouteRulesFil } } +// ----------------------------------------------------------------------------- +// Path Rewrite +// ----------------------------------------------------------------------------- + +/// Emits a conditional `path_rewrite` filter for a `URLRewrite` path. +/// +/// Every entry carries `allow_rewrite_override`. Praxis rejects a chain +/// holding more than one rewrite filter unless the later ones opt in, +/// and a Gateway whose routes rewrite two different prefixes produces +/// exactly that — even though the `conditions` make the two mutually +/// exclusive at request time. Setting the flag on all of them beats +/// tracking which one happens to be emitted first; it is inert on the +/// first entry, which the check never inspects. +fn emit_conditional_path_rewrite( + path: &HttpRouteRulesFiltersUrlRewritePath, + ctx: &RuleContext<'_>, + filters: &mut Vec, +) { + let Some(mut config) = path_rewrite_config(path, ctx.prefix) else { + return; + }; + config.insert( + yaml_serde::Value::String("allow_rewrite_override".to_owned()), + yaml_serde::Value::Bool(true), + ); + + let config = inject_conditions(yaml_serde::Value::Mapping(config), ctx.condition); + filters.push(PraxisFilterEntry { + filter: "path_rewrite".to_owned(), + config, + }); +} + +/// Builds the `path_rewrite` config for one `URLRewrite` path modifier. +/// +/// Returns `None` when the rewrite names no replacement, or when a +/// `ReplacePrefixMatch` reaches here on a rule with no prefix to +/// replace. [`validate_route`] rejects that rule before the config is +/// generated, so this is the belt to that braces. +/// +/// [`validate_route`]: crate::gateway_api::route_validation::validate_route +fn path_rewrite_config( + path: &HttpRouteRulesFiltersUrlRewritePath, + prefix: Option<&str>, +) -> Option { + match path.r#type { + HttpRouteRulesFiltersUrlRewritePathType::ReplaceFullPath => { + Some(replace_full_path(path.replace_full_path.as_deref()?)) + }, + HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch => { + let Some(prefix) = prefix else { + warn!("ReplacePrefixMatch on a rule with no PathPrefix match, ignoring"); + return None; + }; + Some(replace_prefix_match(prefix, path.replace_prefix_match.as_deref()?)) + }, + } +} + +/// Builds the config that replaces the whole request path. +/// +/// Praxis offers no "set the path" operation, so this is a regex +/// replace of everything. The query string is not part of what the +/// pattern sees; `path_rewrite` re-attaches it afterwards. +fn replace_full_path(replacement: &str) -> yaml_serde::Mapping { + replace_operation("^.*$", &escape_replacement(replacement)) +} + +/// Builds the config that replaces the prefix the rule matched on. +/// +/// Gateway API replaces whole path segments: `/prefix/one/two` under a +/// `/prefix/one` match and a `/one` replacement becomes `/one/two`, and +/// the bare `/prefix/one` becomes `/one`. A trailing slash is +/// insignificant on both sides. +/// +/// Replacing with `/` is the one case a single regex cannot cover, +/// because the remainder is empty for the bare prefix and a +/// replacement of `${1}` would leave an empty path. Praxis's +/// `strip_prefix` has precisely the Gateway API semantics there, +/// including yielding `/` for that case, so it handles it instead. +fn replace_prefix_match(prefix: &str, replacement: &str) -> yaml_serde::Mapping { + let prefix = prefix.trim_end_matches('/'); + let replacement = replacement.trim_end_matches('/'); + + if replacement.is_empty() { + let mut config = yaml_serde::Mapping::new(); + config.insert( + yaml_serde::Value::String("strip_prefix".to_owned()), + yaml_serde::Value::String(prefix.to_owned()), + ); + return config; + } + + let pattern = format!("^{}(/.*)?$", escape_pattern(prefix)); + let replacement = format!("{}${{1}}", escape_replacement(replacement)); + replace_operation(&pattern, &replacement) +} + +/// Builds a `path_rewrite` `replace` operation. +fn replace_operation(pattern: &str, replacement: &str) -> yaml_serde::Mapping { + let mut replace = yaml_serde::Mapping::new(); + replace.insert( + yaml_serde::Value::String("pattern".to_owned()), + yaml_serde::Value::String(pattern.to_owned()), + ); + replace.insert( + yaml_serde::Value::String("replacement".to_owned()), + yaml_serde::Value::String(replacement.to_owned()), + ); + + let mut config = yaml_serde::Mapping::new(); + config.insert( + yaml_serde::Value::String("replace".to_owned()), + yaml_serde::Value::Mapping(replace), + ); + config +} + +/// Escapes a literal so a regular expression matches it verbatim. +/// +/// A path prefix routinely contains `.` and `-`, and an unescaped `.` +/// in the pattern matches any character — `/v1.api` would rewrite +/// `/v1Xapi` as well. Only the characters the `regex` crate treats as +/// meta are escaped; backslash-escaping an arbitrary character is an +/// error there, not a no-op. +fn escape_pattern(literal: &str) -> String { + /// Characters `regex::is_meta_character` reports, and the only ones + /// that crate accepts a backslash in front of. + const META: &[char] = &[ + '\\', '.', '+', '*', '?', '(', ')', '|', '[', ']', '{', '}', '^', '$', '#', '&', '-', '~', + ]; + + let mut escaped = String::with_capacity(literal.len()); + for ch in literal.chars() { + if META.contains(&ch) { + escaped.push('\\'); + } + escaped.push(ch); + } + escaped +} + +/// Escapes a replacement string so every `$` in it stays literal. +/// +/// The `regex` crate reads `$1` and `$name` in a replacement as capture +/// group references, and a rewrite target is a path the route author +/// chose, which may contain a dollar sign. +fn escape_replacement(literal: &str) -> String { + literal.replace('$', "$$") +} + /// Emits a conditional header filter entry. fn emit_conditional_header_filter( config: &HeaderFilterConfig, @@ -860,6 +1078,152 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // URL Rewrite + // ----------------------------------------------------------------------- + + #[test] + fn test_replace_prefix_match_keeps_the_remainder() { + let config = replace_prefix_match("/prefix/one", "/one"); + let replace = config + .get("replace") + .expect("a non-empty replacement is a regex replace"); + + assert_eq!( + replace["pattern"], + yaml_serde::Value::String("^/prefix/one(/.*)?$".to_owned()), + "the group has to be optional so the bare prefix rewrites too" + ); + assert_eq!( + replace["replacement"], + yaml_serde::Value::String("/one${1}".to_owned()), + "whatever followed the prefix is carried across unchanged" + ); + } + + #[test] + fn test_replace_prefix_match_ignores_trailing_slashes() { + let with_slashes = replace_prefix_match("/prefix/", "/one/"); + let without = replace_prefix_match("/prefix", "/one"); + + assert_eq!( + with_slashes, without, + "Gateway API treats a trailing slash as insignificant on both the match and the \ + replacement, and keeping one would emit //" + ); + } + + #[test] + fn test_replace_prefix_match_with_a_bare_slash_strips() { + let config = replace_prefix_match("/strip-prefix", "/"); + + assert_eq!( + config.get("strip_prefix"), + Some(&yaml_serde::Value::String("/strip-prefix".to_owned())), + "a regex replacement of ${{1}} would leave the bare prefix with an empty path, which \ + Praxis rejects; strip_prefix yields / for that case and matches the spec elsewhere" + ); + } + + #[test] + fn test_replace_full_path_discards_everything() { + let config = replace_full_path("/one"); + let replace = config.get("replace").expect("a full-path rewrite is a regex replace"); + + assert_eq!( + replace["pattern"], + yaml_serde::Value::String("^.*$".to_owned()), + "the whole path goes, however long" + ); + assert_eq!( + replace["replacement"], + yaml_serde::Value::String("/one".to_owned()), + "the replacement is the new path verbatim" + ); + } + + #[test] + fn test_a_prefix_is_matched_literally() { + let config = replace_prefix_match("/v1.api", "/v2"); + let replace = config + .get("replace") + .expect("a non-empty replacement is a regex replace"); + + assert_eq!( + replace["pattern"], + yaml_serde::Value::String("^/v1\\.api(/.*)?$".to_owned()), + "an unescaped dot matches any character, so /v1Xapi would be rewritten too" + ); + } + + #[test] + fn test_a_dollar_in_a_replacement_stays_literal() { + let config = replace_full_path("/cost/$total"); + let replace = config.get("replace").expect("a full-path rewrite is a regex replace"); + + assert_eq!( + replace["replacement"], + yaml_serde::Value::String("/cost/$$total".to_owned()), + "the regex crate reads $total as a capture group reference and would substitute nothing" + ); + } + + #[test] + fn test_host_rewrite_sets_the_host_header_after_the_router() { + let rules = vec![rule_with_rewrite( + HttpRouteRulesMatchesPathType::PathPrefix, + "/one", + HttpRouteRulesFiltersUrlRewrite { + hostname: Some("one.example.org".to_owned()), + path: None, + }, + )]; + + let filters = convert_filters(&rules); + let yaml = yaml_serde::to_string(&filters.transforming[0].config).unwrap(); + + assert_eq!( + filters.transforming[0].filter, "headers", + "a host rewrite is a header set" + ); + assert!( + yaml.contains("request_set") && yaml.contains("one.example.org"), + "the rewritten hostname has to reach the upstream as the Host header: {yaml}" + ); + assert!( + filters.terminating.is_empty(), + "setting Host before the router would change which route matched" + ); + } + + #[test] + fn test_path_rewrite_allows_overriding_a_sibling_rewrite() { + let rules = vec![ + rule_with_rewrite( + HttpRouteRulesMatchesPathType::PathPrefix, + "/one", + rewrite_to_prefix("/first"), + ), + rule_with_rewrite( + HttpRouteRulesMatchesPathType::PathPrefix, + "/two", + rewrite_to_prefix("/second"), + ), + ]; + + let filters = convert_filters(&rules); + + assert_eq!(filters.transforming.len(), 2, "each rule rewrites its own traffic"); + for entry in &filters.transforming { + assert_eq!( + entry.config.get("allow_rewrite_override"), + Some(&yaml_serde::Value::Bool(true)), + "Praxis refuses a chain with two rewrite filters unless the later ones opt in, \ + even when their conditions make them mutually exclusive" + ); + } + } + // ----------------------------------------------------------------------- // Condition Scoping // ----------------------------------------------------------------------- @@ -1029,6 +1393,34 @@ mod tests { // Test Utilities // ----------------------------------------------------------------------- + /// Builds a `ReplacePrefixMatch` rewrite to the given prefix. + fn rewrite_to_prefix(replacement: &str) -> HttpRouteRulesFiltersUrlRewrite { + HttpRouteRulesFiltersUrlRewrite { + hostname: None, + path: Some(HttpRouteRulesFiltersUrlRewritePath { + r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch, + replace_full_path: None, + replace_prefix_match: Some(replacement.to_owned()), + }), + } + } + + /// Builds a rule with one path match and one `URLRewrite` filter. + fn rule_with_rewrite( + kind: HttpRouteRulesMatchesPathType, + value: &str, + rewrite: HttpRouteRulesFiltersUrlRewrite, + ) -> HttpRouteRules { + let mut rule = rule_with_path(kind, value); + rule.backend_refs = Some(dummy_backend_refs()); + rule.filters = Some(vec![HttpRouteRulesFilters { + r#type: HttpRouteRulesFiltersType::UrlRewrite, + url_rewrite: Some(rewrite), + ..Default::default() + }]); + rule + } + /// Scopes a rule that has no siblings to be scoped against. fn lone_rule_condition(rule: &HttpRouteRules) -> Option { let rules = [rule.clone()]; diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 7dada3c..f905811 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -35,14 +35,14 @@ use crate::{ /// an over-claim turns a passing run into a false negative elsewhere. /// Kept sorted, which `test_supported_features_are_sorted_and_unique` /// enforces. -/// Deliberately absent: `HTTPRouteHostRewrite` and `HTTPRoutePathRewrite` -/// (the `URLRewrite` filter), `HTTPRouteRequestMirror` and +/// Deliberately absent: `HTTPRouteRequestMirror` and /// `HTTPRouteRequestMultipleMirrors` (the `RequestMirror` filter), /// `HTTPRouteMethodMatching` and `HTTPRouteQueryParamMatching`. Every -/// one is rejected by [`validate_route`] — the filters because they are -/// not implemented, the two match kinds because `praxis_core::config::Route` -/// has no field to carry them. Advertising any of them would direct -/// conformance tooling at suites that cannot pass. +/// one is rejected by [`validate_route`] — the filter because Praxis +/// registers none that mirrors a request, the two match kinds because +/// `praxis_core::config::Route` has no field to carry them. +/// Advertising any of them would direct conformance tooling at suites +/// that cannot pass. /// /// `HTTPRouteResponseHeaderModification` was claimed here and withdrawn. /// Claiming it is what made conformance run @@ -58,11 +58,19 @@ use crate::{ /// features are claimed again on that basis, and the conformance suites /// they gate are no longer skipped. /// +/// The two rewrite features rest on the same release. Praxis registers +/// a `path_rewrite` filter whose `strip_prefix` and regex `replace` +/// operations cover both Gateway API path modifiers, and it forwards +/// the `Host` header untouched, so setting that header is the hostname +/// rewrite. +/// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &[ "Gateway", "GatewayPort8080", "HTTPRoute", + "HTTPRouteHostRewrite", + "HTTPRoutePathRewrite", "HTTPRouteRequestHeaderModification", "HTTPRouteResponseHeaderModification", "ReferenceGrant", diff --git a/src/gateway_api/route_validation.rs b/src/gateway_api/route_validation.rs index 30ec418..cf7ce9e 100644 --- a/src/gateway_api/route_validation.rs +++ b/src/gateway_api/route_validation.rs @@ -10,19 +10,23 @@ //! every unsupported construct is surfaced here and the rule is excluded //! from the generated config. //! -//! Two categories are refused. Regular-expression matching this -//! operator does not implement, and match fields the Praxis route -//! schema has no field for at all: `praxis_core::config::Route` carries -//! only a path match, host, headers and cluster, so a method or -//! query-parameter constraint has nowhere to go. Emitting one anyway -//! would be dropped during deserialization and the route would quietly -//! serve every method. +//! Three categories are refused. Regular-expression matching this +//! operator does not implement; match fields the Praxis route schema +//! has no field for at all — `praxis_core::config::Route` carries only +//! a path match, host, headers and cluster, so a method or +//! query-parameter constraint has nowhere to go, and emitting one +//! anyway would see it dropped during deserialization and the route +//! quietly serve every method; and filters the config generator does +//! not produce, together with the one rewrite the Gateway API itself +//! declares invalid — a `ReplacePrefixMatch` on a rule that matches no +//! prefix. use std::collections::BTreeMap; use gateway_api::httproutes::{ - HTTPRoute, HttpRouteRules, HttpRouteRulesFiltersType, HttpRouteRulesMatches, HttpRouteRulesMatchesHeadersType, - HttpRouteRulesMatchesPathType, HttpRouteRulesMatchesQueryParamsType, + HTTPRoute, HttpRouteRules, HttpRouteRulesFiltersType, HttpRouteRulesFiltersUrlRewritePathType, + HttpRouteRulesMatches, HttpRouteRulesMatchesHeadersType, HttpRouteRulesMatchesPathType, + HttpRouteRulesMatchesQueryParamsType, }; // ----------------------------------------------------------------------------- @@ -40,6 +44,9 @@ pub enum RuleRejection { /// A match field the Praxis route schema has no equivalent for. UnsupportedMatchField(&'static str), + + /// A `ReplacePrefixMatch` rewrite on a rule that matches no prefix. + PrefixRewriteWithoutPrefixMatch, } impl RuleRejection { @@ -53,6 +60,9 @@ impl RuleRejection { Self::UnsupportedMatchField(field) => { format!("{field} matching is not supported by the Praxis route schema") }, + Self::PrefixRewriteWithoutPrefixMatch => { + "URLRewrite ReplacePrefixMatch requires a PathPrefix match on the same rule".to_owned() + }, } } } @@ -128,6 +138,7 @@ fn reject_rule(rule: &HttpRouteRules) -> Option { .iter() .find_map(reject_match) .or_else(|| reject_filters(rule)) + .or_else(|| reject_rewrites(rule)) } /// Returns the reason a match cannot be honoured, if any. @@ -194,9 +205,43 @@ fn is_supported_filter(kind: &HttpRouteRulesFiltersType) -> bool { HttpRouteRulesFiltersType::RequestHeaderModifier | HttpRouteRulesFiltersType::ResponseHeaderModifier | HttpRouteRulesFiltersType::RequestRedirect + | HttpRouteRulesFiltersType::UrlRewrite ) } +/// Returns the reason a rule's rewrites cannot be honoured, if any. +/// +/// `ReplacePrefixMatch` names no prefix of its own: it replaces +/// whatever the rule matched on. The Gateway API requires an +/// implementation to refuse the rule outright when there is no +/// `PathPrefix` match to take that from, rather than guess at one. +fn reject_rewrites(rule: &HttpRouteRules) -> Option { + let prefixed = matches!( + rule.matches + .as_deref() + .and_then(<[_]>::first) + .and_then(|m| m.path.as_ref()) + .and_then(|p| p.r#type.as_ref()), + Some(HttpRouteRulesMatchesPathType::PathPrefix) + ); + if prefixed { + return None; + } + + rule.filters + .as_deref() + .unwrap_or(&[]) + .iter() + .filter_map(|f| f.url_rewrite.as_ref()) + .find(|rewrite| { + rewrite + .path + .as_ref() + .is_some_and(|p| p.r#type == HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch) + }) + .map(|_| RuleRejection::PrefixRewriteWithoutPrefixMatch) +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- @@ -205,8 +250,8 @@ fn is_supported_filter(kind: &HttpRouteRulesFiltersType) -> bool { #[expect(clippy::default_trait_access, reason = "tests")] mod tests { use gateway_api::httproutes::{ - HttpRouteRulesFilters, HttpRouteRulesMatchesHeaders, HttpRouteRulesMatchesPath, - HttpRouteRulesMatchesQueryParams, HttpRouteSpec, + HttpRouteRulesFilters, HttpRouteRulesFiltersUrlRewrite, HttpRouteRulesFiltersUrlRewritePath, + HttpRouteRulesMatchesHeaders, HttpRouteRulesMatchesPath, HttpRouteRulesMatchesQueryParams, HttpRouteSpec, }; use super::*; @@ -283,14 +328,14 @@ mod tests { fn test_unsupported_filter_is_rejected() { let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/api"); rule.filters = Some(vec![HttpRouteRulesFilters { - r#type: HttpRouteRulesFiltersType::UrlRewrite, + r#type: HttpRouteRulesFiltersType::RequestMirror, ..Default::default() }]); let validation = validate_route(&route_with(vec![rule])); assert!( validation.is_rejected(0), - "URLRewrite is not implemented and must be rejected rather than ignored" + "RequestMirror is not implemented and must be rejected rather than ignored" ); assert!( validation.message().is_some_and(|m| m.contains("not supported")), @@ -298,6 +343,80 @@ mod tests { ); } + #[test] + fn test_host_rewrite_is_accepted() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/api"); + rule.filters = Some(vec![url_rewrite(HttpRouteRulesFiltersUrlRewrite { + hostname: Some("one.example.org".to_owned()), + path: None, + })]); + + assert!( + !validate_route(&route_with(vec![rule])).is_rejected(0), + "a hostname rewrite needs no path match and is served by setting the Host header" + ); + } + + #[test] + fn test_full_path_rewrite_is_accepted_without_a_prefix_match() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/api"); + rule.filters = Some(vec![url_rewrite(HttpRouteRulesFiltersUrlRewrite { + hostname: None, + path: Some(HttpRouteRulesFiltersUrlRewritePath { + r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplaceFullPath, + replace_full_path: Some("/one".to_owned()), + replace_prefix_match: None, + }), + })]); + + assert!( + !validate_route(&route_with(vec![rule])).is_rejected(0), + "ReplaceFullPath discards the whole path, so it does not care what the rule matched on" + ); + } + + #[test] + fn test_prefix_rewrite_without_a_prefix_match_is_rejected() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/api"); + rule.filters = Some(vec![url_rewrite(HttpRouteRulesFiltersUrlRewrite { + hostname: None, + path: Some(HttpRouteRulesFiltersUrlRewritePath { + r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch, + replace_full_path: None, + replace_prefix_match: Some("/one".to_owned()), + }), + })]); + let validation = validate_route(&route_with(vec![rule])); + + assert!( + validation.is_rejected(0), + "ReplacePrefixMatch replaces what the rule matched on, and an Exact match leaves it \ + nothing to replace — the Gateway API requires refusing the rule, not guessing" + ); + assert!( + validation.message().is_some_and(|m| m.contains("PathPrefix")), + "the message should say what the rule is missing" + ); + } + + #[test] + fn test_prefix_rewrite_with_a_prefix_match_is_accepted() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/api"); + rule.filters = Some(vec![url_rewrite(HttpRouteRulesFiltersUrlRewrite { + hostname: None, + path: Some(HttpRouteRulesFiltersUrlRewritePath { + r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch, + replace_full_path: None, + replace_prefix_match: Some("/one".to_owned()), + }), + })]); + + assert!( + !validate_route(&route_with(vec![rule])).is_rejected(0), + "the prefix the rewrite replaces is right there on the rule" + ); + } + #[test] fn test_supported_filters_are_accepted() { let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/api"); @@ -391,6 +510,15 @@ mod tests { } } + /// Wraps a `URLRewrite` body in a rule filter. + fn url_rewrite(rewrite: HttpRouteRulesFiltersUrlRewrite) -> HttpRouteRulesFilters { + HttpRouteRulesFilters { + r#type: HttpRouteRulesFiltersType::UrlRewrite, + url_rewrite: Some(rewrite), + ..Default::default() + } + } + /// Builds a rule with a single path match of the given type. fn rule_with_path(kind: HttpRouteRulesMatchesPathType, value: &str) -> HttpRouteRules { HttpRouteRules { From 7f9ece6c97993a9383d14d659b931f3109ff270d Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:38:02 -0400 Subject: [PATCH 35/51] fix: match wildcard hostnames at any depth Signed-off-by: Shane Utt --- src/config/generate.rs | 47 +++++++++++++++++++++++++++++++++++++----- 1 file changed, 42 insertions(+), 5 deletions(-) diff --git a/src/config/generate.rs b/src/config/generate.rs index 35f7e47..6c7e9c6 100644 --- a/src/config/generate.rs +++ b/src/config/generate.rs @@ -249,16 +249,30 @@ fn extra_constraints(route: &PraxisRoute) -> usize { /// Builds the `router` filter entry from matched routes. /// -/// Serializes the routes into a YAML mapping under the `routes` key. +/// Serializes the routes into a YAML mapping under the `routes` key, +/// and turns on multi-level subdomain matching. +/// +/// Praxis defaults that off, so `*.example.com` matches +/// `foo.example.com` and nothing deeper. The Gateway API defines the +/// wildcard as a suffix match at any depth — `foo.bar.example.com` is +/// as much a match as `foo.example.com` — and a Gateway serving +/// `*.example.com` that 404s the deeper name is refusing traffic it +/// advertised. /// /// # Errors /// /// Returns an error if route serialization fails. fn build_router_filter(routes: &[&PraxisRoute]) -> yaml_serde::Result { - let config = yaml_serde::to_value(yaml_serde::Mapping::from_iter([( - yaml_serde::Value::String("routes".to_owned()), - yaml_serde::to_value(routes)?, - )]))?; + let config = yaml_serde::to_value(yaml_serde::Mapping::from_iter([ + ( + yaml_serde::Value::String("multi_level_subdomain_matching".to_owned()), + yaml_serde::Value::Bool(true), + ), + ( + yaml_serde::Value::String("routes".to_owned()), + yaml_serde::to_value(routes)?, + ), + ]))?; Ok(PraxisFilterEntry { filter: "router".to_owned(), @@ -616,6 +630,29 @@ mod tests { ); } + #[test] + fn test_router_matches_wildcards_at_any_depth() { + let listener = PraxisListener { + name: "http".to_owned(), + address: "0.0.0.0:80".to_owned(), + protocol: None, + filter_chains: vec!["http-chain".to_owned()], + hostname: None, + merged_section_names: vec![], + tls: None, + }; + + let config = assemble_config(vec![listener], &[], &[], &RouteFilters::default(), &Default::default()).unwrap(); + + let router = &config.filter_chains[0].filters[1]; + assert_eq!( + router.config.get("multi_level_subdomain_matching"), + Some(&serde_norway::Value::Bool(true)), + "Praxis defaults this off, which would 404 foo.bar.example.com on a *.example.com \ + listener — traffic the Gateway API says the wildcard covers" + ); + } + #[test] fn test_terminating_filters_run_before_the_router() { let listener = PraxisListener { From ff08d19c43b888a26779ddc2dff639dcbcbec561 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:38:14 -0400 Subject: [PATCH 36/51] fix: answer 500 when every backend of a rule was refused Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 111 ++++++++++++++++++++++++++------ src/config/generate.rs | 2 +- src/config/routing.rs | 19 ++++++ src/controller/praxis_config.rs | 28 ++++++-- 4 files changed, 132 insertions(+), 28 deletions(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 510ab33..5bd3757 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -126,6 +126,27 @@ pub struct RouteFilters { pub transforming: Vec, } +// ----------------------------------------------------------------------------- +// ServedRule +// ----------------------------------------------------------------------------- + +/// A rule paired with whether the gateway can forward its traffic. +#[derive(Debug, Clone, Copy)] +pub struct ServedRule<'a> { + /// The rule contributing filters. + pub rule: &'a HttpRouteRules, + + /// Whether any `backendRef` on the rule survived reference checks. + /// + /// A rule whose every backend was refused — a cross-namespace + /// reference with no `ReferenceGrant` covering it — produces no + /// route, and the router answers a request for it with 404. The + /// Gateway API prescribes 500 for an unresolvable reference, so + /// such a rule gets the same static response as one that named no + /// backend at all. + pub resolvable: bool, +} + // ----------------------------------------------------------------------------- // Filter Conversion // ----------------------------------------------------------------------------- @@ -136,18 +157,16 @@ pub struct RouteFilters { /// filters (`conditions`) derived from the rule's path match. This ensures /// header modifications and redirects apply only to traffic matching the /// originating rule. -pub fn convert_filters(rules: &[HttpRouteRules]) -> RouteFilters { +pub fn convert_filters(rules: &[ServedRule<'_>]) -> RouteFilters { let scoped = scope_rules(rules); let mut filters = RouteFilters::default(); - for (index, rule) in rules.iter().enumerate() { + for (index, served) in rules.iter().enumerate() { let condition = rule_conditions(index, &scoped); - let has_backends = rule.backend_refs.as_ref().is_some_and(|refs| !refs.is_empty()); - let has_redirect = rule_has_redirect(rule); - if !has_backends && !has_redirect { + if !served.resolvable && !rule_has_redirect(served.rule) { emit_no_backend_response(&condition, &mut filters.terminating); } - if rule.filters.is_some() { - convert_rule_filters(rule, &condition, &mut filters); + if served.rule.filters.is_some() { + convert_rule_filters(served.rule, &condition, &mut filters); } } filters @@ -301,8 +320,8 @@ struct ScopedRule { } /// Describes every rule by the traffic it claims and its rank. -fn scope_rules(rules: &[HttpRouteRules]) -> Vec { - rules.iter().map(scope_rule).collect() +fn scope_rules(rules: &[ServedRule<'_>]) -> Vec { + rules.iter().map(|served| scope_rule(served.rule)).collect() } /// Describes one rule by the traffic it claims and its rank. @@ -846,7 +865,7 @@ mod tests { ..Default::default() }]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); let transforming = &filters.transforming; assert_eq!(transforming.len(), 1, "should produce one header filter"); @@ -898,7 +917,7 @@ mod tests { ..Default::default() }]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); assert_eq!(filters.transforming.len(), 1, "should produce one header filter"); let config_str = yaml_serde::to_string(&filters.transforming[0].config).unwrap(); @@ -929,7 +948,7 @@ mod tests { ..Default::default() }]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); let terminating = &filters.terminating; assert_eq!(terminating.len(), 1, "should produce one redirect filter"); @@ -982,7 +1001,7 @@ mod tests { ..Default::default() }]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); assert!( filters.terminating.iter().any(|f| f.filter == "redirect"), @@ -1048,7 +1067,7 @@ mod tests { }, ]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); let transforming = &filters.transforming; assert_eq!(transforming.len(), 2, "should produce one filter per rule"); @@ -1078,6 +1097,44 @@ mod tests { ); } + // ----------------------------------------------------------------------- + // Unresolvable Backends + // ----------------------------------------------------------------------- + + #[test] + fn test_a_rule_whose_backends_were_all_refused_answers_500() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/v2"); + rule.backend_refs = Some(dummy_backend_refs()); + let rules = [ServedRule { + rule: &rule, + resolvable: false, + }]; + + let filters = convert_filters(&rules); + + assert_eq!( + filters.terminating.first().map(|f| f.filter.as_str()), + Some("static_response"), + "a backendRef refused for want of a ReferenceGrant leaves no route, and the router \ + would answer 404 where the Gateway API prescribes 500" + ); + } + + #[test] + fn test_a_rule_with_a_resolved_backend_adds_no_static_response() { + let mut rule = rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/v2"); + rule.backend_refs = Some(dummy_backend_refs()); + let rules = [ServedRule { + rule: &rule, + resolvable: true, + }]; + + assert!( + convert_filters(&rules).terminating.is_empty(), + "the router serves this rule, so nothing may answer ahead of it" + ); + } + // ----------------------------------------------------------------------- // URL Rewrite // ----------------------------------------------------------------------- @@ -1179,7 +1236,7 @@ mod tests { }, )]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); let yaml = yaml_serde::to_string(&filters.transforming[0].config).unwrap(); assert_eq!( @@ -1211,7 +1268,7 @@ mod tests { ), ]; - let filters = convert_filters(&rules); + let filters = convert_filters(&served(&rules)); assert_eq!(filters.transforming.len(), 2, "each rule rewrites its own traffic"); for entry in &filters.transforming { @@ -1321,7 +1378,7 @@ mod tests { rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), HttpRouteRules::default(), ]; - let scoped = scope_rules(&rules); + let scoped = scope_rules(&served(&rules)); let cond = rule_conditions(1, &scoped).expect("a catch-all rule beside a narrower one must be scoped"); @@ -1343,7 +1400,7 @@ mod tests { rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one/two"), ]; - let scoped = scope_rules(&rules); + let scoped = scope_rules(&served(&rules)); let cond = rule_conditions(1, &scoped).expect("a rule with a path match is always scoped"); @@ -1360,7 +1417,7 @@ mod tests { rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one/two/three"), rule_with_path(HttpRouteRulesMatchesPathType::Exact, "/one"), ]; - let scoped = scope_rules(&rules); + let scoped = scope_rules(&served(&rules)); let cond = rule_conditions(0, &scoped).expect("a rule with a path match is always scoped"); @@ -1378,7 +1435,7 @@ mod tests { rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), rule_with_path(HttpRouteRulesMatchesPathType::PathPrefix, "/one"), ]; - let scoped = scope_rules(&rules); + let scoped = scope_rules(&served(&rules)); let cond = rule_conditions(0, &scoped).expect("a catch-all rule beside narrower ones must be scoped"); @@ -1421,10 +1478,22 @@ mod tests { rule } + /// Pairs rules with whether their backends resolved, as the + /// controller does before converting them. + fn served(rules: &[HttpRouteRules]) -> Vec> { + rules + .iter() + .map(|rule| ServedRule { + rule, + resolvable: rule.backend_refs.as_ref().is_some_and(|refs| !refs.is_empty()), + }) + .collect() + } + /// Scopes a rule that has no siblings to be scoped against. fn lone_rule_condition(rule: &HttpRouteRules) -> Option { let rules = [rule.clone()]; - rule_conditions(0, &scope_rules(&rules)) + rule_conditions(0, &scope_rules(&served(&rules))) } /// Builds a rule with a single path match of the given type. diff --git a/src/config/generate.rs b/src/config/generate.rs index 6c7e9c6..c8e32ad 100644 --- a/src/config/generate.rs +++ b/src/config/generate.rs @@ -647,7 +647,7 @@ mod tests { let router = &config.filter_chains[0].filters[1]; assert_eq!( router.config.get("multi_level_subdomain_matching"), - Some(&serde_norway::Value::Bool(true)), + Some(&yaml_serde::Value::Bool(true)), "Praxis defaults this off, which would 404 foo.bar.example.com on a *.example.com \ listener — traffic the Gateway API says the wildcard covers" ); diff --git a/src/config/routing.rs b/src/config/routing.rs index a043eed..1fa2a2d 100644 --- a/src/config/routing.rs +++ b/src/config/routing.rs @@ -269,6 +269,25 @@ fn process_rule( ); } +/// Returns `true` when a rule has at least one backend the gateway may +/// forward to. +/// +/// A rule whose every `backendRef` was refused is not the same as a +/// rule that named none: it still has to answer, with the 500 the +/// Gateway API prescribes for an unresolvable reference, so the caller +/// needs to tell the two apart. +pub fn rule_has_authorized_backend( + rule: &gateway_api::httproutes::HttpRouteRules, + route_ns: &str, + grants: &[ReferenceGrant], +) -> bool { + rule.backend_refs + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|b| is_backend_authorized(b, route_ns, grants)) +} + /// Returns `true` if authorized; logs and returns `false` otherwise. fn check_backend_authorized(backend: &HttpRouteRulesBackendRefs, route_ns: &str, grants: &[ReferenceGrant]) -> bool { if is_backend_authorized(backend, route_ns, grants) { diff --git a/src/controller/praxis_config.rs b/src/controller/praxis_config.rs index 774ab4b..8435fa9 100644 --- a/src/controller/praxis_config.rs +++ b/src/controller/praxis_config.rs @@ -23,10 +23,10 @@ use tracing::debug; use crate::{ config::{ cluster::{PraxisCluster, build_cluster}, - filter_conversion::{RouteFilters, convert_filters}, + filter_conversion::{RouteFilters, ServedRule, convert_filters}, generate::assemble_config, listener::{PraxisCertificate, PraxisListener, PraxisTls, convert_listener}, - routing::{BackendRef, PraxisRoute, convert_routes}, + routing::{BackendRef, PraxisRoute, convert_routes, rule_has_authorized_backend}, weights::{ResolvedBackend, distribute_service_weights, sort_service_endpoints}, }, endpoints, @@ -82,7 +82,7 @@ pub(super) async fn build_praxis_config( let listener_hostnames = build_listener_hostname_map(&supported); let praxis_listeners = merge_listeners_by_port(&supported); let (praxis_routes, backend_refs) = convert_attached_routes(attached, &listener_hostnames, grants); - let route_filters = collect_filters(attached); + let route_filters = collect_filters(attached, grants); let clusters = resolve_clusters(client, &backend_refs).await?; let config = assemble_config( praxis_listeners, @@ -175,11 +175,27 @@ fn convert_attached_routes( } /// Extracts and converts filters from all attached route rules. -fn collect_filters(attached: &[AttachedRoute<'_>]) -> RouteFilters { +/// +/// Each rule is paired with whether any of its backends survived the +/// reference checks, which is what decides between a rule the router +/// will serve and one that has to answer 500 on its own. +fn collect_filters(attached: &[AttachedRoute<'_>], grants: &[ReferenceGrant]) -> RouteFilters { let all_rules: Vec<_> = attached .iter() - .flat_map(|attached| attached.route.spec.rules.as_deref().unwrap_or(&[])) - .cloned() + .flat_map(|attached| { + let namespace = attached.route.namespace().unwrap_or_default(); + attached + .route + .spec + .rules + .as_deref() + .unwrap_or(&[]) + .iter() + .map(move |rule| ServedRule { + rule, + resolvable: rule_has_authorized_backend(rule, &namespace, grants), + }) + }) .collect(); convert_filters(&all_rules) } From 4fd5370a76f30fb224d8bd191602d64158542e0a Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:38:27 -0400 Subject: [PATCH 37/51] fix: propagate Gateway infrastructure metadata to generated resources Signed-off-by: Shane Utt --- src/controller/gateway_class.rs | 1 + src/resources/deployment.rs | 132 +++++++++++++++++++++++++++++--- src/resources/labels.rs | 43 +++++++++++ src/resources/service.rs | 81 ++++++++++++++++++-- 4 files changed, 240 insertions(+), 17 deletions(-) diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index f905811..622f1df 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -67,6 +67,7 @@ use crate::{ /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &[ "Gateway", + "GatewayInfrastructurePropagation", "GatewayPort8080", "HTTPRoute", "HTTPRouteHostRewrite", diff --git a/src/resources/deployment.rs b/src/resources/deployment.rs index 2d126b8..2394256 100644 --- a/src/resources/deployment.rs +++ b/src/resources/deployment.rs @@ -23,7 +23,7 @@ use k8s_openapi::{ }; use kube::ResourceExt as _; -use super::labels::{owner_reference, standard_labels}; +use super::labels::{descriptive_labels, infrastructure_annotations, owner_reference, standard_labels}; use crate::context::{ADMIN_PORT, praxis_image}; // ----------------------------------------------------------------------------- @@ -105,8 +105,15 @@ pub struct DeploymentParams<'a> { /// Returns an error if the Gateway has no UID. pub fn build_deployment(params: &DeploymentParams<'_>) -> crate::error::Result { let instance = params.gateway.name_any(); - let labels = standard_labels(&instance); - let pod_annotations = BTreeMap::from([("praxis.sh/config-hash".to_owned(), params.config_hash.to_owned())]); + let selector = standard_labels(&instance); + + // Everything the selector must not carry, because a selector cannot + // be edited after creation and these can change with the Gateway. + let mut labels = selector.clone(); + labels.extend(descriptive_labels(params.gateway)); + + let mut pod_annotations = infrastructure_annotations(params.gateway); + pod_annotations.insert("praxis.sh/config-hash".to_owned(), params.config_hash.to_owned()); let (mut volume_mounts, mut volumes) = config_volume(params.name); let (tls_mounts, tls_vols) = build_tls_volumes(params.tls_secret_names); @@ -120,9 +127,18 @@ pub fn build_deployment(params: &DeploymentParams<'_>) -> crate::error::Result SecurityContext { /// Builds the pod template spec with labels, annotations, and volumes. /// -/// Wraps a single container in a hardened pod spec. +/// Wraps a single container in a hardened pod spec. `labels` go on the +/// pods; `selector` is the narrower, fixed set that identifies them. fn build_pod_template( labels: &BTreeMap, + selector: &BTreeMap, pod_annotations: BTreeMap, container: Container, volumes: Vec, @@ -319,7 +337,7 @@ fn build_pod_template( automount_service_account_token: Some(false), containers: vec![container], termination_grace_period_seconds: Some(15), - topology_spread_constraints: Some(spread_constraints(labels)), + topology_spread_constraints: Some(spread_constraints(selector)), volumes: Some(volumes), ..Default::default() }; @@ -369,6 +387,18 @@ fn spread_constraints(labels: &BTreeMap) -> Vec, + + /// The immutable subset that selects those pods. + selector: BTreeMap, + + /// The pod template itself. + pod_template: PodTemplateSpec, +} + /// Assembles the final [`Deployment`] object with metadata and spec. /// /// Sets owner references, labels, rolling update strategy, and the pod @@ -377,28 +407,28 @@ fn build_deployment_object( name: &str, namespace: &str, gateway: &Gateway, - labels: BTreeMap, - pod_template: PodTemplateSpec, + meta: DeploymentMetadata, ) -> crate::error::Result { Ok(Deployment { metadata: ObjectMeta { + annotations: Some(infrastructure_annotations(gateway)), name: Some(name.to_owned()), namespace: Some(namespace.to_owned()), owner_references: Some(vec![owner_reference(gateway)?]), - labels: Some(labels.clone()), + labels: Some(meta.labels), ..Default::default() }, spec: Some(DeploymentSpec { replicas: Some(desired_replicas(gateway)), selector: LabelSelector { - match_labels: Some(labels), + match_labels: Some(meta.selector), ..Default::default() }, strategy: Some(DeploymentStrategy { type_: Some("RollingUpdate".to_owned()), ..Default::default() }), - template: pod_template, + template: meta.pod_template, ..Default::default() }), ..Default::default() @@ -440,6 +470,84 @@ mod tests { } } + #[test] + fn test_deployment_selector_excludes_the_variable_labels() { + let gateway = infrastructure_gateway(); + let deployment = build_deployment(¶ms(&gateway)).expect("a gateway with a uid builds"); + + let spec = deployment.spec.expect("spec should be set"); + let selector = spec.selector.match_labels.expect("selector should be set"); + let pod_labels = spec + .template + .metadata + .expect("template metadata should be set") + .labels + .expect("pod labels should be set"); + + assert!( + !selector.contains_key("key2") && !selector.contains_key(super::super::labels::GATEWAY_NAME_LABEL), + "a Deployment selector cannot be edited after creation, so the first Gateway to \ + change spec.infrastructure would leave the operator unable to apply: {selector:?}" + ); + assert!( + selector.iter().all(|(key, value)| pod_labels.get(key) == Some(value)), + "the selector still has to select the pods: {selector:?} vs {pod_labels:?}" + ); + } + + #[test] + fn test_pods_carry_gateway_infrastructure_metadata() { + let gateway = infrastructure_gateway(); + let deployment = build_deployment(¶ms(&gateway)).expect("a gateway with a uid builds"); + + let template = deployment + .spec + .expect("spec should be set") + .template + .metadata + .expect("template metadata should be set"); + let labels = template.labels.expect("pod labels should be set"); + let annotations = template.annotations.expect("pod annotations should be set"); + + assert_eq!( + labels.get(super::super::labels::GATEWAY_NAME_LABEL), + Some(&"test-gateway".to_owned()), + "conformance finds an implementation's generated pods by this label" + ); + assert_eq!( + labels.get("key2"), + Some(&"value2".to_owned()), + "spec.infrastructure.labels asks for these on every generated resource" + ); + assert_eq!( + annotations.get("key1"), + Some(&"value1".to_owned()), + "spec.infrastructure.annotations likewise" + ); + assert!( + annotations.contains_key("praxis.sh/config-hash"), + "the config hash still has to reach the pod template, or config edits stop rolling out" + ); + } + + /// Builds a Gateway declaring infrastructure labels and annotations. + fn infrastructure_gateway() -> Gateway { + use gateway_api::gateways::GatewayInfrastructure; + + let mut gateway = test_gateway(); + gateway.spec.infrastructure = Some(GatewayInfrastructure { + annotations: Some(BTreeMap::from([("key1".to_owned(), "value1".to_owned())])), + labels: Some(BTreeMap::from([("key2".to_owned(), "value2".to_owned())])), + ..Default::default() + }); + gateway + } + + /// Builds deployment params for a gateway with no listener ports. + fn params(gateway: &Gateway) -> DeploymentParams<'_> { + test_params(gateway, &[]) + } + #[test] fn test_build_deployment_metadata() { let gateway = test_gateway(); diff --git a/src/resources/labels.rs b/src/resources/labels.rs index 4f9692d..1a4f4ab 100644 --- a/src/resources/labels.rs +++ b/src/resources/labels.rs @@ -24,6 +24,49 @@ pub fn standard_labels(instance: &str) -> BTreeMap { labels } +/// Label naming the Gateway a generated resource was created for. +/// +/// Standardised by the Gateway API so that tooling can find an +/// implementation's generated objects without knowing its naming +/// scheme. Conformance uses it as a list selector. +pub const GATEWAY_NAME_LABEL: &str = "gateway.networking.k8s.io/gateway-name"; + +/// Returns the labels every generated resource carries, beyond the +/// selector. +/// +/// Deliberately separate from [`standard_labels`]: that set is the +/// `Deployment` and `Service` selector, which Kubernetes will not let +/// the operator change once created. Anything that can vary with the +/// Gateway spec — the operator-declared labels below — has to stay out +/// of it, or the first Gateway to edit `spec.infrastructure` would +/// leave the operator unable to apply its own `Deployment`. +pub fn descriptive_labels(gateway: &gateway_api::gateways::Gateway) -> BTreeMap { + let mut labels = BTreeMap::from([(GATEWAY_NAME_LABEL.to_owned(), gateway.name_any())]); + labels.extend(infrastructure_labels(gateway)); + labels +} + +/// Returns the labels the Gateway asks generated resources to carry. +pub fn infrastructure_labels(gateway: &gateway_api::gateways::Gateway) -> BTreeMap { + gateway + .spec + .infrastructure + .as_ref() + .and_then(|infra| infra.labels.clone()) + .unwrap_or_default() +} + +/// Returns the annotations the Gateway asks generated resources to +/// carry. +pub fn infrastructure_annotations(gateway: &gateway_api::gateways::Gateway) -> BTreeMap { + gateway + .spec + .infrastructure + .as_ref() + .and_then(|infra| infra.annotations.clone()) + .unwrap_or_default() +} + /// Returns the child resource name for a given Gateway name. /// /// Prefixes the gateway name with `praxis-` to form the deployment and service diff --git a/src/resources/service.rs b/src/resources/service.rs index e148353..6aa310e 100644 --- a/src/resources/service.rs +++ b/src/resources/service.rs @@ -10,12 +10,19 @@ use k8s_openapi::{ }; use kube::ResourceExt as _; -use super::labels::{owner_reference, standard_labels}; +use super::labels::{descriptive_labels, infrastructure_annotations, owner_reference, standard_labels}; // ----------------------------------------------------------------------------- // Service Builder // ----------------------------------------------------------------------------- +/// Returns the labels stamped on the Service itself. +fn service_labels(gateway: &Gateway, instance: &str) -> std::collections::BTreeMap { + let mut labels = standard_labels(instance); + labels.extend(descriptive_labels(gateway)); + labels +} + /// Builds a `LoadBalancer` `Service` for Praxis. /// /// Creates a `Service` with type `LoadBalancer`, standard labels, and a selector @@ -33,14 +40,18 @@ pub fn build_service( Ok(Service { metadata: ObjectMeta { + annotations: Some(infrastructure_annotations(gateway)), name: Some(name.to_owned()), namespace: Some(namespace.to_owned()), owner_references: Some(vec![owner_reference(gateway)?]), - labels: Some(standard_labels(&instance)), + labels: Some(service_labels(gateway, &instance)), ..Default::default() }, spec: Some(ServiceSpec { type_: Some("LoadBalancer".to_owned()), + // Deliberately the bare standard set: a Service selector is + // as immutable as a Deployment's, so nothing that can change + // with the Gateway spec may appear in it. selector: Some(standard_labels(&instance)), ports: Some(ports), ..Default::default() @@ -58,7 +69,7 @@ pub fn build_service( mod tests { use k8s_openapi::apimachinery::pkg::{apis::meta::v1::ObjectMeta, util::intstr::IntOrString}; - use super::*; + use super::{super::labels::GATEWAY_NAME_LABEL, *}; #[test] fn test_build_service_metadata() { @@ -183,7 +194,7 @@ mod tests { } #[test] - fn test_build_service_selector_matches_labels() { + fn test_build_service_selector_is_a_subset_of_its_labels() { let gateway = Gateway { metadata: ObjectMeta { name: Some("my-gateway".to_owned()), @@ -201,6 +212,66 @@ mod tests { let spec = service.spec.expect("spec should be set"); let selector = spec.selector.expect("selector should be set"); - assert_eq!(labels, selector, "labels and selector should match"); + assert!( + selector.iter().all(|(key, value)| labels.get(key) == Some(value)), + "the selector has to keep selecting the pods: {selector:?} vs {labels:?}" + ); + assert!( + !selector.contains_key(GATEWAY_NAME_LABEL) && labels.contains_key(GATEWAY_NAME_LABEL), + "a Service selector is immutable once created, so only the fixed labels may appear in \ + it — the descriptive ones belong on the object alone" + ); + } + + #[test] + fn test_build_service_carries_gateway_infrastructure_metadata() { + use gateway_api::gateways::{GatewayInfrastructure, GatewaySpec}; + + + let gateway = Gateway { + metadata: ObjectMeta { + name: Some("my-gateway".to_owned()), + namespace: Some("default".to_owned()), + uid: Some("test-uid".to_owned()), + ..Default::default() + }, + spec: GatewaySpec { + infrastructure: Some(GatewayInfrastructure { + annotations: Some(std::collections::BTreeMap::from([( + "key1".to_owned(), + "value1".to_owned(), + )])), + labels: Some(std::collections::BTreeMap::from([( + "key2".to_owned(), + "value2".to_owned(), + )])), + ..Default::default() + }), + ..Default::default() + }, + status: None, + }; + + let service = build_service("praxis-svc", "default", &gateway, vec![]).unwrap(); + + assert_eq!( + service.metadata.labels.as_ref().and_then(|l| l.get("key2")), + Some(&"value2".to_owned()), + "spec.infrastructure.labels asks for these on every generated resource" + ); + assert_eq!( + service.metadata.annotations.as_ref().and_then(|a| a.get("key1")), + Some(&"value1".to_owned()), + "spec.infrastructure.annotations likewise" + ); + assert_eq!( + service + .spec + .and_then(|s| s.selector) + .and_then(|s| s.get("key2").cloned()), + None, + "an infrastructure label in the selector would make the Service unpatchable the first \ + time someone edited it" + ); } } From 402acd01773e01a698e72c05a519ac8380d5aee5 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:38:49 -0400 Subject: [PATCH 38/51] fix: honor the timeouts an HTTPRoute rule declares Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 155 +++++++++++++++++++++++++++++++- src/controller/gateway_class.rs | 10 +++ 2 files changed, 164 insertions(+), 1 deletion(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 5bd3757..4d2fa80 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -165,6 +165,7 @@ pub fn convert_filters(rules: &[ServedRule<'_>]) -> RouteFilters { if !served.resolvable && !rule_has_redirect(served.rule) { emit_no_backend_response(&condition, &mut filters.terminating); } + emit_conditional_timeout(served.rule, &condition, &mut filters.transforming); if served.rule.filters.is_some() { convert_rule_filters(served.rule, &condition, &mut filters); } @@ -624,6 +625,89 @@ fn build_redirect_location(redirect: &gateway_api::httproutes::HttpRouteRulesFil } } +// ----------------------------------------------------------------------------- +// Timeouts +// ----------------------------------------------------------------------------- + +/// Emits a conditional `timeout` filter for a rule's timeouts. +/// +/// A rule may set `request`, `backendRequest`, or both. Praxis has one +/// timeout to give, so the shorter of the two is what gets enforced — +/// which is also the one that would fire first if both existed. A +/// timeout of `0s` disables it, and a rule setting nothing gets no +/// filter. +fn emit_conditional_timeout( + rule: &HttpRouteRules, + condition: &Option, + filters: &mut Vec, +) { + let Some(timeouts) = &rule.timeouts else { return }; + + let request = timeouts.request.as_deref().and_then(parse_duration_ms); + let backend = timeouts.backend_request.as_deref().and_then(parse_duration_ms); + let Some(timeout_ms) = [request, backend].into_iter().flatten().filter(|ms| *ms > 0).min() else { + return; + }; + + let mut config = serde_norway::Mapping::new(); + config.insert( + serde_norway::Value::String("timeout_ms".to_owned()), + serde_norway::Value::Number(timeout_ms.into()), + ); + + let config = inject_conditions(serde_norway::Value::Mapping(config), condition); + filters.push(PraxisFilterEntry { + filter: "timeout".to_owned(), + config, + }); +} + +/// Parses a Gateway API duration into whole milliseconds. +/// +/// [GEP-2257] spells a duration as a run of `` pairs with +/// units `h`, `m`, `s` and `ms` — `500ms`, `1s`, `1h30m`. The CRD +/// enforces the grammar with a pattern, so a value that fails to parse +/// here is one the API server should never have admitted; it is +/// dropped rather than guessed at, leaving the rule without a timeout. +/// +/// [GEP-2257]: https://gateway-api.sigs.k8s.io/geps/gep-2257/ +fn parse_duration_ms(value: &str) -> Option { + let mut total: u64 = 0; + let mut rest = value; + + while !rest.is_empty() { + let digits = rest.find(|c: char| !c.is_ascii_digit())?; + if digits == 0 { + return None; + } + let (number, tail) = rest.split_at(digits); + let (unit_ms, unit_len) = unit_millis(tail)?; + total = total.checked_add(number.parse::().ok()?.checked_mul(unit_ms)?)?; + rest = tail.get(unit_len..)?; + } + + (value != rest).then_some(total) +} + +/// Returns the millisecond value of the unit starting `tail`, with its +/// length. +/// +/// `ms` is tested before `m`, or every millisecond would be read as a +/// minute followed by a stray `s`. +fn unit_millis(tail: &str) -> Option<(u64, usize)> { + if tail.starts_with("ms") { + Some((1, 2)) + } else if tail.starts_with('s') { + Some((1_000, 1)) + } else if tail.starts_with('m') { + Some((60_000, 1)) + } else if tail.starts_with('h') { + Some((3_600_000, 1)) + } else { + None + } +} + // ----------------------------------------------------------------------------- // Path Rewrite // ----------------------------------------------------------------------------- @@ -826,7 +910,7 @@ fn inject_conditions(mut config: yaml_serde::Value, condition: &Option Date: Fri, 14 Aug 2026 15:39:07 -0400 Subject: [PATCH 39/51] chore: claim the redirect features Signed-off-by: Shane Utt --- src/controller/gateway_class.rs | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index c4df8fe..6eb4d87 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -72,18 +72,33 @@ use crate::{ /// from any backend that eventually replies; a hung backend still /// hangs. Withdraw both if that gap matters more than the coverage. /// +/// The redirect features need no code of their own: the `redirect` +/// filter already carries scheme and status through, and Praxis +/// accepts 301, 302, 307 and 308. `HTTPRoute303RedirectStatusCode` is +/// absent because it accepts no 303, and `HTTPRoutePathRedirect` +/// because its `location` template offers `${path}` whole and no way +/// to substitute part of it, which `ReplacePrefixMatch` needs. +/// `HTTPRoutePortRedirect` is absent for a different reason: the suite +/// gating on it requires omitting a default port for the listener's +/// own scheme, and filter entries are built once per Gateway rather +/// than once per listener, so that scheme is not known where the +/// location is assembled. +/// /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &[ "Gateway", "GatewayInfrastructurePropagation", "GatewayPort8080", "HTTPRoute", + "HTTPRoute307RedirectStatusCode", + "HTTPRoute308RedirectStatusCode", "HTTPRouteBackendTimeout", "HTTPRouteHostRewrite", "HTTPRoutePathRewrite", "HTTPRouteRequestHeaderModification", "HTTPRouteRequestTimeout", "HTTPRouteResponseHeaderModification", + "HTTPRouteSchemeRedirect", "ReferenceGrant", ]; From eb40c37a6444ca3f616904b84b526b7324ee4c9a Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:39:24 -0400 Subject: [PATCH 40/51] fix: resolve parentRefs by port as well as by section name Signed-off-by: Shane Utt --- benches/config_generation.rs | 5 +- src/controller/gateway_class.rs | 5 ++ src/controller/httproute.rs | 64 ++++++++++++---- src/controller/ownership.rs | 2 +- src/gateway_api/attachment.rs | 127 +++++++++++++++++++++++++++++--- 5 files changed, 176 insertions(+), 27 deletions(-) diff --git a/benches/config_generation.rs b/benches/config_generation.rs index 49c0aa3..e20408f 100644 --- a/benches/config_generation.rs +++ b/benches/config_generation.rs @@ -83,7 +83,8 @@ fn bench_attachment(c: &mut Criterion) { for count in ROUTE_COUNTS { group.bench_function(format!("{count}_routes"), |b| { let routes = route_manifests(count); - b.iter(|| black_box(attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, &routes).len())); + let listeners = listener_manifests(); + b.iter(|| black_box(attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, &listeners, &routes).len())); }); } @@ -100,7 +101,7 @@ criterion_main!(benches); /// Runs the synchronous half of `build_praxis_config` and returns the /// serialized length, which keeps the optimizer from eliding the work. fn generate_config(listeners: &[GatewayListeners], routes: &[Arc]) -> usize { - let attached = attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, routes); + let attached = attached_routes(GATEWAY_NAME, GATEWAY_NAMESPACE, listeners, routes); let listener_hostnames: HashMap> = listeners.iter().map(|l| (l.name.clone(), l.hostname.clone())).collect(); diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 6eb4d87..32ba6b7 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -64,6 +64,9 @@ use crate::{ /// the `Host` header untouched, so setting that header is the hostname /// rewrite. /// +/// The port features come from `parentRefs[].port`, which the +/// operator now resolves to listeners rather than ignoring. +/// /// The two timeout features are claimed with a caveat worth stating. /// Praxis's `timeout` filter compares elapsed time in the response /// phase, so it converts a late response into a 504 but does not abort @@ -93,7 +96,9 @@ const SUPPORTED_FEATURES: &[&str] = &[ "HTTPRoute307RedirectStatusCode", "HTTPRoute308RedirectStatusCode", "HTTPRouteBackendTimeout", + "HTTPRouteDestinationPortMatching", "HTTPRouteHostRewrite", + "HTTPRouteParentRefPort", "HTTPRoutePathRewrite", "HTTPRouteRequestHeaderModification", "HTTPRouteRequestTimeout", diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index 2b6df02..bd44849 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -23,7 +23,9 @@ use super::namespace_filter; use crate::{ context::{CONTROLLER_NAME, Context}, error::{OperatorError, Result}, - gateway_api::{conditions, hostname, route_status, status_types::RouteParentStatus}, + gateway_api::{ + attachment::listener_matches_parent_ref, conditions, hostname, route_status, status_types::RouteParentStatus, + }, }; // ----------------------------------------------------------------------------- @@ -165,11 +167,11 @@ fn validate_listener_attachment( generation: i64, ctx: &Context, ) -> Option { - if !section_name_valid(gw, parent_ref) { + if !parent_ref_selects_a_listener(gw, parent_ref) { return Some(conditions::not_accepted( generation, "NoMatchingParent", - "no listener matches sectionName", + "no listener matches the parentRef", )); } if !namespace_allowed(route, gw, parent_ref, ctx) { @@ -189,12 +191,19 @@ fn validate_listener_attachment( None } -/// Returns `true` when the `parentRef` section name matches a listener. -fn section_name_valid(gw: &Gateway, parent_ref: &HttpRouteParentRefs) -> bool { - parent_ref - .section_name - .as_ref() - .is_none_or(|s| gw.spec.listeners.iter().any(|l| l.name == *s)) +/// Returns `true` when some listener satisfies everything the +/// `parentRef` asks for. +/// +/// A `parentRef` may narrow by `sectionName`, by `port`, or by both, +/// and the constraints are not independent: they have to hold of the +/// same listener. A ref naming a listener and a port that listener +/// does not serve selects nothing, exactly as one naming a listener +/// that does not exist does. +fn parent_ref_selects_a_listener(gw: &Gateway, parent_ref: &HttpRouteParentRefs) -> bool { + gw.spec + .listeners + .iter() + .any(|l| listener_matches_parent_ref(l, parent_ref)) } /// Returns `true` when the route's namespace is allowed by listeners. @@ -281,35 +290,60 @@ mod tests { use super::*; #[test] - fn test_section_name_valid_without_section_name() { + fn test_a_parent_ref_naming_nothing_selects_every_listener() { let gw = gateway(vec![listener("http", None)]); assert!( - section_name_valid(&gw, &parent_ref(None)), + parent_ref_selects_a_listener(&gw, &parent_ref(None)), "a parentRef without a sectionName attaches to every listener" ); } #[test] - fn test_section_name_valid_matching_listener() { + fn test_a_section_name_naming_a_listener_selects_it() { let gw = gateway(vec![listener("http", None), listener("https", None)]); assert!( - section_name_valid(&gw, &parent_ref(Some("https"))), + parent_ref_selects_a_listener(&gw, &parent_ref(Some("https"))), "a sectionName naming an existing listener is valid" ); } #[test] - fn test_section_name_valid_rejects_unknown_listener() { + fn test_a_section_name_naming_no_listener_selects_nothing() { let gw = gateway(vec![listener("http", None)]); assert!( - !section_name_valid(&gw, &parent_ref(Some("grpc"))), + !parent_ref_selects_a_listener(&gw, &parent_ref(Some("grpc"))), "a sectionName with no matching listener is invalid" ); } + #[test] + fn test_a_port_no_listener_serves_selects_nothing() { + let gw = gateway(vec![listener("http", None)]); + let mut parent = parent_ref(None); + parent.port = Some(81); + + assert!( + !parent_ref_selects_a_listener(&gw, &parent), + "the Gateway API rejects a parentRef whose port no listener serves" + ); + } + + #[test] + fn test_a_section_name_and_port_must_hold_of_one_listener() { + let gw = gateway(vec![listener("http", None)]); + let mut parent = parent_ref(Some("http")); + parent.port = Some(81); + + assert!( + !parent_ref_selects_a_listener(&gw, &parent), + "the two constraints are ANDed, so naming a listener and a port it does not serve \ + selects nothing at all" + ); + } + #[test] fn test_targeted_listeners_without_section_name_returns_all() { let listeners = vec![listener("http", None), listener("https", None)]; diff --git a/src/controller/ownership.rs b/src/controller/ownership.rs index 0897a0e..6f4b985 100644 --- a/src/controller/ownership.rs +++ b/src/controller/ownership.rs @@ -83,7 +83,7 @@ pub(super) fn collect_routes<'a>( let ns = gw.namespace().unwrap_or_default(); let name = gw.name_any(); - let attached = attachment::attached_routes(&name, &ns, all_routes); + let attached = attachment::attached_routes(&name, &ns, &gw.spec.listeners, all_routes); namespace_filter::filter_routes_by_allowed_namespaces(&attached, &gw.spec.listeners, &ns, stores) } diff --git a/src/gateway_api/attachment.rs b/src/gateway_api/attachment.rs index 7ff4f0a..d00a36b 100644 --- a/src/gateway_api/attachment.rs +++ b/src/gateway_api/attachment.rs @@ -5,7 +5,10 @@ use std::sync::Arc; -use gateway_api::httproutes::{HTTPRoute, HttpRouteParentRefs}; +use gateway_api::{ + gateways::GatewayListeners, + httproutes::{HTTPRoute, HttpRouteParentRefs}, +}; // ----------------------------------------------------------------------------- // AttachedRoute @@ -59,13 +62,46 @@ pub fn parent_ref_matches_gateway( group == "gateway.networking.k8s.io" && kind == "Gateway" && parent.name == gateway_name && namespace == gateway_ns } +/// Returns `true` when a listener satisfies everything a `parentRef` +/// asks of the listener it attaches to. +/// +/// `sectionName` and `port` are both optional and both narrowing, and +/// the Gateway API applies them together rather than as alternatives: +/// a ref carrying each selects the listener satisfying both, and +/// selects nothing when no listener does. +pub fn listener_matches_parent_ref(listener: &GatewayListeners, parent: &HttpRouteParentRefs) -> bool { + parent.section_name.as_ref().is_none_or(|name| listener.name == *name) + && parent.port.is_none_or(|port| listener.port == port) +} + +/// Returns the listeners a `parentRef` attaches to, by section name. +/// +/// A ref naming a `sectionName` targets that listener alone. A ref +/// naming only a `port` targets every listener serving that port — a +/// Gateway may have several, told apart by hostname. A ref naming +/// neither targets all of them, which is spelled `None` so that a +/// route does not have to be re-derived when listeners change. +fn targeted_sections(parent: &HttpRouteParentRefs, listeners: &[GatewayListeners]) -> Vec> { + if parent.section_name.is_none() && parent.port.is_none() { + return vec![None]; + } + + listeners + .iter() + .filter(|listener| listener_matches_parent_ref(listener, parent)) + .map(|listener| Some(listener.name.clone())) + .collect() +} + /// Returns routes attached to the given Gateway with their section names. /// -/// Each tuple contains a route and a vector of section names (one per matching -/// parentRef). A `None` section name means the route attaches to all listeners. +/// Each entry pairs a route with the listener section names its +/// `parentRefs` resolve to. A `None` section name means the route +/// attaches to all listeners. pub fn attached_routes<'a>( gateway_name: &str, gateway_ns: &str, + listeners: &[GatewayListeners], routes: &'a [Arc], ) -> Vec> { let mut result = Vec::new(); @@ -77,7 +113,7 @@ pub fn attached_routes<'a>( let mut section_names = Vec::new(); for parent_ref in refs { if parent_ref_matches_gateway(parent_ref, gateway_name, gateway_ns, route_ns) { - section_names.push(parent_ref.section_name.clone()); + section_names.extend(targeted_sections(parent_ref, listeners)); } } @@ -177,7 +213,7 @@ mod tests { #[test] fn test_attached_routes_none() { let routes = vec![]; - let attached = attached_routes("test-gateway", "default", &routes); + let attached = attached_routes("test-gateway", "default", &listeners(), &routes); assert!(attached.is_empty(), "no routes should be attached"); } @@ -202,7 +238,7 @@ mod tests { }; let routes = vec![Arc::new(route)]; - let attached = attached_routes("test-gateway", "default", &routes); + let attached = attached_routes("test-gateway", "default", &listeners(), &routes); assert_eq!(attached.len(), 1, "one route should be attached"); assert_eq!( @@ -236,7 +272,7 @@ mod tests { }; let routes = vec![Arc::new(route)]; - let attached = attached_routes("test-gateway", "default", &routes); + let attached = attached_routes("test-gateway", "default", &listeners(), &routes); assert_eq!(attached.len(), 1, "one route should be attached"); assert_eq!( @@ -279,7 +315,7 @@ mod tests { }; let routes = vec![Arc::new(route)]; - let attached = attached_routes("test-gateway", "default", &routes); + let attached = attached_routes("test-gateway", "default", &listeners(), &routes); assert_eq!(attached.len(), 1, "one route should be attached"); assert_eq!( @@ -320,8 +356,81 @@ mod tests { }; let routes = vec![Arc::new(route)]; - let attached = attached_routes("test-gateway", "default", &routes); + let attached = attached_routes("test-gateway", "default", &listeners(), &routes); assert!(attached.is_empty(), "no routes should be attached"); } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds the listener set the attachment tests resolve against. + fn listeners() -> Vec { + vec![ + GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + }, + GatewayListeners { + name: "https".to_owned(), + port: 443, + protocol: "HTTPS".to_owned(), + ..Default::default() + }, + ] + } + + #[test] + fn test_a_port_only_parent_ref_targets_every_listener_on_it() { + let listeners = vec![ + GatewayListeners { + name: "foo".to_owned(), + port: 8080, + protocol: "HTTP".to_owned(), + ..Default::default() + }, + GatewayListeners { + name: "bar".to_owned(), + port: 8080, + protocol: "HTTP".to_owned(), + ..Default::default() + }, + GatewayListeners { + name: "other".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + }, + ]; + let parent = HttpRouteParentRefs { + name: "gw".to_owned(), + port: Some(8080), + ..Default::default() + }; + + assert_eq!( + targeted_sections(&parent, &listeners), + vec![Some("foo".to_owned()), Some("bar".to_owned())], + "a Gateway may serve one port from several listeners, told apart by hostname, and the \ + route attaches to all of them" + ); + } + + #[test] + fn test_a_parent_ref_naming_neither_stays_unresolved() { + let parent = HttpRouteParentRefs { + name: "gw".to_owned(), + ..Default::default() + }; + + assert_eq!( + targeted_sections(&parent, &listeners()), + vec![None], + "resolving it to today's listener names would leave the route bound to a stale set \ + the next time the Gateway gained one" + ); + } } From 5fe9931ad1895722e81d1de7efec2fb31f5b0aaa Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:39:37 -0400 Subject: [PATCH 41/51] chore(deps): add a Kubernetes client backed by canned responses Signed-off-by: Shane Utt --- Cargo.lock | 4 + Cargo.toml | 4 + src/controller/gateway_class.rs | 119 ++++++++++ src/lib.rs | 3 + src/stores.rs | 57 +++-- src/testing.rs | 379 ++++++++++++++++++++++++++++++++ 6 files changed, 548 insertions(+), 18 deletions(-) create mode 100644 src/testing.rs diff --git a/Cargo.lock b/Cargo.lock index 6dba703..0f04d6d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1560,10 +1560,13 @@ dependencies = [ name = "praxis-operator" version = "0.1.0" dependencies = [ + "bytes", "chrono", "criterion", "futures", "gateway-api", + "http", + "http-body-util", "k8s-openapi", "kube", "reqwest", @@ -1572,6 +1575,7 @@ dependencies = [ "sha2", "thiserror", "tokio", + "tower", "tracing", "tracing-subscriber", "yaml_serde", diff --git a/Cargo.toml b/Cargo.toml index eb2ad77..36037ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -28,7 +28,11 @@ name = "config_generation" harness = false [dev-dependencies] +bytes = "1.12.1" criterion = "0.7.0" +http = "1.4.2" +http-body-util = "0.1.4" +tower = { version = "0.5.3", features = ["util"] } chrono = "0.4.45" reqwest = { version = "0.12.28", default-features = false, features = ["rustls-tls"] } diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 32ba6b7..462fe50 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -216,6 +216,20 @@ mod tests { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use super::*; + use crate::testing; + + /// The object the API server hands back from a status apply. + fn accepted_class_response() -> testing::Canned { + testing::Canned::ok( + "/gatewayclasses/praxis", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": CONTROLLER_NAME }, + }), + ) + } #[test] fn test_is_our_controller_accepts_matching_name() { @@ -280,6 +294,111 @@ mod tests { ); } + // ----------------------------------------------------------------------------- + // Reconciliation + // ----------------------------------------------------------------------------- + + #[tokio::test] + async fn test_reconcile_accepts_a_class_naming_this_controller() { + let (ctx, journal) = testing::fake_context(vec![accepted_class_response()], testing::Cached::default()); + + let action = reconcile(Arc::new(gateway_class(CONTROLLER_NAME)), ctx) + .await + .expect("a reachable API server accepts the patch"); + + let patch = journal + .matching("/gatewayclasses/praxis/status") + .pop() + .expect("an owned class should have its status written"); + assert_eq!(patch.method, "PATCH", "status is written by server-side apply"); + assert_eq!( + patch + .body + .as_ref() + .and_then(|b| b.pointer("/status/conditions/0/status")), + Some(&serde_json::Value::String("True".to_owned())), + "an owned class is accepted" + ); + assert_eq!( + action, + Action::await_change(), + "a class has nothing to requeue for once its status is written" + ); + } + + #[tokio::test] + async fn test_reconcile_writes_nothing_for_another_controller() { + let (ctx, journal) = testing::fake_context(vec![], testing::Cached::default()); + + reconcile(Arc::new(gateway_class("example.com/other")), ctx) + .await + .expect("ignoring a class is not a failure"); + + assert!( + journal.requests().is_empty(), + "writing to another controller's GatewayClass would fight it for the status" + ); + } + + #[tokio::test] + async fn test_reconcile_skips_the_patch_when_the_status_already_matches() { + let (ctx, journal) = testing::fake_context(vec![], testing::Cached::default()); + let mut class = gateway_class(CONTROLLER_NAME); + class.metadata.generation = Some(1); + + // Feed back exactly what the reconciler would compute, as the + // API server would hold it after a first pass. + let desired = build_accepted_status(1).expect("a class status is strings and conditions"); + class.status = serde_json::from_value(desired).expect("the computed status is a class status"); + + reconcile(Arc::new(class), ctx) + .await + .expect("an unchanged status is not a failure"); + + assert!( + journal.requests().is_empty(), + "re-patching an unchanged status wakes this controller's own watch, and the loop only \ + ends because the second pass compares equal" + ); + } + + #[tokio::test] + async fn test_reconcile_surfaces_an_api_failure() { + let (client, _) = testing::failing_client(); + let recorder = kube::runtime::events::Recorder::new(client.clone(), crate::context::reporter()); + let ctx = Arc::new(Context { + client, + recorder, + stores: crate::stores::Stores::fake(vec![], vec![], vec![]), + }); + + let error = reconcile(Arc::new(gateway_class(CONTROLLER_NAME)), ctx) + .await + .expect_err("a 500 from the API server is not success"); + + assert!( + matches!(error, OperatorError::Kube(_)), + "the failure has to reach error_policy as itself, or the class is never retried: {error}" + ); + } + + #[tokio::test] + async fn test_error_policy_requeues_rather_than_dropping_the_class() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + let action = error_policy( + Arc::new(gateway_class(CONTROLLER_NAME)), + &OperatorError::MissingObjectKey(".metadata.uid"), + ctx, + ); + + assert_eq!( + action, + Action::requeue(Duration::from_secs(30)), + "a class left un-accepted blocks every Gateway that names it, so the failure has to be \ + retried rather than dropped" + ); + } + #[test] fn test_build_accepted_status_carries_no_metadata() { let status = build_accepted_status(1).expect("a class status is strings and conditions"); diff --git a/src/lib.rs b/src/lib.rs index c9543b8..43893d7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,9 @@ pub mod observability; pub mod resources; pub mod stores; +#[cfg(test)] +mod testing; + use std::{future::Future, sync::Arc}; use ::gateway_api::{ diff --git a/src/stores.rs b/src/stores.rs index 56b86c0..3cd9864 100644 --- a/src/stores.rs +++ b/src/stores.rs @@ -211,6 +211,44 @@ where } } +// ----------------------------------------------------------------------------- +// Test Construction +// ----------------------------------------------------------------------------- + +#[cfg(test)] +impl Stores { + /// Builds populated stores without touching an API server. + /// + /// A reflector `Writer` shares its cache with the `Store` rather + /// than owning it, so the contents outlive the writers dropped at + /// the end of this function. Only `wait_until_ready` would notice + /// the missing writer, and nothing reading these stores calls it. + pub(crate) fn fake(routes: Vec, grants: Vec, namespaces: Vec) -> Self { + use kube::runtime::watcher::Event; + + let (route_store, mut route_writer) = reflector::store::(); + let (grant_store, mut grant_writer) = reflector::store::(); + let (ns_store, mut ns_writer) = reflector::store::(); + + for route in routes { + route_writer.apply_watcher_event(&Event::Apply(route)); + } + for grant in grants { + grant_writer.apply_watcher_event(&Event::Apply(grant)); + } + for namespace in namespaces { + ns_writer.apply_watcher_event(&Event::Apply(namespace)); + } + + drop((route_writer, grant_writer, ns_writer)); + Self { + routes: route_store, + grants: grant_store, + namespaces: ns_store, + } + } +} + // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- @@ -219,7 +257,6 @@ where mod tests { use gateway_api::referencegrants::ReferenceGrantSpec; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; - use kube::runtime::watcher::Event; use super::*; @@ -246,23 +283,7 @@ mod tests { /// `wait_until_ready` would notice, and these tests read state /// directly. fn populated(grants: Vec, namespaces: Vec) -> Stores { - let (route_store, route_writer) = reflector::store::(); - let (grant_store, mut grant_writer) = reflector::store::(); - let (ns_store, mut ns_writer) = reflector::store::(); - - for g in grants { - grant_writer.apply_watcher_event(&Event::Apply(g)); - } - for n in namespaces { - ns_writer.apply_watcher_event(&Event::Apply(n)); - } - - drop((route_writer, grant_writer, ns_writer)); - Stores { - routes: route_store, - grants: grant_store, - namespaces: ns_store, - } + Stores::fake(vec![], grants, namespaces) } #[test] diff --git a/src/testing.rs b/src/testing.rs new file mode 100644 index 0000000..e366273 --- /dev/null +++ b/src/testing.rs @@ -0,0 +1,379 @@ +// SPDX-License-Identifier: MIT +// Copyright (c) 2026 Shane Utt + +//! A Kubernetes client backed by canned responses. +//! +//! Most of what an operator does is talk to an API server, so most of +//! it used to be unreachable from a unit test: every reconciler, every +//! apply, every lookup sat behind a [`kube::Client`] that only exists +//! against a real cluster. Testing those paths through the conformance +//! suite alone means a twenty-minute round trip to learn that a status +//! patch names the wrong field. +//! +//! [`kube::Client::new`] takes any `tower::Service` over HTTP, so a +//! client can be built from a function that answers requests from a +//! table instead. What the tests then exercise is the real reconciler, +//! the real serialization, and the real error handling — only the +//! socket is fake. +//! +//! ```ignore +//! let client = fake_client(vec![ +//! Route::get("/apis/gateway.networking.k8s.io/v1/gatewayclasses/praxis", json!({ ... })), +//! ]); +//! ``` + +use std::{ + sync::{Arc, Mutex}, + task::{Context, Poll}, +}; + +use bytes::Bytes; +use http::{Request, Response, StatusCode}; +use http_body_util::Full; +use kube::Client; +use serde_json::Value; + +// ----------------------------------------------------------------------------- +// Canned Responses +// ----------------------------------------------------------------------------- + +/// One canned answer, matched against the request that asks for it. +#[derive(Debug, Clone)] +pub(crate) struct Canned { + /// Substring the request path must contain for this entry to apply. + /// + /// A substring rather than the whole path: kube appends field + /// managers, dry-run flags and label selectors to a query string + /// that a test has no reason to care about. + pub(crate) path: String, + + /// HTTP status to answer with. + pub(crate) status: StatusCode, + + /// JSON body to answer with. + pub(crate) body: Value, +} + +impl Canned { + /// Answers a request whose path contains `path` with `body` and 200. + pub(crate) fn ok(path: &str, body: Value) -> Self { + Self { + path: path.to_owned(), + status: StatusCode::OK, + body, + } + } + + /// Answers with 404 and a Kubernetes `Status` object. + /// + /// The shape matters: kube parses the body to decide whether an + /// error is `ErrorResponse::NotFound`, and code under test + /// routinely branches on exactly that. + pub(crate) fn not_found(path: &str) -> Self { + Self { + path: path.to_owned(), + status: StatusCode::NOT_FOUND, + body: serde_json::json!({ + "kind": "Status", + "apiVersion": "v1", + "status": "Failure", + "message": "not found", + "reason": "NotFound", + "code": 404, + }), + } + } + + /// Answers with 500 and a Kubernetes `Status` object. + pub(crate) fn server_error(path: &str) -> Self { + Self { + path: path.to_owned(), + status: StatusCode::INTERNAL_SERVER_ERROR, + body: serde_json::json!({ + "kind": "Status", + "apiVersion": "v1", + "status": "Failure", + "message": "boom", + "reason": "InternalError", + "code": 500, + }), + } + } +} + +// ----------------------------------------------------------------------------- +// Recorded Requests +// ----------------------------------------------------------------------------- + +/// A request the code under test issued. +#[derive(Debug, Clone)] +pub(crate) struct Recorded { + /// HTTP method. + pub(crate) method: String, + + /// Request path, query string included. + pub(crate) uri: String, + + /// Request body, parsed as JSON when it was JSON. + pub(crate) body: Option, +} + +/// The requests one fake client has seen, in order. +/// +/// Shared with the service so a test can assert on what was sent — +/// which for a status writer is the whole of the observable behaviour. +#[derive(Debug, Clone, Default)] +pub(crate) struct Journal(Arc>>); + +impl Journal { + /// Returns every request recorded so far. + /// + /// # Panics + /// + /// Panics if a previous holder of the lock panicked, which in a + /// test is a failure worth surfacing rather than hiding. + #[must_use] + pub(crate) fn requests(&self) -> Vec { + self.0.lock().expect("the journal lock is only held to push").clone() + } + + /// Returns the recorded requests whose path contains `needle`. + #[must_use] + pub(crate) fn matching(&self, needle: &str) -> Vec { + self.requests() + .into_iter() + .filter(|request| request.uri.contains(needle)) + .collect() + } + + /// Records one request. + fn push(&self, recorded: Recorded) { + self.0 + .lock() + .expect("the journal lock is only held to push") + .push(recorded); + } +} + +// ----------------------------------------------------------------------------- +// Fake Service +// ----------------------------------------------------------------------------- + +/// A `tower::Service` answering from a table of canned responses. +#[derive(Clone)] +struct FakeService { + /// Canned answers, tried in order; the first path match wins. + canned: Arc<[Canned]>, + + /// Where requests are recorded. + journal: Journal, +} + +impl tower::Service> for FakeService { + type Error = std::convert::Infallible; + type Future = std::pin::Pin> + Send>>; + type Response = Response>; + + fn poll_ready(&mut self, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn call(&mut self, req: Request) -> Self::Future { + let this = self.clone(); + Box::pin(async move { + let method = req.method().to_string(); + let uri = req.uri().to_string(); + let body = collect_json(req.into_body()).await; + + this.journal.push(Recorded { + method, + uri: uri.clone(), + body, + }); + Ok(this.answer(&uri)) + }) + } +} + +/// Reads a request body and parses it as JSON, if it is JSON. +/// +/// The body is what a test asserting on a status patch actually cares +/// about, and it is only readable here — by the time the request +/// reaches the journal the stream is gone. +async fn collect_json(body: kube::client::Body) -> Option { + use http_body_util::BodyExt as _; + + let bytes = body.collect().await.ok()?.to_bytes(); + serde_json::from_slice(&bytes).ok() +} + +impl FakeService { + /// Builds the response for a request path. + fn answer(&self, uri: &str) -> Response> { + let found = self.canned.iter().find(|entry| uri.contains(&entry.path)); + + let (status, body) = found.map_or_else( + || (StatusCode::NOT_FOUND, Canned::not_found("").body), + |entry| (entry.status, entry.body.clone()), + ); + + Response::builder() + .status(status) + .header("content-type", "application/json") + .body(Full::new(Bytes::from(body.to_string()))) + .unwrap_or_else(|_| Response::new(Full::new(Bytes::new()))) + } +} + +// ----------------------------------------------------------------------------- +// Constructors +// ----------------------------------------------------------------------------- + +/// Builds a client answering from `canned`, and the journal of what it +/// was asked. +/// +/// A request matching no entry gets a 404, which is what an empty +/// cluster would say and keeps a test from having to enumerate the +/// lookups it does not care about. +#[must_use] +pub(crate) fn fake_client(canned: Vec) -> (Client, Journal) { + let journal = Journal::default(); + let service = FakeService { + canned: canned.into(), + journal: journal.clone(), + }; + (Client::new(service, "default"), journal) +} + +/// Builds a client that answers everything with 500. +/// +/// The error paths are worth their own tests: a reconciler that +/// swallows an API failure looks identical to one that succeeded until +/// something downstream is missing. +#[must_use] +pub(crate) fn failing_client() -> (Client, Journal) { + fake_client(vec![Canned::server_error("")]) +} + +/// Builds the [`Context`] a reconciler takes, over a fake client. +/// +/// [`Context`]: crate::context::Context +#[must_use] +pub(crate) fn fake_context(canned: Vec, cached: Cached) -> (Arc, Journal) { + let (client, journal) = fake_client(canned); + let recorder = kube::runtime::events::Recorder::new(client.clone(), crate::context::reporter()); + let context = crate::context::Context { + client, + recorder, + stores: crate::stores::Stores::fake(cached.routes, cached.grants, cached.namespaces), + }; + (Arc::new(context), journal) +} + +/// What the reflector caches hold for one test. +/// +/// A struct rather than three positional vectors, which at three empty +/// `vec![]`s in a row stop saying which kind is which. +#[derive(Debug, Default)] +pub(crate) struct Cached { + /// Cached `HTTPRoutes`. + pub(crate) routes: Vec, + + /// Cached `ReferenceGrants`. + pub(crate) grants: Vec, + + /// Cached `Namespaces`. + pub(crate) namespaces: Vec, +} + +// ----------------------------------------------------------------------------- +// Tests +// ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use gateway_api::gatewayclasses::GatewayClass; + use kube::Api; + use serde_json::json; + + use super::*; + + #[tokio::test] + async fn test_a_canned_object_comes_back_typed() { + let (client, _) = fake_client(vec![Canned::ok( + "/gatewayclasses/praxis", + json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": "praxis.sh/gateway-controller" }, + }), + )]); + + let class = Api::::all(client) + .get("praxis") + .await + .expect("the canned response deserializes"); + + assert_eq!( + class.spec.controller_name, "praxis.sh/gateway-controller", + "the fake client has to round-trip real objects, or the tests built on it prove nothing" + ); + } + + #[tokio::test] + async fn test_an_unlisted_path_reads_as_absent() { + let (client, journal) = fake_client(vec![]); + + let result = Api::::all(client).get("missing").await; + + assert!(result.is_err(), "an empty cluster has no GatewayClass to return"); + assert_eq!(journal.matching("missing").len(), 1, "the lookup should be recorded"); + } + + #[tokio::test] + async fn test_a_patch_records_its_method_and_body() { + let (client, journal) = fake_client(vec![Canned::ok( + "/gatewayclasses/praxis", + json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": "praxis.sh/gateway-controller" }, + }), + )]); + + let patch = json!({ "status": { "conditions": [] } }); + Api::::all(client) + .patch_status( + "praxis", + &kube::api::PatchParams::apply("test"), + &kube::api::Patch::Apply(&patch), + ) + .await + .expect("the canned response deserializes"); + + let sent = journal.matching("/status").pop().expect("the patch should be recorded"); + assert_eq!(sent.method, "PATCH", "a server-side apply is a PATCH"); + assert_eq!( + sent.body, + Some(patch), + "the body is the whole of what a status writer does, so a test has to be able to see it" + ); + } + + #[tokio::test] + async fn test_a_failing_client_reports_the_status_code() { + let (client, _) = failing_client(); + + let error = Api::::all(client) + .get("praxis") + .await + .expect_err("a 500 is an error"); + + assert!( + matches!(&error, kube::Error::Api(response) if response.code == 500), + "the error has to survive as an API error, since callers branch on the code: {error}" + ); + } +} From b53359a0679d7c6d7dd7b1e4f52e1a2690aec803 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:39:51 -0400 Subject: [PATCH 42/51] tests(coverage): cover the ownership checks Signed-off-by: Shane Utt --- src/controller/ownership.rs | 159 ++++++++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) diff --git a/src/controller/ownership.rs b/src/controller/ownership.rs index 6f4b985..c798873 100644 --- a/src/controller/ownership.rs +++ b/src/controller/ownership.rs @@ -92,3 +92,162 @@ pub(super) fn collect_routes<'a>( // ----------------------------------------------------------------------------- // Tests // ----------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use gateway_api::{ + gateways::{GatewayListeners, GatewaySpec}, + httproutes::{HttpRouteParentRefs, HttpRouteSpec}, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use serde_json::json; + + use super::*; + use crate::testing; + + // ----------------------------------------------------------------------- + // GatewayClass Validation + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_class_naming_this_controller_is_ours() { + let (client, _) = testing::fake_client(vec![class_response(CONTROLLER_NAME)]); + + assert!( + validate_gateway_class(&client, &gateway()) + .await + .expect("the class exists"), + "a Gateway whose class names this controller is ours to reconcile" + ); + } + + #[tokio::test] + async fn test_a_class_naming_another_controller_is_skipped() { + let (client, _) = testing::fake_client(vec![class_response("example.com/other")]); + + assert!( + !validate_gateway_class(&client, &gateway()) + .await + .expect("the class exists"), + "reconciling another controller's Gateway would fight it for every child resource" + ); + } + + #[tokio::test] + async fn test_a_missing_class_is_its_own_error() { + let (client, _) = testing::fake_client(vec![]); + + let error = validate_gateway_class(&client, &gateway()) + .await + .expect_err("a Gateway naming no existing class cannot be reconciled"); + + assert!( + matches!(&error, OperatorError::GatewayClassNotFound(name) if name == "praxis"), + "a 404 is a user-visible misconfiguration and gets its own variant, not a generic API \ + error the reconciler would retry forever: {error}" + ); + } + + #[tokio::test] + async fn test_a_failed_lookup_stays_an_api_error() { + let (client, _) = testing::failing_client(); + + let error = validate_gateway_class(&client, &gateway()) + .await + .expect_err("a 500 is not an answer"); + + assert!( + matches!(error, OperatorError::Kube(_)), + "an API server that is merely down must be retried, not reported as a missing class: \ + {error}" + ); + } + + // ----------------------------------------------------------------------- + // Route Collection + // ----------------------------------------------------------------------- + + #[test] + fn test_collect_routes_returns_the_routes_naming_this_gateway() { + let routes = vec![Arc::new(route("mine", "gw")), Arc::new(route("theirs", "other-gw"))]; + let stores = Stores::fake(vec![], vec![], vec![]); + + let collected = collect_routes(&gateway(), &routes, &stores); + + let names: Vec<_> = collected.iter().filter_map(|a| a.route.metadata.name.clone()).collect(); + assert_eq!( + names, + vec!["mine".to_owned()], + "a route naming another Gateway contributes nothing to this one's config" + ); + } + + #[test] + fn test_collect_routes_is_empty_when_nothing_attaches() { + let routes = vec![Arc::new(route("theirs", "other-gw"))]; + let stores = Stores::fake(vec![], vec![], vec![]); + + assert!( + collect_routes(&gateway(), &routes, &stores).is_empty(), + "a Gateway with no attached routes gets an empty route table, not every route" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds the Gateway these tests reconcile. + fn gateway() -> Gateway { + Gateway { + metadata: ObjectMeta { + name: Some("gw".to_owned()), + namespace: Some("infra".to_owned()), + ..Default::default() + }, + spec: GatewaySpec { + gateway_class_name: "praxis".to_owned(), + listeners: vec![GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + }], + ..Default::default() + }, + status: None, + } + } + + /// Builds a route naming `parent` as its Gateway. + fn route(name: &str, parent: &str) -> HTTPRoute { + HTTPRoute { + metadata: ObjectMeta { + name: Some(name.to_owned()), + namespace: Some("infra".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + parent_refs: Some(vec![HttpRouteParentRefs { + name: parent.to_owned(), + ..Default::default() + }]), + ..Default::default() + }, + status: None, + } + } + + /// The `GatewayClass` the fake API server hands back. + fn class_response(controller: &str) -> testing::Canned { + testing::Canned::ok( + "/gatewayclasses/praxis", + json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": controller }, + }), + ) + } +} From bfa7e2c6c60348146ef98d03714e6aa788b7550c Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:40:10 -0400 Subject: [PATCH 43/51] chore(coverage): cover listener validation and namespace filtering Signed-off-by: Shane Utt --- src/controller/listener_validation.rs | 214 +++++++++++++++++++++++++- src/controller/namespace_filter.rs | 122 +++++++++++++++ 2 files changed, 332 insertions(+), 4 deletions(-) diff --git a/src/controller/listener_validation.rs b/src/controller/listener_validation.rs index ddeb82a..bf9b7d0 100644 --- a/src/controller/listener_validation.rs +++ b/src/controller/listener_validation.rs @@ -219,14 +219,18 @@ fn is_pem_entry(data: &BTreeMap, key: &str) -> bool { #[cfg(test)] mod tests { - use gateway_api::gateways::{GatewayListenersAllowedRoutes, GatewayListenersAllowedRoutesKinds}; + use gateway_api::{ + gateways::{GatewayListenersAllowedRoutes, GatewayListenersAllowedRoutesKinds, GatewayListenersTls}, + referencegrants::{ReferenceGrantFrom, ReferenceGrantSpec, ReferenceGrantTo}, + }; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use super::*; - use crate::controller::fixtures::{listener, secret_data}; + use crate::{controller::fixtures::secret_data, testing}; #[test] fn test_validate_route_kinds_defaults_to_httproute() { - let (supported, invalid) = validate_route_kinds(&listener("http", 80, "HTTP")); + let (supported, invalid) = validate_route_kinds(&http_listener()); assert_eq!(supported.len(), 1, "HTTPRoute is supported by default"); assert!(!invalid, "an unspecified kind list is never invalid"); @@ -234,7 +238,7 @@ mod tests { #[test] fn test_validate_route_kinds_flags_unsupported_kinds() { - let mut l = listener("http", 80, "HTTP"); + let mut l = http_listener(); l.allowed_routes = Some(GatewayListenersAllowedRoutes { kinds: Some(vec![GatewayListenersAllowedRoutesKinds { group: None, @@ -313,4 +317,206 @@ mod tests { assert!(!is_pem_entry(&data, "tls.key"), "non-PEM data should be rejected"); assert!(!is_pem_entry(&data, "missing"), "an absent key is not PEM"); } + + // ----------------------------------------------------------------------- + // Resolved Refs + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_plain_http_listener_resolves() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + + let (kinds, condition) = listener_resolved_refs(&http_listener(), 1, "infra", &ctx).await; + + assert_eq!( + kinds, + vec![RouteGroupKind::httproute()], + "a listener that names no kinds serves the one kind this operator implements" + ); + assert_eq!(condition.status, "True", "there is nothing for it to fail to resolve"); + } + + #[tokio::test] + async fn test_an_unsupported_route_kind_is_named_as_such() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + let mut listener = http_listener(); + listener.allowed_routes = Some(allowed_kinds(&["TCPRoute"])); + + let (kinds, condition) = listener_resolved_refs(&listener, 1, "infra", &ctx).await; + + assert_eq!(condition.reason, "InvalidRouteKinds", "the reason names the problem"); + assert!( + kinds.is_empty(), + "advertising HTTPRoute on a listener that asked only for TCPRoute would invite routes \ + it will not serve" + ); + } + + #[tokio::test] + async fn test_a_missing_tls_secret_is_an_invalid_certificate_ref() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + + let (_, condition) = listener_resolved_refs(&tls_listener("infra"), 1, "infra", &ctx).await; + + assert_eq!( + (condition.status.as_str(), condition.reason.as_str()), + ("False", "InvalidCertificateRef"), + "a listener whose certificate does not exist cannot terminate TLS, and saying so is \ + the only way an author learns why" + ); + } + + #[tokio::test] + async fn test_a_well_formed_tls_secret_resolves() { + let (ctx, _) = testing::fake_context(vec![secret_response()], testing::Cached::default()); + + let (_, condition) = listener_resolved_refs(&tls_listener("infra"), 1, "infra", &ctx).await; + + assert_eq!( + condition.status, "True", + "a PEM certificate in the same namespace resolves" + ); + } + + #[tokio::test] + async fn test_a_cross_namespace_secret_needs_a_grant() { + let (ctx, journal) = testing::fake_context(vec![secret_response()], testing::Cached::default()); + + let (_, condition) = listener_resolved_refs(&tls_listener("certs"), 1, "infra", &ctx).await; + + assert_eq!( + (condition.status.as_str(), condition.reason.as_str()), + ("False", "RefNotPermitted"), + "reading a Secret across a namespace boundary without a grant is exactly what a \ + ReferenceGrant exists to prevent" + ); + assert!( + journal.requests().is_empty(), + "the refusal has to come before the read, or the operator has already done the thing \ + the grant was meant to authorize" + ); + } + + #[tokio::test] + async fn test_a_grant_admits_the_cross_namespace_secret() { + let (ctx, _) = testing::fake_context( + vec![secret_response()], + testing::Cached { + grants: vec![secret_grant()], + ..Default::default() + }, + ); + + let (_, condition) = listener_resolved_refs(&tls_listener("certs"), 1, "infra", &ctx).await; + + assert_eq!(condition.status, "True", "the grant is what makes the reference legal"); + } + + #[tokio::test] + async fn test_a_non_secret_certificate_ref_is_refused() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + let mut listener = tls_listener("infra"); + if let Some(tls) = listener.tls.as_mut() + && let Some(refs) = tls.certificate_refs.as_mut() + && let Some(first) = refs.first_mut() + { + first.kind = Some("ConfigMap".to_owned()); + } + + let (_, condition) = listener_resolved_refs(&listener, 1, "infra", &ctx).await; + + assert_eq!( + condition.reason, "InvalidCertificateRef", + "this operator mounts core Secrets and nothing else" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a plain HTTP listener. + fn http_listener() -> GatewayListeners { + GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + } + } + + /// Builds an HTTPS listener naming a certificate in `secret_ns`. + fn tls_listener(secret_ns: &str) -> GatewayListeners { + GatewayListeners { + name: "https".to_owned(), + port: 443, + protocol: "HTTPS".to_owned(), + tls: Some(GatewayListenersTls { + certificate_refs: Some(vec![GatewayListenersTlsCertificateRefs { + name: "cert".to_owned(), + namespace: Some(secret_ns.to_owned()), + ..Default::default() + }]), + ..Default::default() + }), + ..Default::default() + } + } + + /// Builds an `allowedRoutes` naming the given kinds. + fn allowed_kinds(kinds: &[&str]) -> GatewayListenersAllowedRoutes { + GatewayListenersAllowedRoutes { + kinds: Some( + kinds + .iter() + .map(|kind| GatewayListenersAllowedRoutesKinds { + group: None, + kind: (*kind).to_owned(), + }) + .collect(), + ), + ..Default::default() + } + } + + /// The Secret the fake API server hands back, PEM and all. + /// + /// A `Secret`'s `data` is base64 on the wire, so the value is + /// pre-encoded rather than pulling in an encoder for one literal. + /// It decodes to a one-line PEM block. + fn secret_response() -> testing::Canned { + let pem = "LS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCngKLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQo="; + testing::Canned::ok( + "/secrets/cert", + serde_json::json!({ + "apiVersion": "v1", + "kind": "Secret", + "metadata": { "name": "cert", "namespace": "infra" }, + "data": { "tls.crt": pem, "tls.key": pem }, + }), + ) + } + + /// A grant letting Gateways in `infra` read Secrets in `certs`. + fn secret_grant() -> ReferenceGrant { + ReferenceGrant { + metadata: ObjectMeta { + name: Some("allow-certs".to_owned()), + namespace: Some("certs".to_owned()), + ..Default::default() + }, + spec: ReferenceGrantSpec { + from: vec![ReferenceGrantFrom { + group: "gateway.networking.k8s.io".to_owned(), + kind: "Gateway".to_owned(), + namespace: "infra".to_owned(), + }], + to: vec![ReferenceGrantTo { + group: String::new(), + kind: "Secret".to_owned(), + name: Some("cert".to_owned()), + }], + }, + } + } } diff --git a/src/controller/namespace_filter.rs b/src/controller/namespace_filter.rs index 3314177..23e6645 100644 --- a/src/controller/namespace_filter.rs +++ b/src/controller/namespace_filter.rs @@ -277,4 +277,126 @@ mod tests { "an unknown operator must not match" ); } + + // ----------------------------------------------------------------------- + // Route Filtering + // ----------------------------------------------------------------------- + + #[test] + fn test_a_route_from_a_disallowed_namespace_is_dropped() { + let listeners = vec![same_namespace_listener()]; + let route = route_in("apps"); + let attached = vec![attached(&route)]; + let stores = Stores::fake(vec![], vec![], vec![]); + + assert!( + filter_routes_by_allowed_namespaces(&attached, &listeners, "infra", &stores).is_empty(), + "a Same-namespace listener must not serve a route from another namespace, and a route \ + the filter lets through reaches the generated config" + ); + } + + #[test] + fn test_a_route_from_the_gateway_namespace_is_kept() { + let listeners = vec![same_namespace_listener()]; + let route = route_in("infra"); + let attached = vec![attached(&route)]; + let stores = Stores::fake(vec![], vec![], vec![]); + + assert_eq!( + filter_routes_by_allowed_namespaces(&attached, &listeners, "infra", &stores).len(), + 1, + "Same is the default policy and it allows the Gateway's own namespace" + ); + } + + #[test] + fn test_one_permissive_listener_is_enough() { + let listeners = vec![same_namespace_listener(), all_namespaces_listener()]; + let route = route_in("apps"); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + let stores = Stores::fake(vec![], vec![], vec![]); + + assert_eq!( + filter_routes_by_allowed_namespaces(&attached, &listeners, "infra", &stores).len(), + 1, + "a parentRef naming no section targets every listener, so one that allows the route's \ + namespace admits it even while another refuses" + ); + } + + #[test] + fn test_a_section_name_confines_the_check_to_that_listener() { + let listeners = vec![same_namespace_listener(), all_namespaces_listener()]; + let route = route_in("apps"); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![Some("same".to_owned())], + }]; + let stores = Stores::fake(vec![], vec![], vec![]); + + assert!( + filter_routes_by_allowed_namespaces(&attached, &listeners, "infra", &stores).is_empty(), + "naming a listener means asking that listener, and the permissive one beside it does \ + not answer for it" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a listener refusing every namespace but the Gateway's. + fn same_namespace_listener() -> GatewayListeners { + GatewayListeners { + name: "same".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + } + } + + /// Builds a listener admitting routes from anywhere. + fn all_namespaces_listener() -> GatewayListeners { + use gateway_api::gateways::{GatewayListenersAllowedRoutes, GatewayListenersAllowedRoutesNamespaces}; + + GatewayListeners { + name: "all".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + allowed_routes: Some(GatewayListenersAllowedRoutes { + namespaces: Some(GatewayListenersAllowedRoutesNamespaces { + from: Some(GatewayListenersAllowedRoutesNamespacesFrom::All), + selector: None, + }), + ..Default::default() + }), + ..Default::default() + } + } + + /// Builds a route living in `namespace`. + fn route_in(namespace: &str) -> HTTPRoute { + HTTPRoute { + metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta { + name: Some("route".to_owned()), + namespace: Some(namespace.to_owned()), + ..Default::default() + }, + spec: gateway_api::httproutes::HttpRouteSpec::default(), + status: None, + } + } + + /// Attaches a route to every listener, as a `parentRef` with no + /// `sectionName` does. + fn attached(route: &HTTPRoute) -> AttachedRoute<'_> { + AttachedRoute { + route, + section_names: vec![None], + } + } } From db3fa7bd0e100774ac4682aab7f9465c95eb5c59 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:40:30 -0400 Subject: [PATCH 44/51] tests(coverage): cover Gateway status assembly against a fake API server Signed-off-by: Shane Utt --- src/controller/gateway_status.rs | 266 ++++++++++++++++++++++++++++++- 1 file changed, 265 insertions(+), 1 deletion(-) diff --git a/src/controller/gateway_status.rs b/src/controller/gateway_status.rs index e666672..9ca26b1 100644 --- a/src/controller/gateway_status.rs +++ b/src/controller/gateway_status.rs @@ -319,7 +319,10 @@ fn gateway_programmed_condition(generation: i64, any_accepted: bool, data_plane_ #[cfg(test)] mod tests { use super::*; - use crate::controller::fixtures::{https_listener, listener, route_with_hostnames}; + use crate::{ + controller::fixtures::{https_listener, listener, route_with_hostnames}, + testing, + }; #[test] fn test_gateway_programmed_all_ready() { @@ -434,4 +437,265 @@ mod tests { "a route bound to another section is not attached here" ); } + + // ----------------------------------------------------------------------- + // Status Assembly + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_ready_data_plane_reports_programmed_with_its_address() { + let (ctx, journal) = testing::fake_context( + vec![service_with_address(), ready_deployment(), gateway_response()], + testing::Cached::default(), + ); + + build_and_apply_gateway_status(&ctx, &gateway(), &[http_listener()], &[]) + .await + .expect("a reachable API server accepts the patch"); + + let status = patched_status(&journal); + assert_eq!( + status.pointer("/addresses/0/value").and_then(Value::as_str), + Some("10.0.0.1"), + "the load-balancer address is how a client finds the Gateway, so it belongs in status" + ); + assert_eq!( + condition_status(&status, "Programmed"), + Some("True"), + "a Deployment with a ready replica and an address is a programmed data plane" + ); + } + + #[tokio::test] + async fn test_an_address_without_ready_pods_is_not_programmed() { + let (ctx, journal) = testing::fake_context( + vec![service_with_address(), gateway_response()], + testing::Cached::default(), + ); + + build_and_apply_gateway_status(&ctx, &gateway(), &[http_listener()], &[]) + .await + .expect("a reachable API server accepts the patch"); + + assert_eq!( + condition_status(&patched_status(&journal), "Programmed"), + Some("False"), + "an address in front of no running pods answers nothing, and reporting Programmed \ + would tell conformance to start sending traffic" + ); + } + + #[tokio::test] + async fn test_a_ready_deployment_without_an_address_is_not_programmed() { + let (ctx, journal) = + testing::fake_context(vec![ready_deployment(), gateway_response()], testing::Cached::default()); + + build_and_apply_gateway_status(&ctx, &gateway(), &[http_listener()], &[]) + .await + .expect("a reachable API server accepts the patch"); + + let status = patched_status(&journal); + assert_eq!( + status.pointer("/addresses").and_then(Value::as_array).map(Vec::len), + Some(0), + "a Service with no ingress yet yields no addresses, not a missing field" + ); + assert_eq!( + condition_status(&status, "Programmed"), + Some("False"), + "pods with no address in front of them are unreachable" + ); + } + + #[tokio::test] + async fn test_an_unsupported_protocol_listener_is_rejected_in_status() { + let (ctx, journal) = testing::fake_context(vec![gateway_response()], testing::Cached::default()); + let mut listener = http_listener(); + listener.protocol = "TCP".to_owned(); + + build_and_apply_gateway_status(&ctx, &gateway(), &[listener], &[]) + .await + .expect("a reachable API server accepts the patch"); + + let status = patched_status(&journal); + assert_eq!( + status + .pointer("/listeners/0/conditions/0/reason") + .and_then(Value::as_str), + Some("UnsupportedProtocol"), + "the listener has to say why it is not accepted" + ); + assert_eq!( + condition_status(&status, "Accepted"), + Some("False"), + "a Gateway whose only listener was rejected accepts nothing" + ); + } + + #[tokio::test] + async fn test_a_conflicting_pair_of_listeners_is_reported_on_both() { + let (ctx, journal) = testing::fake_context(vec![gateway_response()], testing::Cached::default()); + let mut second = http_listener(); + second.name = "http-2".to_owned(); + second.protocol = "HTTPS".to_owned(); + + build_and_apply_gateway_status(&ctx, &gateway(), &[http_listener(), second], &[]) + .await + .expect("a reachable API server accepts the patch"); + + let status = patched_status(&journal); + let conflicted = status + .pointer("/listeners") + .and_then(Value::as_array) + .map(|listeners| { + listeners + .iter() + .filter(|l| { + l.pointer("/conditions") + .and_then(Value::as_array) + .is_some_and(|c| c.iter().any(|c| c["type"] == "Conflicted" && c["status"] == "True")) + }) + .count() + }) + .unwrap_or_default(); + + assert_eq!( + conflicted, 2, + "two protocols on one port is a conflict both listeners are party to, and reporting \ + it on one leaves the other looking healthy" + ); + } + + #[tokio::test] + async fn test_an_unchanged_status_is_not_rewritten() { + let (ctx, journal) = testing::fake_context(vec![gateway_response()], testing::Cached::default()); + let mut gw = gateway(); + + build_and_apply_gateway_status(&ctx, &gw, &[http_listener()], &[]) + .await + .expect("the first pass writes"); + let first = patched_status(&journal); + gw.status = serde_json::from_value(first).expect("the computed status is a Gateway status"); + + let (ctx, second_journal) = testing::fake_context(vec![gateway_response()], testing::Cached::default()); + build_and_apply_gateway_status(&ctx, &gw, &[http_listener()], &[]) + .await + .expect("an unchanged status is not a failure"); + + assert!( + second_journal.matching("/status").is_empty(), + "patching an unchanged status wakes this controller's own watch, and an idle Gateway \ + would reconcile forever" + ); + } + + #[tokio::test] + async fn test_a_rejected_status_patch_surfaces() { + let (client, _) = testing::failing_client(); + + let error = apply_gateway_status(&client, &gateway(), &serde_json::json!({ "conditions": [] })) + .await + .expect_err("a 500 is not success"); + + assert!( + matches!(error, crate::error::OperatorError::Kube(_)), + "a status the API server refused has to be retried, not reported as written: {error}" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Returns the `status` sub-object of the patch that was sent. + fn patched_status(journal: &testing::Journal) -> Value { + journal + .matching("/status") + .pop() + .and_then(|request| request.body) + .and_then(|body| body.get("status").cloned()) + .expect("a status patch should have been sent") + } + + /// Returns the status of the named Gateway-level condition. + fn condition_status<'a>(status: &'a Value, kind: &str) -> Option<&'a str> { + status + .pointer("/conditions")? + .as_array()? + .iter() + .find(|c| c["type"] == kind)? + .get("status")? + .as_str() + } + + /// Builds the Gateway these tests report on. + fn gateway() -> Gateway { + use gateway_api::gateways::GatewaySpec; + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + + Gateway { + metadata: ObjectMeta { + name: Some("gw".to_owned()), + namespace: Some("infra".to_owned()), + generation: Some(1), + ..Default::default() + }, + spec: GatewaySpec { + gateway_class_name: "praxis".to_owned(), + listeners: vec![http_listener()], + ..Default::default() + }, + status: None, + } + } + + /// Builds a plain HTTP listener on port 80. + fn http_listener() -> GatewayListeners { + GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + } + } + + /// The child Service, carrying a load-balancer address. + fn service_with_address() -> testing::Canned { + testing::Canned::ok( + "/services/praxis-gw", + serde_json::json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { "name": "praxis-gw", "namespace": "infra" }, + "status": { "loadBalancer": { "ingress": [{ "ip": "10.0.0.1" }] } }, + }), + ) + } + + /// The child Deployment, with a ready replica. + fn ready_deployment() -> testing::Canned { + testing::Canned::ok( + "/deployments/praxis-gw", + serde_json::json!({ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { "name": "praxis-gw", "namespace": "infra" }, + "spec": { "selector": { "matchLabels": {} }, "template": {} }, + "status": { "readyReplicas": 1 }, + }), + ) + } + + /// The object the API server hands back from a status apply. + fn gateway_response() -> testing::Canned { + testing::Canned::ok( + "/gateways/gw", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": { "name": "gw", "namespace": "infra" }, + "spec": { "gatewayClassName": "praxis", "listeners": [] }, + }), + ) + } } From 38647af99b4393c3a96818037b93ee5b114aa5c8 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:40:45 -0400 Subject: [PATCH 45/51] fix: keep generated resource names inside the 63-character limit Signed-off-by: Shane Utt --- src/controller/praxis_config.rs | 239 +++++++++++++++++++++++++++++++- src/resources/labels.rs | 123 +++++++++++++++- 2 files changed, 358 insertions(+), 4 deletions(-) diff --git a/src/controller/praxis_config.rs b/src/controller/praxis_config.rs index 8435fa9..d7807cf 100644 --- a/src/controller/praxis_config.rs +++ b/src/controller/praxis_config.rs @@ -350,8 +350,13 @@ fn sha256_hex(data: &str) -> String { #[cfg(test)] mod tests { + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use super::*; - use crate::controller::fixtures::{https_listener, listener}; + use crate::{ + controller::fixtures::{https_listener, listener}, + testing, + }; #[test] fn test_sha256_hex_of_empty_string() { @@ -477,4 +482,236 @@ mod tests { ); assert_eq!(ports[0].protocol, Some("TCP".to_owned()), "HTTP listeners are TCP"); } + + // ----------------------------------------------------------------------- + // Config Generation + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_gateway_with_one_route_generates_a_routed_config() { + let (client, _) = testing::fake_client(vec![backend_service_response(), endpoint_slice_response()]); + let route = route(); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + let output = build_praxis_config(&client, &[http_listener()], &attached, &[]) + .await + .expect("the backend resolves"); + + assert!( + output.config_yaml.contains("10.0.0.1:8080"), + "the resolved endpoint has to reach the data plane config: {}", + output.config_yaml + ); + assert_eq!( + output.listener_ports, + vec![("http".to_owned(), 80)], + "the container and Service need a port per distinct listener port" + ); + assert!( + output.tls_secret_names.is_empty(), + "an HTTP listener mounts no certificates" + ); + } + + #[tokio::test] + async fn test_an_unsupported_listener_contributes_nothing() { + let (client, _) = testing::fake_client(vec![]); + let mut listener = http_listener(); + listener.protocol = "TCP".to_owned(); + + let output = build_praxis_config(&client, &[listener], &[], &[]) + .await + .expect("a config with no listeners is still a config"); + + assert!( + output.listener_ports.is_empty(), + "binding a port for a protocol the data plane cannot serve would answer requests with \ + nothing" + ); + } + + #[tokio::test] + async fn test_a_backend_lookup_failure_fails_the_config() { + let (client, _) = testing::failing_client(); + let route = route(); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + let Err(error) = build_praxis_config(&client, &[http_listener()], &attached, &[]).await else { + panic!("a 500 from the endpoints API is not an empty backend"); + }; + + assert!( + matches!(error, crate::error::OperatorError::Kube(_)), + "generating a config with no endpoints because the API server was down would blackhole \ + live traffic: {error}" + ); + } + + #[tokio::test] + async fn test_applying_children_writes_all_four_and_returns_the_hash() { + let (client, journal) = testing::fake_client(vec![ + testing::Canned::ok("/configmaps", serde_json::json!({ "kind": "ConfigMap" })), + testing::Canned::ok("/deployments", serde_json::json!({ "kind": "Deployment" })), + testing::Canned::ok("/services", serde_json::json!({ "kind": "Service" })), + testing::Canned::ok( + "/poddisruptionbudgets", + serde_json::json!({ "kind": "PodDisruptionBudget" }), + ), + ]); + let output = PraxisConfigOutput { + config_yaml: "listeners: []\n".to_owned(), + listener_ports: vec![("http".to_owned(), 80)], + tls_secret_names: vec![], + }; + + let hash = Box::pin(apply_child_resources(&client, &gateway(), &output)) + .await + .expect("every apply is answered"); + + assert_eq!( + hash, + sha256_hex(&output.config_yaml), + "the returned hash is what the pod template is annotated with, so it has to be the \ + hash of the config that was actually applied" + ); + for kind in ["/configmaps", "/deployments", "/services", "/poddisruptionbudgets"] { + assert_eq!( + journal.matching(kind).len(), + 1, + "every child resource has to be applied, or the data plane is half-built: {kind}" + ); + } + } + + #[tokio::test] + async fn test_a_refused_apply_stops_the_rest() { + let (client, journal) = testing::failing_client(); + let output = PraxisConfigOutput { + config_yaml: "listeners: []\n".to_owned(), + listener_ports: vec![], + tls_secret_names: vec![], + }; + + Box::pin(apply_child_resources(&client, &gateway(), &output)) + .await + .expect_err("a refused ConfigMap is not success"); + + assert_eq!( + journal.requests().len(), + 1, + "applying a Deployment that points at a ConfigMap the API server refused would start \ + pods with no config" + ); + } + + #[test] + fn test_service_ports_target_the_listener_port() { + let ports = build_service_ports(&[("http".to_owned(), 80), ("https".to_owned(), 443)]); + + assert_eq!(ports.len(), 2, "one Service port per listener port"); + assert_eq!( + (ports[0].port, ports[0].target_port.clone()), + (80, Some(IntOrString::Int(80))), + "the data plane listens on the same port the Service publishes, so the two match" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a plain HTTP listener on port 80. + fn http_listener() -> GatewayListeners { + GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + } + } + + /// Builds a route with one backend in the same namespace. + fn route() -> gateway_api::httproutes::HTTPRoute { + use gateway_api::httproutes::{HTTPRoute, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteSpec}; + + HTTPRoute { + metadata: ObjectMeta { + name: Some("route".to_owned()), + namespace: Some("infra".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + rules: Some(vec![HttpRouteRules { + backend_refs: Some(vec![HttpRouteRulesBackendRefs { + name: "svc".to_owned(), + port: Some(8080), + ..Default::default() + }]), + ..Default::default() + }]), + ..Default::default() + }, + status: None, + } + } + + /// Builds the Gateway the child resources belong to. + fn gateway() -> Gateway { + use gateway_api::gateways::GatewaySpec; + + Gateway { + metadata: ObjectMeta { + name: Some("gw".to_owned()), + namespace: Some("infra".to_owned()), + uid: Some("uid".to_owned()), + ..Default::default() + }, + spec: GatewaySpec { + gateway_class_name: "praxis".to_owned(), + listeners: vec![http_listener()], + ..Default::default() + }, + status: None, + } + } + + /// The backend Service the route names. + fn backend_service_response() -> testing::Canned { + testing::Canned::ok( + "/services/svc", + serde_json::json!({ + "apiVersion": "v1", + "kind": "Service", + "metadata": { "name": "svc", "namespace": "infra" }, + "spec": { "ports": [{ "port": 8080, "targetPort": 8080 }] }, + }), + ) + } + + /// One ready endpoint for the route's backend Service. + fn endpoint_slice_response() -> testing::Canned { + testing::Canned::ok( + "/endpointslices", + serde_json::json!({ + "apiVersion": "discovery.k8s.io/v1", + "kind": "EndpointSliceList", + "metadata": {}, + "items": [{ + "metadata": { "name": "svc-abc", "namespace": "infra" }, + "addressType": "IPv4", + "endpoints": [{ + "addresses": ["10.0.0.1"], + "conditions": { "ready": true }, + }], + "ports": [{ "port": 8080 }], + }], + }), + ) + } } diff --git a/src/resources/labels.rs b/src/resources/labels.rs index 1a4f4ab..7bb4e8e 100644 --- a/src/resources/labels.rs +++ b/src/resources/labels.rs @@ -67,12 +67,58 @@ pub fn infrastructure_annotations(gateway: &gateway_api::gateways::Gateway) -> B .unwrap_or_default() } +/// Longest name Kubernetes accepts for the objects this operator +/// creates. +/// +/// `ConfigMap`, `Deployment`, `Service` and `PodDisruptionBudget` names +/// are DNS labels, capped at 63 characters. A Gateway name may be up to +/// 253, and the `praxis-` prefix spends seven of the 63, so anything +/// past 56 characters overflows. +const MAX_CHILD_NAME: usize = 63; + +/// Characters of the digest appended to a truncated name. +const DIGEST_LEN: usize = 8; + /// Returns the child resource name for a given Gateway name. /// -/// Prefixes the gateway name with `praxis-` to form the deployment and service -/// names. +/// Prefixes the gateway name with `praxis-` to form the `ConfigMap`, +/// `Deployment`, `Service` and `PodDisruptionBudget` names. +/// +/// A name that would exceed the 63-character limit is truncated and +/// given a digest of the full Gateway name. Without that, every child +/// apply for such a Gateway is rejected as invalid, the reconcile +/// fails before it writes a status, and the Gateway sits forever on +/// the `Accepted: Unknown` the CRD defaults to — with nothing to say +/// why. The digest is what keeps two long Gateways sharing a prefix +/// from sharing a Deployment. +/// +/// ``` +/// use praxis_operator::resources::labels::child_name; +/// +/// assert_eq!(child_name("my-gateway"), "praxis-my-gateway"); +/// +/// // 57 characters, one past what the prefix leaves room for. +/// let long = "gateway-with-one-not-matching-port-and-section-name-route"; +/// assert!(child_name(long).len() <= 63); +/// assert_ne!(child_name(long), child_name(&format!("{long}-two"))); +/// ``` pub fn child_name(gateway_name: &str) -> String { - format!("praxis-{gateway_name}") + let full = format!("praxis-{gateway_name}"); + if full.len() <= MAX_CHILD_NAME { + return full; + } + + let digest = short_digest(gateway_name); + let keep = MAX_CHILD_NAME - DIGEST_LEN - 1; + let head: String = full.chars().take(keep).collect(); + format!("{}-{digest}", head.trim_end_matches('-')) +} + +/// Returns the first [`DIGEST_LEN`] hex characters of the SHA-256 of +/// `value`. +fn short_digest(value: &str) -> String { + let digest = ::digest(value.as_bytes()); + format!("{digest:x}").chars().take(DIGEST_LEN).collect() } /// Returns an `OwnerReference` for a `Gateway` resource. @@ -128,6 +174,77 @@ mod tests { assert_eq!(child_name(""), "praxis-"); } + #[test] + fn test_a_name_at_the_limit_is_left_alone() { + let name = "g".repeat(MAX_CHILD_NAME - "praxis-".len()); + + assert_eq!( + child_name(&name), + format!("praxis-{name}"), + "truncating a name that already fits would rename the children of every Gateway near \ + the limit, orphaning what it had already created" + ); + } + + #[test] + fn test_an_overlong_name_is_cut_to_the_limit() { + let name = "g".repeat(200); + + let child = child_name(&name); + + assert_eq!( + child.len(), + MAX_CHILD_NAME, + "Kubernetes rejects a longer object name outright, and every child apply fails with it" + ); + } + + #[test] + fn test_trimming_a_trailing_dash_may_come_in_under_the_limit() { + let name = "gateway-with-one-not-matching-port-and-section-name-route"; + + let child = child_name(name); + + assert!( + child.len() <= MAX_CHILD_NAME, + "the cap is a ceiling, not a target: {child}" + ); + assert!( + !child.contains("--"), + "trimming the dash the cut landed on must not leave a doubled one: {child}" + ); + } + + #[test] + fn test_two_overlong_names_sharing_a_prefix_stay_distinct() { + let base = "g".repeat(200); + + assert_ne!( + child_name(&format!("{base}-one")), + child_name(&format!("{base}-two")), + "truncation alone would give two Gateways the same Deployment, and each reconcile \ + would overwrite the other's config" + ); + } + + #[test] + fn test_a_truncated_name_is_a_valid_dns_label() { + let name = format!("{}-", "g".repeat(60)); + + let child = child_name(&name); + + assert!( + child + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "a name outside the DNS label charset is rejected as surely as a long one: {child}" + ); + assert!( + !child.starts_with('-') && !child.ends_with('-'), + "a leading or trailing dash is not a DNS label: {child}" + ); + } + #[test] fn test_owner_reference() { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; From 15e0fb36a1026eca962420f0f56825864faf0e93 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:40:58 -0400 Subject: [PATCH 46/51] test(coverage): cover the Gateway reconciler's apply and cleanup paths Signed-off-by: Shane Utt --- src/controller/gateway.rs | 385 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 385 insertions(+) diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index 34c564e..bf98ef2 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -412,8 +412,10 @@ mod tests { referencegrants::{ReferenceGrantFrom, ReferenceGrantSpec, ReferenceGrantTo}, }; use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use serde_json::Value; use super::*; + use crate::testing; #[test] fn test_map_route_to_gateway_basic() { @@ -664,4 +666,387 @@ mod tests { }, } } + + // ----------------------------------------------------------------------- + // Reconciliation + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_full_apply_writes_children_status_and_route_status() { + let route = attachable_route(); + let (ctx, journal) = testing::fake_context( + reconcile_responses(), + testing::Cached { + routes: vec![route], + ..Default::default() + }, + ); + + let action = Box::pin(apply(Arc::new(reconcilable_gateway()), &ctx)) + .await + .expect("every call is answered"); + + for kind in ["/configmaps", "/deployments", "/services", "/poddisruptionbudgets"] { + assert!( + !journal.matching(kind).is_empty(), + "the data plane is only complete once every child is applied: {kind}" + ); + } + assert!( + !journal.matching("/gateways/gw/status").is_empty(), + "a Gateway with no status tells conformance nothing about whether it is serving" + ); + assert_eq!( + action, + Action::requeue(Duration::from_secs(2)), + "a Deployment that has not finished rolling out is re-checked quickly, not in fifteen \ + seconds" + ); + } + + #[tokio::test] + async fn test_a_finished_rollout_with_unchanged_config_admits_routes() { + let (client, _) = testing::fake_client(vec![rolled_out_deployment()]); + + assert!( + can_accept_routes(&client, &reconcilable_gateway(), "infra", false).await, + "a settled data plane serving the config that is already applied is exactly when a \ + route may be reported accepted" + ); + } + + #[tokio::test] + async fn test_a_changed_config_holds_routes_back() { + let (client, _) = testing::fake_client(vec![rolled_out_deployment()]); + + assert!( + !can_accept_routes(&client, &reconcilable_gateway(), "infra", true).await, + "the pods are still running the previous config, so accepting the route would invite \ + traffic the data plane cannot route yet" + ); + } + + #[tokio::test] + async fn test_an_unfinished_rollout_holds_routes_back() { + let (client, _) = testing::fake_client(vec![]); + + assert!( + !can_accept_routes(&client, &reconcilable_gateway(), "infra", false).await, + "a Deployment that does not exist yet has certainly not rolled out" + ); + } + + #[tokio::test] + async fn test_a_gateway_from_another_class_is_left_alone() { + let (ctx, journal) = testing::fake_context( + vec![testing::Canned::ok( + "/gatewayclasses/praxis", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": "example.com/other" }, + }), + )], + testing::Cached::default(), + ); + + let action = Box::pin(apply(Arc::new(reconcilable_gateway()), &ctx)) + .await + .expect("skipping is not a failure"); + + assert_eq!(action, Action::await_change(), "there is nothing to requeue for"); + assert_eq!( + journal.requests().len(), + 1, + "the class lookup is the only call another controller's Gateway should provoke" + ); + } + + #[tokio::test] + async fn test_a_gateway_requesting_an_address_is_rejected() { + let (ctx, journal) = testing::fake_context(reconcile_responses(), testing::Cached::default()); + let mut gw = reconcilable_gateway(); + gw.spec.addresses = Some(vec![GatewayAddresses { + value: Some("1.2.3.4".to_owned()), + ..Default::default() + }]); + + let action = Box::pin(apply(Arc::new(gw), &ctx)) + .await + .expect("a rejection is a clean outcome"); + + let status = journal + .matching("/gateways/gw/status") + .pop() + .and_then(|request| request.body) + .expect("the rejection has to be written where the author will see it"); + assert_eq!( + status.pointer("/status/conditions/0/reason").and_then(Value::as_str), + Some("UnsupportedAddress"), + "the data-plane Service takes whatever address its provider assigns, and silently \ + ignoring the request would look like it had been honoured" + ); + assert_eq!(action, Action::await_change(), "a rejected Gateway waits for an edit"); + assert!( + journal.matching("/deployments").is_empty(), + "a rejected Gateway must not get a data plane" + ); + } + + #[tokio::test] + async fn test_cleanup_clears_this_gateways_route_entries() { + let route = route_with_parent_status(); + let (ctx, journal) = testing::fake_context( + vec![route_response()], + testing::Cached { + routes: vec![route], + ..Default::default() + }, + ); + + cleanup(&reconcilable_gateway(), &ctx).await; + + assert!( + !journal.matching("/httproutes/route/status").is_empty(), + "child resources go with owner references, but route status does not — a stale entry \ + naming a deleted parent would outlive the Gateway" + ); + } + + #[tokio::test] + async fn test_cleanup_survives_a_route_it_cannot_patch() { + let route = attachable_route(); + let (ctx, _) = testing::fake_context( + vec![testing::Canned::server_error("")], + testing::Cached { + routes: vec![route], + ..Default::default() + }, + ); + + cleanup(&reconcilable_gateway(), &ctx).await; + // Reaching here is the assertion: a Gateway that cannot finish + // deleting because one route refused a patch would hold its + // finalizer forever. + } + + #[tokio::test] + async fn test_apply_resource_reports_a_refused_patch() { + let (client, _) = testing::failing_client(); + let cm = k8s_openapi::api::core::v1::ConfigMap { + metadata: ObjectMeta { + name: Some("praxis-gw".to_owned()), + ..Default::default() + }, + ..Default::default() + }; + + let error = apply_resource(&client, "infra", &cm) + .await + .expect_err("a 500 is not an applied resource"); + + assert!( + matches!(error, OperatorError::Kube(_)), + "an apply the API server refused has to fail the reconcile, or the Gateway reports a \ + data plane it never built: {error}" + ); + } + + #[tokio::test] + async fn test_apply_resource_needs_a_name() { + let (client, _) = testing::fake_client(vec![]); + let cm = k8s_openapi::api::core::v1::ConfigMap::default(); + + let error = apply_resource(&client, "infra", &cm) + .await + .expect_err("an unnamed resource cannot be applied"); + + assert!( + matches!(error, OperatorError::MissingObjectKey(".metadata.name")), + "the missing field is named, because the alternative is a 404 with no explanation: \ + {error}" + ); + } + + #[tokio::test] + async fn test_error_policy_retries_a_transient_failure_sooner() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + let gw = Arc::new(reconcilable_gateway()); + + let transient = error_policy( + Arc::clone(&gw), + &OperatorError::Kube(kube::Error::LinesCodecMaxLineLengthExceeded), + Arc::clone(&ctx), + ); + let logic = error_policy(gw, &OperatorError::MissingObjectKey(".metadata.uid"), ctx); + + assert_eq!( + transient, + Action::requeue(Duration::from_secs(15)), + "an API server that blinked is worth retrying soon" + ); + assert_eq!( + logic, + Action::requeue(Duration::from_secs(30)), + "a malformed object will not fix itself in fifteen seconds, and retrying it as fast \ + only burns the API budget" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Builds a Gateway this operator owns and can serve. + fn reconcilable_gateway() -> Gateway { + use gateway_api::gateways::{GatewayListeners, GatewaySpec}; + + Gateway { + metadata: ObjectMeta { + name: Some("gw".to_owned()), + namespace: Some("infra".to_owned()), + uid: Some("uid".to_owned()), + generation: Some(1), + ..Default::default() + }, + spec: GatewaySpec { + gateway_class_name: "praxis".to_owned(), + listeners: vec![GatewayListeners { + name: "http".to_owned(), + port: 80, + protocol: "HTTP".to_owned(), + ..Default::default() + }], + ..Default::default() + }, + status: None, + } + } + + /// Builds a route already carrying this controller's status entry + /// for [`reconcilable_gateway`]. + fn route_with_parent_status() -> HTTPRoute { + let mut route = attachable_route(); + route.status = serde_json::from_value(serde_json::json!({ + "parents": [{ + "parentRef": { + "group": GATEWAY_GROUP, + "kind": "Gateway", + "name": "gw", + "namespace": "infra", + }, + "controllerName": crate::context::CONTROLLER_NAME, + "conditions": [], + }], + })) + .expect("the entry is the shape the operator writes"); + route + } + + /// Builds a route that attaches to [`reconcilable_gateway`]. + fn attachable_route() -> HTTPRoute { + HTTPRoute { + metadata: ObjectMeta { + name: Some("route".to_owned()), + namespace: Some("infra".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + parent_refs: Some(vec![HttpRouteParentRefs { + name: "gw".to_owned(), + ..Default::default() + }]), + ..Default::default() + }, + status: None, + } + } + + /// Every response a clean reconcile asks for. + fn reconcile_responses() -> Vec { + let mut responses = vec![owned_class_response()]; + responses.extend(child_apply_responses()); + responses.push(gateway_response()); + responses.push(route_response()); + responses + } + + /// The `GatewayClass` this operator owns. + fn owned_class_response() -> testing::Canned { + testing::Canned::ok( + "/gatewayclasses/praxis", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": crate::context::CONTROLLER_NAME }, + }), + ) + } + + /// An accepting answer for each child resource apply. + fn child_apply_responses() -> Vec { + vec![ + testing::Canned::ok("/configmaps", serde_json::json!({ "kind": "ConfigMap" })), + testing::Canned::ok("/deployments", serde_json::json!({ "kind": "Deployment" })), + testing::Canned::ok("/services", serde_json::json!({ "kind": "Service" })), + testing::Canned::ok( + "/poddisruptionbudgets", + serde_json::json!({ "kind": "PodDisruptionBudget" }), + ), + ] + } + + /// The object the API server hands back from a Gateway status apply. + fn gateway_response() -> testing::Canned { + testing::Canned::ok( + "/gateways/gw", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": { "name": "gw", "namespace": "infra" }, + "spec": { "gatewayClassName": "praxis", "listeners": [] }, + }), + ) + } + + /// The object the API server hands back from a route status apply. + fn route_response() -> testing::Canned { + testing::Canned::ok( + "/httproutes/route", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { "name": "route", "namespace": "infra" }, + "spec": {}, + }), + ) + } + + /// A child Deployment whose rollout has finished. + /// + /// Placed ahead of the generic `/deployments` apply response so the + /// GET that reads rollout state sees a finished one. + fn rolled_out_deployment() -> testing::Canned { + testing::Canned::ok( + "/deployments/praxis-gw", + serde_json::json!({ + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": { "name": "praxis-gw", "namespace": "infra", "generation": 1 }, + "spec": { "selector": { "matchLabels": {} }, "template": {} }, + "status": { + "observedGeneration": 1, + "readyReplicas": 1, + "conditions": [{ + "type": "Progressing", + "status": "True", + "reason": "NewReplicaSetAvailable", + "lastTransitionTime": "2026-01-01T00:00:00Z", + }], + }, + }), + ) + } } From 6ee16fcdd7e0657897529a4e80a1503b006be449 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:41:12 -0400 Subject: [PATCH 47/51] tests(coverage): cover the HTTPRoute reconciler's rejection paths Signed-off-by: Shane Utt --- src/controller/httproute.rs | 218 ++++++++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) diff --git a/src/controller/httproute.rs b/src/controller/httproute.rs index bd44849..7e409e0 100644 --- a/src/controller/httproute.rs +++ b/src/controller/httproute.rs @@ -288,6 +288,7 @@ mod tests { use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; use super::*; + use crate::testing; #[test] fn test_a_parent_ref_naming_nothing_selects_every_listener() { @@ -568,4 +569,221 @@ mod tests { "a route in another namespace is not allowed by the default policy" ); } + + // ----------------------------------------------------------------------- + // Reconciliation + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_a_route_naming_no_parent_is_left_alone() { + let (ctx, journal) = testing::fake_context(vec![], testing::Cached::default()); + let mut route = route(&[]); + route.spec.parent_refs = None; + + let action = reconcile(Arc::new(route), ctx) + .await + .expect("skipping is not a failure"); + + assert_eq!(action, Action::await_change(), "there is nothing to requeue for"); + assert!( + journal.requests().is_empty(), + "a route naming no Gateway is not this operator's to write to" + ); + } + + #[tokio::test] + async fn test_a_route_naming_an_unknown_gateway_is_left_alone() { + let (ctx, journal) = testing::fake_context(vec![], testing::Cached::default()); + + reconcile(Arc::new(parented_route(parent_ref(None))), ctx) + .await + .expect("a missing Gateway is not this controller's failure"); + + assert!( + journal.matching("/status").is_empty(), + "writing a rejection for a Gateway that may simply not exist yet would fight whichever \ + controller does own it once it appears" + ); + } + + #[tokio::test] + async fn test_a_route_naming_another_controllers_gateway_is_left_alone() { + let (ctx, journal) = testing::fake_context( + vec![gateway_response(), foreign_class_response()], + testing::Cached::default(), + ); + + reconcile(Arc::new(parented_route(parent_ref(None))), ctx) + .await + .expect("skipping is not a failure"); + + assert!( + journal.matching("/status").is_empty(), + "two controllers writing the same route's status would each undo the other" + ); + } + + #[tokio::test] + async fn test_an_unknown_section_name_is_rejected() { + let (ctx, journal) = testing::fake_context( + vec![gateway_response(), owned_class_response(), route_response()], + testing::Cached::default(), + ); + + reconcile(Arc::new(parented_route(parent_ref(Some("nope")))), ctx) + .await + .expect("a rejection is a clean outcome"); + + assert_eq!( + rejection_reason(&journal), + Some("NoMatchingParent".to_owned()), + "a sectionName no listener answers to has to say so, or the author is left guessing" + ); + } + + #[tokio::test] + async fn test_a_hostname_that_intersects_nothing_is_rejected() { + let (ctx, journal) = testing::fake_context( + vec![hostname_gateway_response(), owned_class_response(), route_response()], + testing::Cached::default(), + ); + let mut route = parented_route(parent_ref(None)); + route.spec.hostnames = Some(vec!["other.example.com".to_owned()]); + + reconcile(Arc::new(route), ctx) + .await + .expect("a rejection is a clean outcome"); + + assert_eq!( + rejection_reason(&journal), + Some("NoMatchingListenerHostname".to_owned()), + "a route whose hostnames miss every listener serves nothing, and the status is the \ + only place that is visible" + ); + } + + #[tokio::test] + async fn test_an_acceptable_route_is_left_for_the_gateway_controller() { + let (ctx, journal) = testing::fake_context( + vec![gateway_response(), owned_class_response(), route_response()], + testing::Cached::default(), + ); + + reconcile(Arc::new(parented_route(parent_ref(None))), ctx) + .await + .expect("an acceptable route is not a failure"); + + assert!( + journal.matching("/status").is_empty(), + "acceptance waits on the data-plane rollout, which only the Gateway controller knows \ + about; writing Accepted here would invite traffic to a stale proxy" + ); + } + + #[tokio::test] + async fn test_error_policy_requeues_rather_than_dropping_the_route() { + let (ctx, _) = testing::fake_context(vec![], testing::Cached::default()); + + let action = error_policy( + Arc::new(route(&[])), + &OperatorError::MissingObjectKey(".metadata.uid"), + ctx, + ); + + assert_eq!( + action, + Action::requeue(Duration::from_secs(30)), + "a route left without a status is indistinguishable from one nobody owns" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Returns the `Accepted` reason from the rejection that was written. + fn rejection_reason(journal: &testing::Journal) -> Option { + journal + .matching("/status") + .pop()? + .body? + .pointer("/status/parents/0/conditions")? + .as_array()? + .iter() + .find(|c| c["type"] == "Accepted")? + .get("reason")? + .as_str() + .map(str::to_owned) + } + + /// Builds a route naming the test Gateway through `parent`. + fn parented_route(parent: HttpRouteParentRefs) -> HTTPRoute { + let mut route = route(&[]); + route.spec.parent_refs = Some(vec![parent]); + route + } + + /// The Gateway the fake API server hands back, one plain listener. + fn gateway_response() -> testing::Canned { + testing::Canned::ok("/gateways/gw", gateway_json(&serde_json::Value::Null)) + } + + /// The same Gateway, with a hostname on its listener. + fn hostname_gateway_response() -> testing::Canned { + testing::Canned::ok("/gateways/gw", gateway_json(&serde_json::json!("foo.example.com"))) + } + + /// Builds the Gateway body, optionally constraining the hostname. + fn gateway_json(hostname: &serde_json::Value) -> serde_json::Value { + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "Gateway", + "metadata": { "name": "gw", "namespace": "apps" }, + "spec": { + "gatewayClassName": "praxis", + "listeners": [{ + "name": "http", + "port": 80, + "protocol": "HTTP", + "hostname": hostname, + }], + }, + }) + } + + /// A `GatewayClass` this operator owns. + fn owned_class_response() -> testing::Canned { + class_response(CONTROLLER_NAME) + } + + /// A `GatewayClass` belonging to somebody else. + fn foreign_class_response() -> testing::Canned { + class_response("example.com/other") + } + + /// Builds a `GatewayClass` body naming `controller`. + fn class_response(controller: &str) -> testing::Canned { + testing::Canned::ok( + "/gatewayclasses/praxis", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "GatewayClass", + "metadata": { "name": "praxis" }, + "spec": { "controllerName": controller }, + }), + ) + } + + /// The object the API server hands back from a route status apply. + fn route_response() -> testing::Canned { + testing::Canned::ok( + "/httproutes/route", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { "name": "route", "namespace": "apps" }, + "spec": {}, + }), + ) + } } From 43332b6fe1007057a1bd5fe99dec3d4cb46c40e5 Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:41:40 -0400 Subject: [PATCH 48/51] fix: raise the coverage floor to 95% Signed-off-by: Shane Utt --- Makefile | 2 +- src/controller/route_parent_status.rs | 188 +++++++++++++++++++++++++- 2 files changed, 188 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 1d188c3..9aec22b 100644 --- a/Makefile +++ b/Makefile @@ -60,7 +60,7 @@ clean: cargo clean coverage-check: - cargo llvm-cov --fail-under-lines 80 + cargo llvm-cov --fail-under-lines 95 # --------------------------------------------------------------------------- # Test diff --git a/src/controller/route_parent_status.rs b/src/controller/route_parent_status.rs index 5d6eef1..abfc859 100644 --- a/src/controller/route_parent_status.rs +++ b/src/controller/route_parent_status.rs @@ -126,8 +126,11 @@ fn validation_conditions(validation: &route_validation::RouteValidation, generat #[cfg(test)] mod tests { + use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta; + use serde_json::Value; + use super::*; - use crate::{controller::fixtures::regex_route, gateway_api::route_validation}; + use crate::{controller::fixtures::regex_route, gateway_api::route_validation, testing}; #[test] fn test_validation_conditions_accepts_a_supported_route() { @@ -167,4 +170,187 @@ mod tests { ); assert_eq!(conds[1].status, "True", "PartiallyInvalid should be True"); } + + // ----------------------------------------------------------------------- + // Status Writing + // ----------------------------------------------------------------------- + + #[tokio::test] + async fn test_an_attached_route_is_reported_accepted() { + let (client, journal) = testing::fake_client(vec![route_response()]); + let route = attached_route(); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + update_route_parent_statuses(&client, &gateway(), &attached, &[]) + .await + .expect("the patch is answered"); + + let parent = written_parent(&journal).expect("an attached route should get a status entry"); + assert_eq!( + parent.pointer("/parentRef/name").and_then(Value::as_str), + Some("gw"), + "the entry has to name the parent it reports on, or it belongs to nobody" + ); + assert_eq!( + parent.pointer("/conditions/0/status").and_then(Value::as_str), + Some("True"), + "the Gateway controller only calls this once the data plane has caught up" + ); + } + + #[tokio::test] + async fn test_a_ref_naming_another_gateway_is_skipped() { + let (client, journal) = testing::fake_client(vec![route_response()]); + let mut route = attached_route(); + route.spec.parent_refs = Some(vec![HttpRouteParentRefs { + name: "other-gw".to_owned(), + ..Default::default() + }]); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + update_route_parent_statuses(&client, &gateway(), &attached, &[]) + .await + .expect("skipping is not a failure"); + + assert!( + journal.matching("/status").is_empty(), + "this Gateway has no business reporting on a ref that names a different one" + ); + } + + #[tokio::test] + async fn test_a_route_with_no_parent_refs_is_skipped() { + let (client, journal) = testing::fake_client(vec![route_response()]); + let mut route = attached_route(); + route.spec.parent_refs = None; + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + update_route_parent_statuses(&client, &gateway(), &attached, &[]) + .await + .expect("skipping is not a failure"); + + assert!( + journal.matching("/status").is_empty(), + "there is no parent to report on" + ); + } + + #[tokio::test] + async fn test_a_route_this_operator_cannot_express_is_reported_unaccepted() { + let (client, journal) = testing::fake_client(vec![route_response()]); + let mut route = attached_route(); + route.spec.rules = regex_route(1).spec.rules; + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + update_route_parent_statuses(&client, &gateway(), &attached, &[]) + .await + .expect("the patch is answered"); + + let parent = written_parent(&journal).expect("a rejected route still gets a status entry"); + assert_eq!( + parent.pointer("/conditions/0/reason").and_then(Value::as_str), + Some("UnsupportedValue"), + "silently dropping a rule the operator cannot express would send traffic the author \ + never asked for, so the refusal has to be on the route" + ); + } + + #[tokio::test] + async fn test_a_refused_patch_fails_the_update() { + let (client, _) = testing::failing_client(); + let route = attached_route(); + let attached = vec![AttachedRoute { + route: &route, + section_names: vec![None], + }]; + + let error = update_route_parent_statuses(&client, &gateway(), &attached, &[]) + .await + .expect_err("a 500 is not a written status"); + + assert!( + matches!(error, crate::error::OperatorError::Kube(_)), + "the Gateway reconcile has to retry, or the route stays unaccepted with nothing \ + scheduled to fix it: {error}" + ); + } + + // ----------------------------------------------------------------------- + // Test Utilities + // ----------------------------------------------------------------------- + + /// Returns the first parent entry of the status that was written. + fn written_parent(journal: &testing::Journal) -> Option { + journal + .matching("/status") + .pop()? + .body? + .pointer("/status/parents/0") + .cloned() + } + + /// Builds the Gateway the route attaches to. + fn gateway() -> Gateway { + use gateway_api::gateways::GatewaySpec; + + Gateway { + metadata: ObjectMeta { + name: Some("gw".to_owned()), + namespace: Some("apps".to_owned()), + ..Default::default() + }, + spec: GatewaySpec { + gateway_class_name: "praxis".to_owned(), + listeners: vec![], + ..Default::default() + }, + status: None, + } + } + + /// Builds a route naming that Gateway as its parent. + fn attached_route() -> HTTPRoute { + use gateway_api::httproutes::HttpRouteSpec; + + HTTPRoute { + metadata: ObjectMeta { + name: Some("route".to_owned()), + namespace: Some("apps".to_owned()), + ..Default::default() + }, + spec: HttpRouteSpec { + parent_refs: Some(vec![HttpRouteParentRefs { + name: "gw".to_owned(), + ..Default::default() + }]), + ..Default::default() + }, + status: None, + } + } + + /// The object the API server hands back from a status apply. + fn route_response() -> testing::Canned { + testing::Canned::ok( + "/httproutes/route", + serde_json::json!({ + "apiVersion": "gateway.networking.k8s.io/v1", + "kind": "HTTPRoute", + "metadata": { "name": "route", "namespace": "apps" }, + "spec": {}, + }), + ) + } } From fd1334546d953fbdccecb1bdf7033317bc0f1c2d Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:41:48 -0400 Subject: [PATCH 49/51] fix: retry the network fetches CI depends on Signed-off-by: Shane Utt --- .github/workflows/conformance.yaml | 9 +++-- .github/workflows/integration.yaml | 9 +++-- hack/retry.sh | 54 ++++++++++++++++++++++++++++++ hack/run-conformance.sh | 9 +++-- 4 files changed, 74 insertions(+), 7 deletions(-) create mode 100755 hack/retry.sh diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index 30006e5..e8f2bfc 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -48,11 +48,16 @@ jobs: run: docker build -t ${{ env.OPERATOR_IMAGE }} -f Containerfile . - name: Pull praxis image - run: docker pull ${{ env.PRAXIS_IMAGE }} + run: bash hack/retry.sh docker pull ${{ env.PRAXIS_IMAGE }} - name: Install KIND run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 + # --fail matters as much as the retries: without it an HTTP error + # page is written to ./kind, chmod +x succeeds, and the failure + # surfaces later as an unreadable exec format error. + bash hack/retry.sh curl --fail --location --show-error --silent \ + --retry 3 --retry-all-errors --retry-delay 2 \ + --output ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 41d6da1..30d8d44 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -57,11 +57,16 @@ jobs: run: docker build -t ${{ env.OPERATOR_IMAGE }} -f Containerfile . - name: Pull praxis image - run: docker pull ${{ env.PRAXIS_IMAGE }} + run: bash hack/retry.sh docker pull ${{ env.PRAXIS_IMAGE }} - name: Install KIND run: | - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 + # --fail matters as much as the retries: without it an HTTP error + # page is written to ./kind, chmod +x succeeds, and the failure + # surfaces later as an unreadable exec format error. + bash hack/retry.sh curl --fail --location --show-error --silent \ + --retry 3 --retry-all-errors --retry-delay 2 \ + --output ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64 chmod +x ./kind sudo mv ./kind /usr/local/bin/kind diff --git a/hack/retry.sh b/hack/retry.sh new file mode 100755 index 0000000..9773dbf --- /dev/null +++ b/hack/retry.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +set -euo pipefail + +# --------------------------------------------------------------------------- +# Retry +# --------------------------------------------------------------------------- +# +# Runs a command, retrying with exponential backoff until it succeeds or the +# attempts run out. +# +# Every network fetch in CI is a coin flip that occasionally lands wrong: a +# registry hiccup or a dropped connection to a download host fails a job that +# has nothing to do with the change under test, and the only fix available is +# a human clicking re-run. Wrapping those fetches here turns a transient +# failure into a pause. +# +# Deliberately not used for anything but fetches. Retrying a test suite would +# hide flakiness that is worth seeing, and retrying a mutation would apply it +# twice. +# +# Usage: +# hack/retry.sh curl -fsSL -o ./kind https://example.com/kind +# RETRY_ATTEMPTS=3 hack/retry.sh docker pull image:tag + +ATTEMPTS="${RETRY_ATTEMPTS:-5}" +DELAY="${RETRY_DELAY:-2}" + +if [ "$#" -eq 0 ]; then + echo "usage: $0 [args...]" >&2 + exit 2 +fi + +attempt=1 +while true; do + # Captured on the failure branch rather than read after an `if`, where + # `$?` is the status of the `if` itself and reads 0 however the command + # exited — which would report a permanently failing fetch as a success. + status=0 + "$@" || status="$?" + + if [ "${status}" -eq 0 ]; then + exit 0 + fi + + if [ "${attempt}" -ge "${ATTEMPTS}" ]; then + echo "==> giving up on '$*' after ${attempt} attempts (exit ${status})" >&2 + exit "${status}" + fi + + echo "==> '$*' failed with exit ${status}; retrying in ${DELAY}s (attempt ${attempt}/${ATTEMPTS})" >&2 + sleep "${DELAY}" + attempt=$((attempt + 1)) + DELAY=$((DELAY * 2)) +done diff --git a/hack/run-conformance.sh b/hack/run-conformance.sh index 122ce1a..797fff4 100755 --- a/hack/run-conformance.sh +++ b/hack/run-conformance.sh @@ -34,9 +34,12 @@ trap "rm -f ${KUBECONFIG_FILE}" EXIT if [ ! -d "${GWAPI_DIR}" ]; then echo "==> Cloning gateway-api ${GWAPI_CONFORMANCE_TAG}..." - git clone --depth 1 --branch "${GWAPI_CONFORMANCE_TAG}" \ - https://github.com/kubernetes-sigs/gateway-api.git \ - "${GWAPI_DIR}" + # Retried, and the directory is removed first: a clone that dies partway + # leaves one behind, and the next attempt would fail on it rather than on + # whatever went wrong. + "${SCRIPT_DIR}/retry.sh" bash -c \ + "rm -rf '${GWAPI_DIR}' && git clone --depth 1 --branch '${GWAPI_CONFORMANCE_TAG}' \ + https://github.com/kubernetes-sigs/gateway-api.git '${GWAPI_DIR}'" else echo "==> Using cached gateway-api at ${GWAPI_DIR}" fi From 1a7793d0fab09b18b0ffc59439eae275794656ac Mon Sep 17 00:00:00 2001 From: Shane Utt Date: Fri, 14 Aug 2026 15:42:24 -0400 Subject: [PATCH 50/51] fix: claim the two features that need no data-plane change Signed-off-by: Shane Utt --- src/config/filter_conversion.rs | 25 ++++++++++---- src/controller/gateway.rs | 59 +++++++++++++++++++++++++++++++-- src/controller/gateway_class.rs | 17 ++++++++++ 3 files changed, 93 insertions(+), 8 deletions(-) diff --git a/src/config/filter_conversion.rs b/src/config/filter_conversion.rs index 4d2fa80..2a06385 100644 --- a/src/config/filter_conversion.rs +++ b/src/config/filter_conversion.rs @@ -250,7 +250,20 @@ fn dispatch_filter( } } +/// A rule's traffic predicate, in Praxis `ConditionMatch` form. +/// +/// Empty for a rule that constrains nothing and so matches every +/// request. +/// +/// Filters are chain-level in Praxis, not per-route, so a filter is +/// confined to its own rule's traffic only as precisely as +/// `praxis_core::config::ConditionMatch` allows: path, path prefix, +/// methods and headers. That type has no host field, so two routes +/// sharing a listener and a path but differing only in hostname still +/// share their filters. Narrowing that further needs host matching in +/// the Praxis condition schema. type Predicate = yaml_serde::Mapping; + /// Dispatches a `URLRewrite` filter. /// /// Returns `true` when the hostname rewrite put a `Host` header into @@ -638,7 +651,7 @@ fn build_redirect_location(redirect: &gateway_api::httproutes::HttpRouteRulesFil /// filter. fn emit_conditional_timeout( rule: &HttpRouteRules, - condition: &Option, + condition: &Option, filters: &mut Vec, ) { let Some(timeouts) = &rule.timeouts else { return }; @@ -649,13 +662,13 @@ fn emit_conditional_timeout( return; }; - let mut config = serde_norway::Mapping::new(); + let mut config = yaml_serde::Mapping::new(); config.insert( - serde_norway::Value::String("timeout_ms".to_owned()), - serde_norway::Value::Number(timeout_ms.into()), + yaml_serde::Value::String("timeout_ms".to_owned()), + yaml_serde::Value::Number(timeout_ms.into()), ); - let config = inject_conditions(serde_norway::Value::Mapping(config), condition); + let config = inject_conditions(yaml_serde::Value::Mapping(config), condition); filters.push(PraxisFilterEntry { filter: "timeout".to_owned(), config, @@ -1228,7 +1241,7 @@ mod tests { assert_eq!( filters.first().and_then(|f| f.config.get("timeout_ms")), - Some(&serde_norway::Value::Number(500.into())), + Some(&yaml_serde::Value::Number(500.into())), "Praxis has one timeout to give, and the shorter one is what would fire first anyway" ); } diff --git a/src/controller/gateway.rs b/src/controller/gateway.rs index bf98ef2..ec24cd4 100644 --- a/src/controller/gateway.rs +++ b/src/controller/gateway.rs @@ -228,9 +228,19 @@ fn unsupported_spec_reason(gw: &Gateway) -> Option<(&'static str, &'static str)> None } -/// Checks whether a `Gateway` requests specific addresses. +/// Checks whether a `Gateway` asks for a specific address. +/// +/// An entry carrying no `value` asks for nothing in particular — the +/// Gateway API defines it as "assign an address matching the requested +/// type", which is what the data-plane Service does anyway. Only an +/// entry naming an address is a request this operator cannot honour. fn has_requested_addresses(gw: &Gateway) -> bool { - gw.spec.addresses.as_ref().is_some_and(|a| !a.is_empty()) + gw.spec + .addresses + .as_deref() + .unwrap_or(&[]) + .iter() + .any(|address| address.value.is_some()) } // ----------------------------------------------------------------------------- @@ -556,6 +566,51 @@ mod tests { ); } + #[test] + fn test_an_address_entry_without_a_value_asks_for_nothing() { + let mut gw = Gateway { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: None, + }; + gw.spec.addresses = Some(vec![GatewayAddresses { + r#type: Some("IPAddress".to_owned()), + value: None, + }]); + + assert!( + unsupported_spec_reason(&gw).is_none(), + "the Gateway API reads a valueless entry as `assign an address of this type`, which is \ + what the data-plane Service does regardless — rejecting it refuses a Gateway that \ + asked for nothing this operator cannot give" + ); + } + + #[test] + fn test_one_named_address_rejects_the_whole_gateway() { + let mut gw = Gateway { + metadata: ObjectMeta::default(), + spec: Default::default(), + status: None, + }; + gw.spec.addresses = Some(vec![ + GatewayAddresses { + r#type: Some("IPAddress".to_owned()), + value: None, + }, + GatewayAddresses { + value: Some("192.0.2.1".to_owned()), + ..Default::default() + }, + ]); + + assert!( + unsupported_spec_reason(&gw).is_some(), + "a valueless entry beside a named one does not excuse the named one, which the \ + operator still cannot assign" + ); + } + #[test] fn test_unsupported_spec_reason_ignores_an_empty_address_list() { let mut gw = Gateway { diff --git a/src/controller/gateway_class.rs b/src/controller/gateway_class.rs index 462fe50..38f3451 100644 --- a/src/controller/gateway_class.rs +++ b/src/controller/gateway_class.rs @@ -67,6 +67,21 @@ use crate::{ /// The port features come from `parentRefs[].port`, which the /// operator now resolves to listeners rather than ignoring. /// +/// `HTTPRouteNamedRouteRule` needs no code at all. The suite gating on +/// it only checks that a route carrying `rules[].name` still routes, +/// and that field is already carried and already ignored harmlessly; +/// nothing validates or rejects it. `GatewayAddressEmpty` is nearly as +/// cheap: an address entry with no value asks for an address of the +/// given type rather than a particular one, which is what the +/// data-plane Service provides regardless. +/// +/// `GatewayStaticAddresses` stays absent, despite sitting next to it. +/// That suite hands the Gateway three addresses — invalid, unusable, +/// and usable — and steps through rejecting, accepting, and finally +/// programming with the usable one. Satisfying it needs the operator +/// to bind a requested address on the Service and the cluster to +/// honour that binding, neither of which exists here. +/// /// The two timeout features are claimed with a caveat worth stating. /// Praxis's `timeout` filter compares elapsed time in the response /// phase, so it converts a late response into a 504 but does not abort @@ -90,6 +105,7 @@ use crate::{ /// [`validate_route`]: crate::gateway_api::route_validation::validate_route const SUPPORTED_FEATURES: &[&str] = &[ "Gateway", + "GatewayAddressEmpty", "GatewayInfrastructurePropagation", "GatewayPort8080", "HTTPRoute", @@ -98,6 +114,7 @@ const SUPPORTED_FEATURES: &[&str] = &[ "HTTPRouteBackendTimeout", "HTTPRouteDestinationPortMatching", "HTTPRouteHostRewrite", + "HTTPRouteNamedRouteRule", "HTTPRouteParentRefPort", "HTTPRoutePathRewrite", "HTTPRouteRequestHeaderModification", From 82851480ad5d1e0fafd8a3b864cfa61b202c3fb4 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 14 Aug 2026 21:10:05 +0000 Subject: [PATCH 51/51] ci(deps): Bump the actions group with 4 updates Bumps the actions group with 4 updates: [docker/login-action](https://github.com/docker/login-action), [actions/setup-go](https://github.com/actions/setup-go), [docker/build-push-action](https://github.com/docker/build-push-action) and [taiki-e/install-action](https://github.com/taiki-e/install-action). Updates `docker/login-action` from 4.1.0 to 4.6.0 - [Release notes](https://github.com/docker/login-action/releases) - [Commits](https://github.com/docker/login-action/compare/4907a6ddec9925e35a0a9e82d7399ccc52663121...dbcb813823bdd20940b903addbd779551569679f) Updates `actions/setup-go` from 5.5.0 to 7.0.0 - [Release notes](https://github.com/actions/setup-go/releases) - [Commits](https://github.com/actions/setup-go/compare/d35c59abb061a4a6fb18e82ac0862c26744d6ab5...b7ad1dad31e06c5925ef5d2fc7ad053ef454303e) Updates `docker/build-push-action` from 6.18.0 to 7.3.0 - [Release notes](https://github.com/docker/build-push-action/releases) - [Commits](https://github.com/docker/build-push-action/compare/263435318d21b8e681c14492fe198d362a7d2c83...53b7df96c91f9c12dcc8a07bcb9ccacbed38856a) Updates `taiki-e/install-action` from 2.54.0 to 2.85.11 - [Release notes](https://github.com/taiki-e/install-action/releases) - [Changelog](https://github.com/taiki-e/install-action/blob/main/CHANGELOG.md) - [Commits](https://github.com/taiki-e/install-action/compare/9ba3ac3fd006a70c6e186a683577abc1ccf0ff3a...7f4eb899022d8fe70b20c4f3de697aa85c309026) --- updated-dependencies: - dependency-name: docker/login-action dependency-version: 4.6.0 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions - dependency-name: actions/setup-go dependency-version: 7.0.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: docker/build-push-action dependency-version: 7.3.0 dependency-type: direct:production update-type: version-update:semver-major dependency-group: actions - dependency-name: taiki-e/install-action dependency-version: 2.85.11 dependency-type: direct:production update-type: version-update:semver-minor dependency-group: actions ... Signed-off-by: dependabot[bot] --- .github/workflows/conformance.yaml | 4 ++-- .github/workflows/integration.yaml | 2 +- .github/workflows/release.yaml | 4 ++-- .github/workflows/tests.yaml | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/conformance.yaml b/.github/workflows/conformance.yaml index e8f2bfc..4101fb4 100644 --- a/.github/workflows/conformance.yaml +++ b/.github/workflows/conformance.yaml @@ -38,7 +38,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} @@ -65,7 +65,7 @@ jobs: run: bash hack/setup-kind.sh - name: Install Go - uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5.5.0 + uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 with: go-version: "1.24" cache: false diff --git a/.github/workflows/integration.yaml b/.github/workflows/integration.yaml index 30d8d44..df3faf8 100644 --- a/.github/workflows/integration.yaml +++ b/.github/workflows/integration.yaml @@ -45,7 +45,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.actor }} diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 09d754b..3f17678 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -55,7 +55,7 @@ jobs: - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - name: Login to GHCR - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ${{ env.REGISTRY }} username: ${{ github.actor }} @@ -70,7 +70,7 @@ jobs: echo "tags=${image}:${version},${image}:latest" >> "$GITHUB_OUTPUT" - name: Build and push - uses: docker/build-push-action@263435318d21b8e681c14492fe198d362a7d2c83 # v6.18.0 + uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0 with: context: . file: Containerfile diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index 41e99f6..569a687 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -80,7 +80,7 @@ jobs: - uses: praxis-proxy/conventions/.github/actions/setup-rust@7e1e8d97c2dc820d24b31f9a65b119c4d0e5342c # v0.1.0 - name: Install cargo-llvm-cov - uses: taiki-e/install-action@9ba3ac3fd006a70c6e186a683577abc1ccf0ff3a # v2.62.44 + uses: taiki-e/install-action@7f4eb899022d8fe70b20c4f3de697aa85c309026 # v2.62.44 with: tool: cargo-llvm-cov