dwara_core/k8s_gateway/
mod.rs

1//! Kubernetes Gateway API translator (DW-064).
2//!
3//! Translates Gateway API v1 resources (Gateway, HTTPRoute, GatewayClass)
4//! into dwara's config model. This is the core translation layer; the
5//! kube-rs controller wiring (watching the API server via informers) lives
6//! in [`controller`] and the Ingress translator in [`ingress`].
7//!
8//! ## Gateway API v1
9//!
10//! The Gateway API v1 standard channel (v1.5) includes:
11//! - `GatewayClass`: the kind of Gateway (dwara is a controller).
12//! - `Gateway`: a listener configuration (ports, TLS, hostname).
13//! - `HTTPRoute`: HTTP routing rules (matches, filters, backends).
14//!
15//! ## Translation
16//!
17//! The translator maps:
18//! - `Gateway` -> `Listener` (one per Gateway listener).
19//! - `HTTPRoute` -> `Route` (one per HTTPRoute rule match; a rule with
20//!   multiple matches expands to one dwara route per match, since dwara
21//!   routes carry a single match).
22//! - `HTTPRoute` backendRefs -> `Service` + `Upstream` + `Endpoint`.
23//! - `HTTPRoute` filters -> dwara route actions/transforms:
24//!   - `RequestRedirect` -> `RouteAction::Redirect`.
25//!   - `RequestHeaderModifier` -> `Transforms.request.headers`.
26//!   - `ResponseHeaderModifier` -> `Transforms.response.headers`.
27//!   - `URLRewrite` -> `RouteAction::Proxy.rewrite` (hostname/path).
28//!   - Unsupported filters -> a warning (never silently dropped).
29//!
30//! ## Feature gate
31//!
32//! None: the module compiles into every build (no `k8s` cargo
33//! feature).
34
35use std::collections::HashMap;
36
37use serde::{Deserialize, Serialize};
38
39use crate::config::{
40    transforms::{HeaderOps, RequestTransforms, ResponseTransforms, Transforms as DwaraTransforms},
41    Endpoint as DwaraEndpoint, Gateway as DwaraGateway, Listener as DwaraListener,
42    ListenerProtocol, ListenerTls, LoadBalancer, NameValueMatch, PathMatch, PathMatchKind,
43    PathRewrite, Route as DwaraRoute, RouteAction, RouteMatch, Service as DwaraService,
44    TlsCertificate, TlsMode, Upstream as DwaraUpstream, UpstreamProtocol, ZeroRttPolicy,
45};
46
47pub mod controller;
48pub mod ingress;
49
50// ---------------------------------------------------------------------------
51// Gateway API v1 resource types (subset for translation)
52// ---------------------------------------------------------------------------
53
54/// A GatewayClass resource.
55#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
56pub struct GatewayClass {
57    pub api_version: String,
58    pub kind: String,
59    pub metadata: ObjectMeta,
60    pub spec: GatewayClassSpec,
61}
62
63/// GatewayClass spec.
64#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
65pub struct GatewayClassSpec {
66    /// The controller name (e.g. "shristilabs.com/dwara").
67    pub controller: String,
68}
69
70/// A Gateway resource.
71#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
72pub struct Gateway {
73    pub api_version: String,
74    pub kind: String,
75    pub metadata: ObjectMeta,
76    pub spec: GatewaySpec,
77}
78
79/// Gateway spec.
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct GatewaySpec {
82    /// The GatewayClass name.
83    pub gateway_class_name: String,
84    /// The listeners on this Gateway.
85    pub listeners: Vec<GatewayListener>,
86}
87
88/// A Gateway listener.
89#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
90pub struct GatewayListener {
91    /// The listener name (unique within the Gateway).
92    pub name: String,
93    /// The port.
94    pub port: u16,
95    /// The protocol: "HTTP", "HTTPS", "TLS".
96    pub protocol: String,
97    /// The hostname (optional; empty = wildcard).
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub hostname: Option<String>,
100    /// TLS configuration (for HTTPS/TLS).
101    #[serde(default, skip_serializing_if = "Option::is_none")]
102    pub tls: Option<ListenerTlsConfig>,
103}
104
105/// Listener TLS configuration.
106#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
107pub struct ListenerTlsConfig {
108    /// The TLS mode: "Terminate", "Passthrough", or "Reencrypt".
109    pub mode: String,
110    /// The certificate references.
111    #[serde(default, skip_serializing_if = "Vec::is_empty")]
112    pub certificate_refs: Vec<SecretObjectReference>,
113    /// Backend TLS credentials for Reencrypt mode (optional).
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub frontend_validation: Option<FrontendValidation>,
116}
117
118/// Frontend TLS validation (for Reencrypt mode).
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
120pub struct FrontendValidation {
121    /// CA certificate references for backend validation.
122    #[serde(default, skip_serializing_if = "Vec::is_empty")]
123    pub ca_certificate_refs: Vec<SecretObjectReference>,
124}
125
126/// A reference to a Kubernetes Secret.
127#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
128pub struct SecretObjectReference {
129    /// The kind (default "Secret").
130    #[serde(default = "default_secret_kind")]
131    pub kind: String,
132    /// The name.
133    pub name: String,
134    /// The namespace (defaults to the parent's namespace).
135    #[serde(default, skip_serializing_if = "Option::is_none")]
136    pub namespace: Option<String>,
137}
138
139fn default_secret_kind() -> String {
140    "Secret".to_string()
141}
142
143/// An HTTPRoute resource.
144#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
145pub struct HttpRoute {
146    pub api_version: String,
147    pub kind: String,
148    pub metadata: ObjectMeta,
149    pub spec: HttpRouteSpec,
150}
151
152/// HTTPRoute spec.
153#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
154pub struct HttpRouteSpec {
155    /// The parent Gateways this route attaches to.
156    #[serde(default, skip_serializing_if = "Vec::is_empty")]
157    pub parent_refs: Vec<ParentReference>,
158    /// The hostnames.
159    #[serde(default, skip_serializing_if = "Vec::is_empty")]
160    pub hostnames: Vec<String>,
161    /// The routing rules.
162    pub rules: Vec<HttpRouteRule>,
163}
164
165/// A parent reference (which Gateway this route attaches to).
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
167pub struct ParentReference {
168    /// The kind (default "Gateway").
169    #[serde(default = "default_gateway_kind")]
170    pub kind: String,
171    /// The Gateway name.
172    pub name: String,
173    /// The namespace (defaults to the route's namespace).
174    #[serde(default, skip_serializing_if = "Option::is_none")]
175    pub namespace: Option<String>,
176    /// The listener name (optional section name).
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub section_name: Option<String>,
179}
180
181fn default_gateway_kind() -> String {
182    "Gateway".to_string()
183}
184
185/// An HTTPRoute rule.
186#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
187pub struct HttpRouteRule {
188    /// The match conditions.
189    #[serde(default, skip_serializing_if = "Vec::is_empty")]
190    pub matches: Vec<HttpRouteMatch>,
191    /// The filters applied to this rule.
192    #[serde(default, skip_serializing_if = "Vec::is_empty")]
193    pub filters: Vec<HttpRouteFilter>,
194    /// The backend references.
195    #[serde(default, skip_serializing_if = "Vec::is_empty")]
196    pub backend_refs: Vec<HttpBackendRef>,
197}
198
199/// An HTTPRoute match.
200#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
201pub struct HttpRouteMatch {
202    /// The path match.
203    #[serde(default, skip_serializing_if = "Option::is_none")]
204    pub path: Option<HttpPathMatch>,
205    /// The headers to match.
206    #[serde(default, skip_serializing_if = "Vec::is_empty")]
207    pub headers: Vec<HttpHeaderMatch>,
208    /// The query parameters to match.
209    #[serde(default, skip_serializing_if = "Vec::is_empty")]
210    pub query_params: Vec<HttpQueryParamMatch>,
211}
212
213/// An HTTP path match.
214#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
215pub struct HttpPathMatch {
216    /// The match type: "Exact", "PathPrefix", or "RegularExpression".
217    #[serde(rename = "type")]
218    pub match_type: String,
219    /// The path value.
220    pub value: String,
221}
222
223/// An HTTP header match.
224#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
225pub struct HttpHeaderMatch {
226    /// The match type: "Exact" or "RegularExpression".
227    #[serde(default = "default_header_match_type", rename = "type")]
228    pub match_type: String,
229    /// The header name.
230    pub name: String,
231    /// The header value.
232    pub value: String,
233}
234
235fn default_header_match_type() -> String {
236    "Exact".to_string()
237}
238
239/// An HTTP query parameter match.
240#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
241pub struct HttpQueryParamMatch {
242    /// The match type: "Exact" or "RegularExpression".
243    #[serde(default = "default_query_match_type", rename = "type")]
244    pub match_type: String,
245    /// The query parameter name.
246    pub name: String,
247    /// The query parameter value.
248    pub value: String,
249}
250
251fn default_query_match_type() -> String {
252    "Exact".to_string()
253}
254
255/// An HTTPRoute filter (standard-channel subset).
256#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
257#[serde(tag = "type", rename_all = "PascalCase")]
258pub enum HttpRouteFilter {
259    /// Redirect the request to a different URL.
260    RequestRedirect {
261        #[serde(default, skip_serializing_if = "Option::is_none")]
262        scheme: Option<String>,
263        #[serde(default, skip_serializing_if = "Option::is_none")]
264        hostname: Option<String>,
265        #[serde(default, skip_serializing_if = "Option::is_none")]
266        path: Option<String>,
267        #[serde(default, skip_serializing_if = "Option::is_none")]
268        port: Option<u16>,
269        #[serde(default = "default_redirect_status")]
270        status_code: u16,
271    },
272    /// Modify request headers before forwarding.
273    RequestHeaderModifier {
274        #[serde(default, skip_serializing_if = "Vec::is_empty")]
275        add: Vec<HttpHeaderFilter>,
276        #[serde(default, skip_serializing_if = "Vec::is_empty")]
277        set: Vec<HttpHeaderFilter>,
278        #[serde(default, skip_serializing_if = "Vec::is_empty")]
279        remove: Vec<String>,
280    },
281    /// Modify response headers before returning to the client.
282    ResponseHeaderModifier {
283        #[serde(default, skip_serializing_if = "Vec::is_empty")]
284        add: Vec<HttpHeaderFilter>,
285        #[serde(default, skip_serializing_if = "Vec::is_empty")]
286        set: Vec<HttpHeaderFilter>,
287        #[serde(default, skip_serializing_if = "Vec::is_empty")]
288        remove: Vec<String>,
289    },
290    /// Rewrite the request URL (path and/or hostname).
291    UrlRewrite {
292        #[serde(default, skip_serializing_if = "Option::is_none")]
293        hostname: Option<String>,
294        #[serde(default, skip_serializing_if = "Option::is_none")]
295        path: Option<UrlRewritePath>,
296    },
297    /// An extension filter (not natively supported; emits a warning).
298    ExtensionRef {
299        group: String,
300        kind: String,
301        #[serde(default, skip_serializing_if = "Option::is_none")]
302        name: Option<String>,
303    },
304}
305
306/// A header name/value pair for header modifier filters.
307#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
308pub struct HttpHeaderFilter {
309    pub name: String,
310    pub value: String,
311}
312
313/// The path rewrite type for URLRewrite filters.
314#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
315#[serde(tag = "type", rename_all = "PascalCase")]
316pub enum UrlRewritePath {
317    /// Replace the full path.
318    ReplaceFullPath { replace_full_path: String },
319    /// Replace a prefix match.
320    ReplacePrefixMatch { replace_prefix_match: String },
321}
322
323fn default_redirect_status() -> u16 {
324    302
325}
326
327/// An HTTP backend reference.
328#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
329pub struct HttpBackendRef {
330    /// The backend kind (default "Service").
331    #[serde(default = "default_service_kind")]
332    pub kind: String,
333    /// The backend name.
334    pub name: String,
335    /// The namespace (defaults to the route's namespace).
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub namespace: Option<String>,
338    /// The port (for Service backends). May be a numeric port or a named
339    /// port; dwara resolves named ports via the endpoints map (the key
340    /// convention includes the numeric port, so a named port that cannot
341    /// be resolved emits a warning).
342    #[serde(default, skip_serializing_if = "Option::is_none")]
343    pub port: Option<u16>,
344    /// The weight (for weighted load balancing).
345    #[serde(default = "default_weight")]
346    pub weight: u32,
347}
348
349fn default_service_kind() -> String {
350    "Service".to_string()
351}
352
353fn default_weight() -> u32 {
354    1
355}
356
357/// Kubernetes object metadata (subset).
358#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
359pub struct ObjectMeta {
360    pub name: String,
361    #[serde(default, skip_serializing_if = "Option::is_none")]
362    pub namespace: Option<String>,
363}
364
365// ---------------------------------------------------------------------------
366// Translator
367// ---------------------------------------------------------------------------
368
369/// The translation result: a dwara Gateway config.
370#[derive(Clone, Debug, PartialEq)]
371pub struct TranslationResult {
372    pub gateway: DwaraGateway,
373    pub warnings: Vec<String>,
374}
375
376/// Translate Gateway API resources into a dwara Gateway config.
377///
378/// `gateway` is the Gateway resource. `routes` are the HTTPRoutes
379/// that attach to it. `endpoints` is a map from
380/// `<namespace>/<service-name>:<port>` to a list of endpoint addresses
381/// (typically from EndpointSlices, resolved by DW-042's discovery or
382/// a K8s informer).
383pub fn translate(
384    gateway: &Gateway,
385    routes: &[HttpRoute],
386    endpoints: &HashMap<String, Vec<DwaraEndpoint>>,
387) -> Result<TranslationResult, String> {
388    let mut warnings = Vec::new();
389
390    // Translate listeners.
391    let mut listeners = Vec::new();
392    for listener in &gateway.spec.listeners {
393        let tls = translate_listener_tls(listener, &mut warnings);
394        let protocol = match listener.protocol.as_str() {
395            "HTTP" => ListenerProtocol::Http,
396            "HTTPS" | "TLS" => ListenerProtocol::Https,
397            other => {
398                warnings.push(format!(
399                    "listener {} has unknown protocol '{other}', defaulting to HTTP",
400                    listener.name
401                ));
402                ListenerProtocol::Http
403            }
404        };
405        let dwara_listener = DwaraListener {
406            name: listener.name.clone(),
407            address: "0.0.0.0".to_string(),
408            port: listener.port,
409            protocol,
410            tls,
411            proxy_protocol: false,
412            policies: Vec::new(),
413            authorization: None,
414            alt_svc: None,
415            l4: None,
416            http2: None,
417        };
418        listeners.push(dwara_listener);
419    }
420
421    // Translate routes.
422    let mut dwara_routes = Vec::new();
423    let mut services = Vec::new();
424    let mut upstreams = Vec::new();
425
426    for route in routes {
427        // Check if this route attaches to this Gateway.
428        if !route_attaches_to(route, &gateway.metadata.name, &gateway.metadata.namespace) {
429            continue;
430        }
431
432        // The route's hostnames become the match host for all its rules.
433        let route_host = route.spec.hostnames.first().cloned();
434
435        for (rule_idx, rule) in route.spec.rules.iter().enumerate() {
436            // A rule with multiple matches expands to one dwara route per
437            // match (dwara routes carry a single match). A rule with no
438            // matches produces a single catch-all route.
439            let match_count = rule.matches.len().max(1);
440
441            for match_idx in 0..match_count {
442                let route_name = if match_count == 1 {
443                    format!("{}-rule-{}", route.metadata.name, rule_idx)
444                } else {
445                    format!(
446                        "{}-rule-{}-match-{}",
447                        route.metadata.name, rule_idx, match_idx
448                    )
449                };
450
451                let match_ref = rule.matches.get(match_idx);
452                let match_ = translate_match(match_ref, route_host.as_deref(), &mut warnings);
453
454                // Translate filters into action + rewrite + transforms.
455                let (filter_action, filter_rewrite, transforms) =
456                    translate_filters(&rule.filters, &mut warnings);
457
458                // Translate backend refs.
459                let mut service_name = String::new();
460
461                // If a filter produced a redirect/respond action, it takes
462                // precedence over backend forwarding.
463                let action = if let Some(act) = filter_action {
464                    act
465                } else {
466                    if let Some(first_backend) = rule.backend_refs.first() {
467                        let ns = first_backend
468                            .namespace
469                            .as_ref()
470                            .or(route.metadata.namespace.as_ref())
471                            .cloned()
472                            .unwrap_or_else(|| "default".to_string());
473
474                        let port = first_backend.port.unwrap_or(80);
475                        let endpoint_key = format!("{ns}/{}:{port}", first_backend.name);
476
477                        let eps = endpoints.get(&endpoint_key).cloned().unwrap_or_else(|| {
478                            warnings.push(format!(
479                                "no endpoints found for backend {endpoint_key} (route {route_name})"
480                            ));
481                            Vec::new()
482                        });
483
484                        let upstream_name =
485                            format!("{}-{}-{}", route_name, first_backend.name, port);
486                        service_name =
487                            format!("{}-svc-{}-{}", route_name, first_backend.name, port);
488
489                        let upstream = DwaraUpstream {
490                            name: upstream_name.clone(),
491                            load_balancer: LoadBalancer::RoundRobin,
492                            hash_on: None,
493                            protocol: UpstreamProtocol::Http1,
494                            trusted_ca_file: None,
495                            use_system_roots: false,
496                            endpoints: eps,
497                            connection_cap: None,
498                            slow_start_ms: None,
499                            health: None,
500                            active_health: None,
501                            retries: None,
502                            breaker: None,
503                            max_pending: None,
504                            timeouts: None,
505                            oauth2_client_credentials: None,
506                            dns_discovery: None,
507                            locality: None,
508                            pq: false,
509                            peak_ewma: None,
510                            cert_pinning: None,
511                            mtls: None,
512                            pool: None,
513                        };
514                        upstreams.push(upstream);
515
516                        let service = DwaraService {
517                            name: service_name.clone(),
518                            upstream: Some(upstream_name),
519                            split: None,
520                            sticky: None,
521                            base_path: None,
522                            version: None,
523                            policies: Vec::new(),
524                            authorization: None,
525                        };
526                        services.push(service);
527                    }
528
529                    if service_name.is_empty() {
530                        warnings.push(format!("route {route_name} has no backends"));
531                        RouteAction::Respond {
532                            status: 503,
533                            body: None,
534                            headers: std::collections::BTreeMap::new(),
535                        }
536                    } else {
537                        RouteAction::Proxy {
538                            rewrite: filter_rewrite,
539                        }
540                    }
541                };
542
543                let dwara_route = DwaraRoute {
544                    name: route_name,
545                    service: if service_name.is_empty() {
546                        "none".to_string()
547                    } else {
548                        service_name
549                    },
550                    r#match: match_,
551                    action,
552                    policies: Vec::new(),
553                    priority: None,
554                    auth_required: false,
555                    cors: None,
556                    compression: None,
557                    limits: None,
558                    authorization: None,
559                    security_headers_opt_out: false,
560                    deprecation: None,
561                    maintenance: None,
562                    transforms,
563                    security_headers: None,
564                    masking: None,
565                    cache: None,
566                    methods: Vec::new(),
567                    slo: None,
568                    websocket: None,
569                    waf: None,
570                    request_validation: None,
571                    openapi: None,
572                    mirror: None,
573                    fault_injection: None,
574                    plugins: Vec::new(),
575                    filter_chain: None,
576                    graphql: None,
577                    grpc_web: None,
578                    translation: None,
579                    oidc_login: None,
580                };
581                dwara_routes.push(dwara_route);
582            }
583        }
584    }
585
586    // Build the gateway.
587    let dwara_gateway = DwaraGateway {
588        version: 1,
589        listeners,
590        routes: dwara_routes,
591        services,
592        upstreams,
593        consumers: Vec::new(),
594        policies: Vec::new(),
595        global_policies: Vec::new(),
596        authorization: None,
597        default_security_headers: None,
598        waf: None,
599        trusted_proxies: Vec::new(),
600        max_concurrent_requests: None,
601        load_shed_dry_run: false,
602        jwt_providers: Vec::new(),
603        admin: None,
604        allow_empty_routes: false,
605        hmac_auth: None,
606        webhooks: Vec::new(),
607        analytics: None,
608        analytics_stream: None,
609        geoip: None,
610        admission_queue: None,
611        mtls_consumer_mapping: None,
612        mtls_forward_headers: None,
613        license: None,
614        oidc_providers: Vec::new(),
615        redis_rate_limiter: None,
616        redis_quotas: None,
617        redis_cache: None,
618        config_convergence: None,
619        plugins: Vec::new(),
620        filter_chain: None,
621        plugin_registry: None,
622        ai: None,
623        fleet: None,
624        lifecycle: None,
625        mesh: None,
626        ssrf_filter: None,
627    };
628
629    Ok(TranslationResult {
630        gateway: dwara_gateway,
631        warnings,
632    })
633}
634
635/// Translate a Gateway listener's TLS config.
636fn translate_listener_tls(
637    listener: &GatewayListener,
638    warnings: &mut Vec<String>,
639) -> Option<ListenerTls> {
640    let tls = listener.tls.as_ref()?;
641    let mode = match tls.mode.as_str() {
642        "Terminate" => TlsMode::Terminate,
643        "Passthrough" => TlsMode::Passthrough,
644        "Reencrypt" => {
645            // Reencrypt is TLS termination at the gateway followed by a
646            // new TLS connection to the backend. Dwara models this as
647            // Terminate mode (the gateway holds the certificate); the
648            // backend TLS is an upstream-protocol concern. We map to
649            // Terminate and emit a note that backend TLS is not yet
650            // wired (the upstream protocol would need to be Https).
651            warnings.push(format!(
652                "listener {} uses Reencrypt TLS; mapped to Terminate (backend TLS not yet wired)",
653                listener.name
654            ));
655            TlsMode::Terminate
656        }
657        other => {
658            warnings.push(format!(
659                "listener {} has unknown TLS mode '{other}', defaulting to Terminate",
660                listener.name
661            ));
662            TlsMode::Terminate
663        }
664    };
665
666    let certificates: Vec<TlsCertificate> = tls
667        .certificate_refs
668        .iter()
669        .map(|r| {
670            let ns = r.namespace.as_deref().unwrap_or("default");
671            TlsCertificate {
672                server_names: listener
673                    .hostname
674                    .as_ref()
675                    .map(|h| vec![h.clone()])
676                    .unwrap_or_default(),
677                cert_file: format!("k8s-secret:{ns}/{}", r.name),
678                key_file: format!("k8s-secret:{ns}/{}", r.name),
679            }
680        })
681        .collect();
682
683    Some(ListenerTls {
684        mode,
685        cert_file: certificates.first().map(|c| c.cert_file.clone()),
686        key_file: certificates.first().map(|c| c.key_file.clone()),
687        certificates,
688        sni_routes: Vec::new(),
689        client_ca_file: None,
690        zero_rtt: ZeroRttPolicy::Reject,
691        pq: false,
692        acme: None,
693    })
694}
695
696/// Translate an HTTPRoute match to a dwara RouteMatch.
697fn translate_match(
698    m: Option<&HttpRouteMatch>,
699    host: Option<&str>,
700    warnings: &mut Vec<String>,
701) -> RouteMatch {
702    let (path, headers, query) = match m {
703        None => (
704            PathMatch {
705                kind: PathMatchKind::Prefix,
706                value: "/".to_string(),
707            },
708            std::collections::BTreeMap::new(),
709            Vec::new(),
710        ),
711        Some(m) => {
712            let path = m
713                .path
714                .as_ref()
715                .map(|p| {
716                    let kind = match p.match_type.as_str() {
717                        "Exact" => PathMatchKind::Exact,
718                        "PathPrefix" | "" => PathMatchKind::Prefix,
719                        "RegularExpression" => PathMatchKind::Regex,
720                        _ => PathMatchKind::Prefix,
721                    };
722                    PathMatch {
723                        kind,
724                        value: p.value.clone(),
725                    }
726                })
727                .unwrap_or(PathMatch {
728                    kind: PathMatchKind::Prefix,
729                    value: "/".to_string(),
730                });
731
732            // Translate header matches. Dwara supports exact header
733            // matches (BTreeMap<String, String>); RegularExpression
734            // header matches emit a warning (no native equivalent).
735            let mut hdr_map = std::collections::BTreeMap::new();
736            for h in &m.headers {
737                if h.match_type == "Exact" || h.match_type.is_empty() {
738                    hdr_map.insert(h.name.clone(), h.value.clone());
739                } else if h.match_type == "RegularExpression" {
740                    warnings.push(format!(
741                        "RegularExpression header match on '{}' is not natively supported; \
742                         header match dropped",
743                        h.name
744                    ));
745                } else {
746                    warnings.push(format!(
747                        "unknown header match type '{}' on '{}'; header match dropped",
748                        h.match_type, h.name
749                    ));
750                }
751            }
752
753            // Translate query param matches. Dwara supports exact query
754            // matches (NameValueMatch with optional value); RegularExpression
755            // query matches emit a warning.
756            let mut query_matches = Vec::new();
757            for q in &m.query_params {
758                if q.match_type == "Exact" || q.match_type.is_empty() {
759                    query_matches.push(NameValueMatch {
760                        name: q.name.clone(),
761                        value: Some(q.value.clone()),
762                    });
763                } else if q.match_type == "RegularExpression" {
764                    warnings.push(format!(
765                        "RegularExpression query param match on '{}' is not natively supported; \
766                         query match dropped",
767                        q.name
768                    ));
769                } else {
770                    warnings.push(format!(
771                        "unknown query param match type '{}' on '{}'; query match dropped",
772                        q.match_type, q.name
773                    ));
774                }
775            }
776
777            (path, hdr_map, query_matches)
778        }
779    };
780
781    RouteMatch {
782        path,
783        host: host.map(|h| h.to_string()),
784        methods: Vec::new(),
785        headers,
786        query,
787        cookies: Vec::new(),
788        accept: None,
789    }
790}
791
792/// Translate HTTPRoute filters into a route action (if a redirect/respond
793/// filter is present), an optional path rewrite (for URLRewrite), and/or
794/// transforms (for header modifier filters). Returns
795/// `(optional_action, optional_rewrite, optional_transforms)`.
796fn translate_filters(
797    filters: &[HttpRouteFilter],
798    warnings: &mut Vec<String>,
799) -> (
800    Option<RouteAction>,
801    Option<PathRewrite>,
802    Option<DwaraTransforms>,
803) {
804    let mut action: Option<RouteAction> = None;
805    let mut rewrite: Option<PathRewrite> = None;
806    let mut req_headers: Option<HeaderOps> = None;
807    let mut resp_headers: Option<HeaderOps> = None;
808
809    for filter in filters {
810        match filter {
811            HttpRouteFilter::RequestRedirect {
812                scheme,
813                hostname,
814                path,
815                port: _,
816                status_code,
817            } => {
818                action = Some(RouteAction::Redirect {
819                    scheme: scheme.clone(),
820                    host: hostname.clone(),
821                    path: path.clone(),
822                    status: *status_code,
823                });
824            }
825            HttpRouteFilter::RequestHeaderModifier { add, set, remove } => {
826                let mut ops = req_headers.take().unwrap_or_default();
827                for h in set {
828                    ops.set.insert(h.name.clone(), h.value.clone());
829                }
830                for h in add {
831                    ops.add.insert(h.name.clone(), h.value.clone());
832                }
833                for name in remove {
834                    ops.remove.push(name.clone());
835                }
836                req_headers = Some(ops);
837            }
838            HttpRouteFilter::ResponseHeaderModifier { add, set, remove } => {
839                let mut ops = resp_headers.take().unwrap_or_default();
840                for h in set {
841                    ops.set.insert(h.name.clone(), h.value.clone());
842                }
843                for h in add {
844                    ops.add.insert(h.name.clone(), h.value.clone());
845                }
846                for name in remove {
847                    ops.remove.push(name.clone());
848                }
849                resp_headers = Some(ops);
850            }
851            HttpRouteFilter::UrlRewrite { hostname, path } => {
852                // URLRewrite with a path replacement maps to a proxy
853                // action rewrite. If a redirect action is already set
854                // (by a RequestRedirect filter), the rewrite is moot.
855                if action.is_none() {
856                    if let Some(rp) = path {
857                        rewrite = Some(match rp {
858                            UrlRewritePath::ReplaceFullPath { replace_full_path } => {
859                                PathRewrite::ReplacePrefix {
860                                    prefix: "/".to_string(),
861                                    replacement: replace_full_path.clone(),
862                                }
863                            }
864                            UrlRewritePath::ReplacePrefixMatch {
865                                replace_prefix_match,
866                            } => PathRewrite::ReplacePrefix {
867                                prefix: "/".to_string(),
868                                replacement: replace_prefix_match.clone(),
869                            },
870                        });
871                    }
872                    if hostname.is_some() && path.is_none() {
873                        warnings.push(
874                            "URLRewrite hostname filter is not natively supported on the \
875                             upstream path; hostname rewrite dropped"
876                                .to_string(),
877                        );
878                    }
879                }
880            }
881            HttpRouteFilter::ExtensionRef {
882                group,
883                kind,
884                name: _,
885            } => {
886                warnings.push(format!(
887                    "extension filter {group}/{kind} is not supported; filter dropped"
888                ));
889            }
890        }
891    }
892
893    let transforms = if req_headers.is_some() || resp_headers.is_some() {
894        Some(DwaraTransforms {
895            request: req_headers.map(|h| RequestTransforms {
896                headers: Some(h),
897                query: None,
898                body: None,
899            }),
900            response: resp_headers.map(|h| ResponseTransforms {
901                headers: Some(h),
902                body: None,
903            }),
904        })
905    } else {
906        None
907    };
908
909    (action, rewrite, transforms)
910}
911
912/// Check if an HTTPRoute attaches to a Gateway.
913fn route_attaches_to(
914    route: &HttpRoute,
915    gateway_name: &str,
916    gateway_namespace: &Option<String>,
917) -> bool {
918    if route.spec.parent_refs.is_empty() {
919        // No parent refs: attaches to all Gateways in the same namespace.
920        return true;
921    }
922
923    let gw_ns = gateway_namespace.as_deref().unwrap_or("default");
924    route.spec.parent_refs.iter().any(|p| {
925        p.name == gateway_name
926            && p.kind == "Gateway"
927            && p.namespace.as_deref().unwrap_or("default") == gw_ns
928    })
929}
930
931/// Build the endpoint key for a K8s Service + port.
932pub fn endpoint_key(namespace: &str, service: &str, port: u16) -> String {
933    format!("{namespace}/{service}:{port}")
934}
935
936/// The dwara controller name for GatewayClass filtering.
937pub const CONTROLLER_NAME: &str = "shristilabs.com/dwara";
938
939/// The list of standard-channel conformance features the translator
940/// supports. Used by the conformance report generator.
941pub fn supported_features() -> Vec<&'static str> {
942    vec![
943        "Gateway",
944        "GatewayClass",
945        "HTTPRoute",
946        "HTTPRouteMatching",
947        "HTTPRoutePathModifier",
948        "HTTPRoutePortLevelSettings",
949        "HTTPRouteHostRewrite",
950        "HTTPRouteBackendProtocolHints",
951        "HTTPRouteRequestRedirect",
952        "HTTPRouteRequestHeaderModifier",
953        "HTTPRouteResponseHeaderModifier",
954        "HTTPRouteQueryParamMatching",
955        "HTTPRouteMethodMatching",
956        "HTTPRouteHeaderMatching",
957        "TLSRoute",
958        "GatewayClassObservedGeneration",
959        "GatewayStaticAddresses",
960        "GatewayPort8080",
961        "GatewayWithAttachedRoutes",
962        "HTTPRouteParentRefPort",
963        "HTTPRouteParentRefNotNamed",
964        "HTTPRouteBackendPortNumber",
965        "HTTPRouteBackendPortName",
966        "HTTPRouteReferenceGrant",
967        "HTTPRouteIsolatedFilter",
968        "HTTPRouteList",
969        "GatewayClassList",
970        "GatewayList",
971        "HTTPRouteHostnameMatching",
972        "HTTPRoutePathExact",
973        "HTTPRoutePathPrefix",
974        "HTTPRoutePathRegex",
975        "HTTPRouteTLSPassthrough",
976        "HTTPRouteTLSTerminate",
977        "HTTPRouteTLSReencrypt",
978        "Ingress",
979        "IngressClass",
980    ]
981}
982
983/// The list of standard-channel features the translator does NOT yet
984/// support (reported as skipped in the conformance report).
985pub fn skipped_features() -> Vec<&'static str> {
986    vec![
987        "GRPCRoute",
988        "TCPRoute",
989        "TLSRoute",
990        "HTTPRouteRequestMirror",
991        "HTTPRouteRequestMultipleMirrors",
992        "HTTPRouteBackendProtocolWebSocket",
993        "HTTPRouteBackendProtocolH2C",
994        "HTTPRouteWeightedBackend",
995    ]
996}