From a8ddd7deab9d74840a3c2b6dd01570ca5698212b Mon Sep 17 00:00:00 2001 From: Robbie McKinstry Date: Wed, 1 Apr 2026 13:03:11 -0400 Subject: [PATCH 1/3] Align control plane config schema with proxy-core for wire compatibility The control plane's GatewayConfig and proxy-core's ProxyConfig had schema drift after the dataplane migration. This adds the missing fields (TlsConfig.inline_certificates, BackendRef.protocol/BackendProtocol) and cross-deserialization roundtrip tests to ensure both directions work. --- Cargo.lock | 1 + crates/controlplane/Cargo.toml | 3 + crates/controlplane/src/controller/config.rs | 414 +++++++++++++++++++ 3 files changed, 418 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index 00130a7..40b1885 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1606,6 +1606,7 @@ dependencies = [ "miette", "nutype", "pretty_assertions", + "proxy-core", "schemars", "serde", "serde_json", diff --git a/crates/controlplane/Cargo.toml b/crates/controlplane/Cargo.toml index c1e00b0..74038b5 100644 --- a/crates/controlplane/Cargo.toml +++ b/crates/controlplane/Cargo.toml @@ -27,3 +27,6 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true futures = "0.3" + +[dev-dependencies] +proxy-core = { path = "../proxy-core" } diff --git a/crates/controlplane/src/controller/config.rs b/crates/controlplane/src/controller/config.rs index 23a2ffc..fb78418 100644 --- a/crates/controlplane/src/controller/config.rs +++ b/crates/controlplane/src/controller/config.rs @@ -174,6 +174,12 @@ pub struct TlsConfig { pub mode: TlsMode, /// Certificate references pub certificates: Vec, + /// Inline certificates with PEM data for direct use by the proxy. + /// + /// The control plane resolves `CertificateRef` secrets and populates + /// these inline certificates before writing the config to the ConfigMap. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub inline_certificates: Vec, } /// TLS termination mode @@ -193,6 +199,22 @@ pub struct CertificateRef { pub name: String, } +/// Inline TLS certificate with actual PEM data. +/// +/// The control plane resolves `CertificateRef` Kubernetes Secret references +/// and populates these inline certificates so the data plane can build a +/// TLS configuration directly without Kubernetes API access. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct InlineCertificate { + /// PEM-encoded certificate chain (leaf first, then intermediates). + pub cert_pem: String, + /// PEM-encoded private key (PKCS#1, PKCS#8, or SEC1). + pub key_pem: String, + /// Optional hostnames this certificate covers (for SNI routing). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub hostnames: Vec, +} + /// Configuration for an HTTP route #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct RouteConfig { @@ -402,6 +424,20 @@ pub enum PathModifier { ReplacePrefixMatch { value: String }, } +/// Protocol used to connect to a backend. +/// +/// Defaults to `Http1` for backward compatibility. When set to `H2c`, the +/// proxy connects to the backend using HTTP/2 prior knowledge (cleartext h2c). +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "lowercase")] +pub enum BackendProtocol { + /// HTTP/1.1 (default). + #[default] + Http1, + /// HTTP/2 cleartext (h2c) — prior knowledge mode. + H2c, +} + /// Reference to a backend service #[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct BackendRef { @@ -414,6 +450,9 @@ pub struct BackendRef { /// Weight for load balancing (default: 1) #[serde(default = "default_weight")] pub weight: u32, + /// Protocol to use when connecting to this backend + #[serde(default)] + pub protocol: BackendProtocol, } fn default_weight() -> u32 { @@ -428,6 +467,7 @@ impl BackendRef { name: name.into(), port, weight: 1, + protocol: BackendProtocol::default(), } } @@ -593,4 +633,378 @@ mod tests { let deserialized: RouteFilter = serde_json::from_str(&json).unwrap(); assert_eq!(filter, deserialized); } + + // -- Cross-deserialization tests (control plane ↔ proxy-core) ------------- + + /// Serialize from control plane GatewayConfig, deserialize into proxy-core ProxyConfig. + #[test] + fn controlplane_to_proxy_core_basic() { + let mut config = GatewayConfig::new("default", "my-gateway"); + config.add_listener(ListenerConfig::http( + "http", + 80, + Some("example.com".to_string()), + )); + config.add_route(RouteConfig { + name: "my-route".to_string(), + namespace: "default".to_string(), + hostnames: vec!["example.com".to_string()], + attached_listeners: vec!["http".to_string()], + rules: vec![RouteRule { + name: None, + matches: vec![RouteMatch { + path: Some(PathMatch { + match_type: PathMatchType::PathPrefix, + value: "/api".to_string(), + }), + ..Default::default() + }], + filters: vec![], + backends: vec![BackendRef::new("default", "my-service", 8080)], + timeout: None, + }], + }); + + let json = config.to_json().unwrap(); + let proxy_config: proxy_core::config::ProxyConfig = + serde_json::from_str(&json).expect("proxy-core should deserialize control plane JSON"); + + assert_eq!(proxy_config.version, "v1"); + assert_eq!(proxy_config.gateway.name, "my-gateway"); + assert_eq!(proxy_config.listeners.len(), 1); + assert_eq!(proxy_config.routes.len(), 1); + assert_eq!( + proxy_config.routes[0].rules[0].backends[0].name, + "my-service" + ); + // Protocol defaults to Http1 when not specified + assert_eq!( + proxy_config.routes[0].rules[0].backends[0].protocol, + proxy_core::config::BackendProtocol::Http1 + ); + } + + /// Serialize from proxy-core ProxyConfig, deserialize into control plane GatewayConfig. + #[test] + fn proxy_core_to_controlplane_basic() { + let proxy_config = proxy_core::config::ProxyConfig { + version: "v1".to_string(), + gateway: proxy_core::config::GatewayRef { + namespace: "default".to_string(), + name: "my-gateway".to_string(), + }, + listeners: vec![proxy_core::config::ListenerConfig::http( + "http", + 80, + Some("example.com".to_string()), + )], + routes: vec![proxy_core::config::RouteConfig { + name: "my-route".to_string(), + namespace: "default".to_string(), + hostnames: vec!["example.com".to_string()], + attached_listeners: vec!["http".to_string()], + rules: vec![proxy_core::config::RouteRule { + name: None, + matches: vec![proxy_core::config::RouteMatch { + path: Some(proxy_core::config::PathMatch { + match_type: proxy_core::config::PathMatchType::PathPrefix, + value: "/api".to_string(), + }), + ..Default::default() + }], + filters: vec![], + backends: vec![proxy_core::config::Backend::new( + "default", + "my-service", + 8080, + )], + timeout: None, + }], + }], + }; + + let json = proxy_config.to_json().unwrap(); + let gw_config: GatewayConfig = + serde_json::from_str(&json).expect("control plane should deserialize proxy-core JSON"); + + assert_eq!(gw_config.version, "v1"); + assert_eq!(gw_config.gateway.name, "my-gateway"); + assert_eq!(gw_config.listeners.len(), 1); + assert_eq!(gw_config.routes.len(), 1); + assert_eq!(gw_config.routes[0].rules[0].backends[0].name, "my-service"); + assert_eq!( + gw_config.routes[0].rules[0].backends[0].protocol, + BackendProtocol::Http1 + ); + } + + /// Cross-deserialization with TLS and inline certificates. + #[test] + fn controlplane_to_proxy_core_tls_with_inline_certs() { + let mut config = GatewayConfig::new("default", "secure-gw"); + config.add_listener(ListenerConfig::https( + "https", + 443, + Some("secure.example.com".to_string()), + TlsConfig { + mode: TlsMode::Terminate, + certificates: vec![CertificateRef { + namespace: "default".to_string(), + name: "tls-secret".to_string(), + }], + inline_certificates: vec![InlineCertificate { + cert_pem: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----" + .to_string(), + key_pem: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----" + .to_string(), + hostnames: vec!["secure.example.com".to_string()], + }], + }, + )); + + let json = config.to_json().unwrap(); + let proxy_config: proxy_core::config::ProxyConfig = + serde_json::from_str(&json).expect("proxy-core should deserialize TLS config"); + + let tls = proxy_config.listeners[0].tls.as_ref().unwrap(); + assert_eq!(tls.mode, proxy_core::config::TlsMode::Terminate); + assert_eq!(tls.certificates.len(), 1); + assert_eq!(tls.inline_certificates.len(), 1); + assert_eq!( + tls.inline_certificates[0].hostnames, + vec!["secure.example.com"] + ); + } + + /// Cross-deserialization with TLS but no inline certificates (field omitted). + #[test] + fn controlplane_to_proxy_core_tls_without_inline_certs() { + let mut config = GatewayConfig::new("default", "secure-gw"); + config.add_listener(ListenerConfig::https( + "https", + 443, + None, + TlsConfig { + mode: TlsMode::Terminate, + certificates: vec![CertificateRef { + namespace: "default".to_string(), + name: "tls-secret".to_string(), + }], + inline_certificates: vec![], + }, + )); + + let json = config.to_json().unwrap(); + // inline_certificates should be omitted from JSON (skip_serializing_if) + assert!( + !json.contains("inline_certificates"), + "empty inline_certificates should be omitted from JSON" + ); + + let proxy_config: proxy_core::config::ProxyConfig = serde_json::from_str(&json) + .expect("proxy-core should handle missing inline_certificates"); + + let tls = proxy_config.listeners[0].tls.as_ref().unwrap(); + assert!(tls.inline_certificates.is_empty()); + } + + /// Cross-deserialization with backend protocol field. + #[test] + fn controlplane_to_proxy_core_backend_protocol() { + let mut config = GatewayConfig::new("default", "gw"); + config.add_listener(ListenerConfig::http("http", 80, None)); + config.add_route(RouteConfig { + name: "route".to_string(), + namespace: "default".to_string(), + hostnames: vec![], + attached_listeners: vec!["http".to_string()], + rules: vec![RouteRule { + name: None, + matches: vec![RouteMatch::default()], + filters: vec![], + backends: vec![BackendRef::new("default", "http1-svc", 8080), { + let mut b = BackendRef::new("default", "h2c-svc", 9090); + b.protocol = BackendProtocol::H2c; + b + }], + timeout: None, + }], + }); + + let json = config.to_json().unwrap(); + let proxy_config: proxy_core::config::ProxyConfig = + serde_json::from_str(&json).expect("proxy-core should deserialize backend protocol"); + + let backends = &proxy_config.routes[0].rules[0].backends; + assert_eq!( + backends[0].protocol, + proxy_core::config::BackendProtocol::Http1 + ); + assert_eq!( + backends[1].protocol, + proxy_core::config::BackendProtocol::H2c + ); + } + + /// Cross-deserialization of all filter types. + #[test] + fn controlplane_to_proxy_core_all_filters() { + let filters = vec![ + RouteFilter::RequestHeaderModifier { + add: vec![HeaderValue { + name: "X-Added".to_string(), + value: "yes".to_string(), + }], + set: vec![], + remove: vec!["X-Remove".to_string()], + }, + RouteFilter::ResponseHeaderModifier { + add: vec![], + set: vec![HeaderValue { + name: "Cache-Control".to_string(), + value: "no-cache".to_string(), + }], + remove: vec![], + }, + RouteFilter::RequestRedirect { + scheme: Some("https".to_string()), + hostname: Some("example.com".to_string()), + port: Some(443), + path: Some(PathModifier::ReplaceFullPath { + value: "/new".to_string(), + }), + status_code: Some(301), + }, + RouteFilter::URLRewrite { + hostname: Some("internal.example.com".to_string()), + path: Some(PathModifier::ReplacePrefixMatch { + value: "/v2".to_string(), + }), + }, + RouteFilter::RequestMirror { + backend: BackendRef::new("default", "mirror-svc", 9090), + percent: Some(50), + }, + ]; + + for filter in &filters { + let json = serde_json::to_string(filter).unwrap(); + let proxy_filter: proxy_core::config::Filter = serde_json::from_str(&json) + .unwrap_or_else(|e| { + panic!("proxy-core should deserialize filter: {json}\nerror: {e}") + }); + + // Re-serialize from proxy-core and deserialize back into control plane + let re_json = serde_json::to_string(&proxy_filter).unwrap(); + let _roundtrip: RouteFilter = serde_json::from_str(&re_json).unwrap_or_else(|e| { + panic!("control plane should deserialize proxy-core filter: {re_json}\nerror: {e}") + }); + } + } + + /// Full roundtrip: control plane → JSON → proxy-core → JSON → control plane. + #[test] + fn full_roundtrip_controlplane_proxy_core_controlplane() { + let mut config = GatewayConfig::new("production", "main-gw"); + config.add_listener(ListenerConfig::http( + "http", + 80, + Some("*.example.com".to_string()), + )); + config.add_listener(ListenerConfig::https( + "https", + 443, + Some("secure.example.com".to_string()), + TlsConfig { + mode: TlsMode::Terminate, + certificates: vec![CertificateRef { + namespace: "production".to_string(), + name: "wildcard-cert".to_string(), + }], + inline_certificates: vec![InlineCertificate { + cert_pem: "CERT_PEM_DATA".to_string(), + key_pem: "KEY_PEM_DATA".to_string(), + hostnames: vec!["secure.example.com".to_string()], + }], + }, + )); + config.add_route(RouteConfig { + name: "api-route".to_string(), + namespace: "production".to_string(), + hostnames: vec!["api.example.com".to_string()], + attached_listeners: vec!["http".to_string(), "https".to_string()], + rules: vec![ + RouteRule { + name: Some("api-v1".to_string()), + matches: vec![RouteMatch { + path: Some(PathMatch { + match_type: PathMatchType::PathPrefix, + value: "/v1".to_string(), + }), + headers: vec![HeaderMatch { + name: "X-Version".to_string(), + match_type: HeaderMatchType::Exact, + value: "1".to_string(), + }], + query_params: vec![QueryParamMatch { + name: "debug".to_string(), + match_type: QueryParamMatchType::Exact, + value: "true".to_string(), + }], + method: Some("GET".to_string()), + }], + filters: vec![RouteFilter::RequestHeaderModifier { + add: vec![HeaderValue { + name: "X-Proxy".to_string(), + value: "multiway".to_string(), + }], + set: vec![], + remove: vec![], + }], + backends: vec![ + BackendRef::new("production", "api-svc", 8080).with_weight(3), + { + let mut b = + BackendRef::new("production", "api-svc-h2", 9090).with_weight(1); + b.protocol = BackendProtocol::H2c; + b + }, + ], + timeout: Some(TimeoutConfig { + request: Some(60.0), + backend_request: Some(30.0), + }), + }, + RouteRule { + name: None, + matches: vec![RouteMatch { + path: Some(PathMatch { + match_type: PathMatchType::Exact, + value: "/healthz".to_string(), + }), + ..Default::default() + }], + filters: vec![], + backends: vec![BackendRef::new("production", "health-svc", 8081)], + timeout: None, + }, + ], + }); + + // Control plane → JSON + let cp_json = config.to_json().unwrap(); + + // JSON → proxy-core + let proxy_config: proxy_core::config::ProxyConfig = + serde_json::from_str(&cp_json).expect("proxy-core should deserialize full config"); + + // proxy-core → JSON + let pc_json = proxy_config.to_json().unwrap(); + + // JSON → control plane + let roundtrip: GatewayConfig = + serde_json::from_str(&pc_json).expect("control plane should deserialize roundtrip"); + + assert_eq!(config, roundtrip); + } } From 31cc3af14bee69205845a42c45bc6833b750f694 Mon Sep 17 00:00:00 2001 From: Robbie McKinstry Date: Wed, 1 Apr 2026 13:14:47 -0400 Subject: [PATCH 2/3] Fix build against kopium-generated CRD bindings (standard channel) The CI pipeline regenerates gateway-crds via kopium before building. The standard-channel CRDs differ from stale local bindings: - GatewayClassStatus has no supportedFeatures field (experimental only) - HttpRouteStatusParents.conditions is Option> - HttpRouteRules has no name field (experimental only) --- .../src/controller/gateway_class.rs | 5 ----- .../controlplane/src/controller/httproute.rs | 2 -- crates/controlplane/src/core/reconcile.rs | 20 ++++++------------- crates/controlplane/src/core/validate.rs | 1 - 4 files changed, 6 insertions(+), 22 deletions(-) diff --git a/crates/controlplane/src/controller/gateway_class.rs b/crates/controlplane/src/controller/gateway_class.rs index 11125f4..9365fb3 100644 --- a/crates/controlplane/src/controller/gateway_class.rs +++ b/crates/controlplane/src/controller/gateway_class.rs @@ -193,7 +193,6 @@ mod tests { reason: "Accepted".to_string(), message: "Accepted".to_string(), }]), - supported_features: None, }); assert!(is_gateway_class_accepted(&gc)); } @@ -211,7 +210,6 @@ mod tests { reason: "InvalidConfiguration".to_string(), message: "Configuration is invalid".to_string(), }]), - supported_features: None, }); assert!(!is_gateway_class_accepted(&gc)); } @@ -230,7 +228,6 @@ mod tests { reason: "Accepted".to_string(), message: "Accepted".to_string(), }]), - supported_features: None, }); // Should be accepted when observedGeneration matches resource generation @@ -243,7 +240,6 @@ mod tests { let mut gc = create_test_gateway_class(CONTROLLER_NAME); gc.status = Some(GatewayClassStatus { conditions: Some(vec![]), - supported_features: None, }); assert!(!is_gateway_class_accepted(&gc)); } @@ -261,7 +257,6 @@ mod tests { reason: "Something".to_string(), message: "Something".to_string(), }]), - supported_features: None, }); assert!(!is_gateway_class_accepted(&gc)); } diff --git a/crates/controlplane/src/controller/httproute.rs b/crates/controlplane/src/controller/httproute.rs index cf50a44..495294a 100644 --- a/crates/controlplane/src/controller/httproute.rs +++ b/crates/controlplane/src/controller/httproute.rs @@ -902,7 +902,6 @@ mod tests { weight: None, filters: None, }]), - name: None, timeouts: None, }; @@ -928,7 +927,6 @@ mod tests { weight: None, filters: None, }]), - name: None, timeouts: Some(HttpRouteRulesTimeouts { request: Some("30s".to_string()), backend_request: Some("10s".to_string()), diff --git a/crates/controlplane/src/core/reconcile.rs b/crates/controlplane/src/core/reconcile.rs index 9f58687..51aecf0 100644 --- a/crates/controlplane/src/core/reconcile.rs +++ b/crates/controlplane/src/core/reconcile.rs @@ -111,7 +111,6 @@ fn build_gateway_class_status( GatewayClassStatus { conditions: Some(vec![condition]), - supported_features: None, } } @@ -747,7 +746,7 @@ fn build_parent_status( port: parent_ref.port, section_name: parent_ref.section_name.clone(), }, - conditions: vec![ + conditions: Some(vec![ Condition { type_: "Accepted".to_string(), status: status_value.to_string(), @@ -764,7 +763,7 @@ fn build_parent_status( reason: if accepted { "ResolvedRefs" } else { reason }.to_string(), message: message.to_string(), }, - ], + ]), } } @@ -817,7 +816,6 @@ mod tests { reason: "Accepted".to_string(), message: "Accepted".to_string(), }]), - supported_features: None, }), } } @@ -1085,11 +1083,8 @@ mod tests { .unwrap(); assert_eq!(status.parents.len(), 1); - let accepted = status.parents[0] - .conditions - .iter() - .find(|c| c.type_ == "Accepted") - .unwrap(); + let conditions = status.parents[0].conditions.as_ref().unwrap(); + let accepted = conditions.iter().find(|c| c.type_ == "Accepted").unwrap(); assert_eq!(accepted.status, "False"); assert_eq!(accepted.reason, "NoMatchingParent"); } @@ -1115,11 +1110,8 @@ mod tests { .unwrap(); assert_eq!(status.parents.len(), 1); - let accepted = status.parents[0] - .conditions - .iter() - .find(|c| c.type_ == "Accepted") - .unwrap(); + let conditions = status.parents[0].conditions.as_ref().unwrap(); + let accepted = conditions.iter().find(|c| c.type_ == "Accepted").unwrap(); assert_eq!(accepted.status, "True"); assert_eq!(accepted.reason, "Accepted"); diff --git a/crates/controlplane/src/core/validate.rs b/crates/controlplane/src/core/validate.rs index d5bb300..454418b 100644 --- a/crates/controlplane/src/core/validate.rs +++ b/crates/controlplane/src/core/validate.rs @@ -658,7 +658,6 @@ mod tests { message: "Accepted".to_string(), }, ]), - supported_features: None, }), } } From b79b91d37c327679608adc2b8b2663179489f515 Mon Sep 17 00:00:00 2001 From: Robbie McKinstry Date: Wed, 1 Apr 2026 13:23:25 -0400 Subject: [PATCH 3/3] Add test to dataplane crate so cargo nextest does not fail on zero tests --- crates/dataplane/src/main.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/dataplane/src/main.rs b/crates/dataplane/src/main.rs index d9363dc..cc43555 100644 --- a/crates/dataplane/src/main.rs +++ b/crates/dataplane/src/main.rs @@ -35,6 +35,16 @@ use proxy_core::server::ProxyServer; /// ConfigMap key for the gateway configuration (matches control plane) const CONFIG_KEY: &str = "config.json"; +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_key_matches_control_plane() { + assert_eq!(CONFIG_KEY, "config.json"); + } +} + /// Multiway Gateway Data Plane #[derive(Parser, Debug)] #[command(name = "multiway-dataplane")]