dwara_core/k8s_gateway/
ingress.rs

1//! Kubernetes Ingress / IngressClass translator (DW-064).
2//!
3//! A minimal Ingress controller translator that maps the standard
4//! Kubernetes `Ingress` and `IngressClass` resources into dwara's config
5//! model. This is the prerequisite that lands BEFORE the Gateway API CRD
6//! path (per FEATURE_ANALYSIS.md section 5-Platform): Ingress is the
7//! ubiquitous K8s routing API, and folding it into this issue's scope lets
8//! dwara serve clusters that have not yet adopted Gateway API.
9//!
10//! ## Translation
11//!
12//! - `Ingress` rules -> dwara `Route` (one per rule path; pathType
13//!   `Prefix` -> `PathMatchKind::Prefix`, `Exact` -> `Exact`,
14//!   `ImplementationSpecific` -> `Prefix` with a warning).
15//! - `Ingress` backend (rule backend or defaultBackend) -> `Service` +
16//!   `Upstream` + `Endpoint`.
17//! - `Ingress` TLS -> listener TLS (Terminate mode, cert from the named
18//!   Secret).
19//! - Unsupported annotations (nginx.ingress.kubernetes.io/*, rewrite-target,
20//!   auth, etc.) -> warnings (never silently dropped).
21//!
22//! ## Feature gate
23//!
24//! None: the module compiles into every build (no `k8s` cargo
25//! feature).
26
27use std::collections::HashMap;
28
29use serde::{Deserialize, Serialize};
30
31use crate::config::{
32    Endpoint as DwaraEndpoint, Gateway as DwaraGateway, Listener as DwaraListener,
33    ListenerProtocol, ListenerTls, LoadBalancer, PathMatch, PathMatchKind, Route as DwaraRoute,
34    RouteAction, RouteMatch, Service as DwaraService, TlsCertificate, TlsMode,
35    Upstream as DwaraUpstream, UpstreamProtocol, ZeroRttPolicy,
36};
37
38// ---------------------------------------------------------------------------
39// Ingress resource types (subset for translation)
40// ---------------------------------------------------------------------------
41
42/// An Ingress resource (subset for translation).
43#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
44pub struct Ingress {
45    pub api_version: String,
46    pub kind: String,
47    pub metadata: IngressObjectMeta,
48    pub spec: IngressSpec,
49}
50
51/// Ingress object metadata (includes annotations for unsupported-construct
52/// detection).
53#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
54pub struct IngressObjectMeta {
55    pub name: String,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub namespace: Option<String>,
58    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
59    pub annotations: HashMap<String, String>,
60}
61
62/// Ingress spec.
63#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct IngressSpec {
65    /// The ingress class name (matched against the configured class).
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub ingress_class_name: Option<String>,
68    /// The routing rules.
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub rules: Vec<IngressRule>,
71    /// The TLS configurations.
72    #[serde(default, skip_serializing_if = "Vec::is_empty")]
73    pub tls: Vec<IngressTls>,
74    /// The default backend (for traffic that matches no rule).
75    #[serde(default, skip_serializing_if = "Option::is_none")]
76    pub default_backend: Option<IngressBackend>,
77}
78
79/// An Ingress routing rule.
80#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct IngressRule {
82    /// The host (optional; empty = wildcard).
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub host: Option<String>,
85    /// The HTTP paths.
86    #[serde(default, skip_serializing_if = "Vec::is_empty")]
87    pub http: Vec<HTTPIngressPath>,
88}
89
90/// An HTTP path in an Ingress rule.
91#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct HTTPIngressPath {
93    /// The path match type: "Prefix", "Exact", "ImplementationSpecific".
94    #[serde(default = "default_path_type", rename = "pathType")]
95    pub path_type: String,
96    /// The path value.
97    #[serde(default = "default_path_value")]
98    pub path: String,
99    /// The backend for this path.
100    pub backend: IngressBackend,
101}
102
103fn default_path_type() -> String {
104    "ImplementationSpecific".to_string()
105}
106
107fn default_path_value() -> String {
108    "/".to_string()
109}
110
111/// An Ingress backend reference.
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct IngressBackend {
114    /// The Service backend.
115    #[serde(default, skip_serializing_if = "Option::is_none")]
116    pub service: Option<IngressServiceBackend>,
117}
118
119/// A Service backend reference.
120#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub struct IngressServiceBackend {
122    /// The Service name.
123    pub name: String,
124    /// The port (name or number).
125    #[serde(default, skip_serializing_if = "Option::is_none")]
126    pub port: Option<ServiceBackendPort>,
127}
128
129/// A Service backend port (name or number).
130#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ServiceBackendPort {
132    /// The named port (mutually exclusive with `number`).
133    #[serde(default, skip_serializing_if = "Option::is_none")]
134    pub name: Option<String>,
135    /// The numeric port.
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub number: Option<u16>,
138}
139
140/// An Ingress TLS configuration.
141#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
142pub struct IngressTls {
143    /// The hostnames this TLS config serves.
144    #[serde(default, skip_serializing_if = "Vec::is_empty")]
145    pub hosts: Vec<String>,
146    /// The name of the TLS Secret.
147    #[serde(default, skip_serializing_if = "Option::is_none")]
148    pub secret_name: Option<String>,
149}
150
151/// An IngressClass resource (subset for translation).
152#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
153pub struct IngressClass {
154    pub api_version: String,
155    pub kind: String,
156    pub metadata: IngressClassObjectMeta,
157    pub spec: IngressClassSpec,
158}
159
160/// IngressClass object metadata.
161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct IngressClassObjectMeta {
163    pub name: String,
164}
165
166/// IngressClass spec.
167#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168pub struct IngressClassSpec {
169    /// The controller name.
170    pub controller: String,
171}
172
173// ---------------------------------------------------------------------------
174// Translator
175// ---------------------------------------------------------------------------
176
177/// The set of annotations the translator recognizes as unsupported
178/// (emits a warning rather than silently dropping). These are the common
179/// NGINX/HAProxy/ALB annotation families that have no dwara equivalent.
180const UNSUPPORTED_ANNOTATION_PREFIXES: &[&str] = &[
181    "nginx.ingress.kubernetes.io/",
182    "nginx.org/",
183    "haproxy.org/",
184    "alb.ingress.kubernetes.io/",
185    "kubernetes.io/ingress.allow-http",
186    "ingress.kubernetes.io/rewrite-target",
187    "nginx.ingress.kubernetes.io/rewrite-target",
188    "nginx.ingress.kubernetes.io/ssl-redirect",
189    "nginx.ingress.kubernetes.io/auth",
190    "nginx.ingress.kubernetes.io/proxy",
191    "nginx.ingress.kubernetes.io/configuration",
192];
193
194/// Translate Ingress resources into a dwara Gateway config.
195///
196/// `ingresses` are the Ingress resources. `ingress_class` is the
197/// configured class name (only Ingresses with `spec.ingressClassName`
198/// matching this, or with no class name when the class is the default,
199/// are translated). `endpoints` is a map from
200/// `<namespace>/<service-name>:<port>` to endpoint addresses.
201pub fn translate_ingress(
202    ingresses: &[Ingress],
203    ingress_class: &str,
204    endpoints: &HashMap<String, Vec<DwaraEndpoint>>,
205) -> Result<TranslationResult, String> {
206    let mut warnings = Vec::new();
207    let mut listeners = Vec::new();
208    let mut dwara_routes = Vec::new();
209    let mut services = Vec::new();
210    let mut upstreams = Vec::new();
211
212    // Collect TLS configs from all ingresses to build listeners.
213    let mut tls_configs: Vec<(String, Option<String>)> = Vec::new(); // (host, secret_name)
214
215    for ingress in ingresses {
216        // Check annotations for unsupported constructs.
217        for key in ingress.metadata.annotations.keys() {
218            if UNSUPPORTED_ANNOTATION_PREFIXES
219                .iter()
220                .any(|p| key.starts_with(p))
221            {
222                warnings.push(format!(
223                    "Ingress {}/{} has unsupported annotation '{}'; ignored",
224                    ingress.metadata.namespace.as_deref().unwrap_or("default"),
225                    ingress.metadata.name,
226                    key
227                ));
228            }
229        }
230
231        // Filter by ingress class.
232        if let Some(ref class) = ingress.spec.ingress_class_name {
233            if class != ingress_class {
234                continue;
235            }
236        }
237
238        let ns = ingress.metadata.namespace.as_deref().unwrap_or("default");
239
240        // Collect TLS.
241        for tls in &ingress.spec.tls {
242            if let Some(ref secret) = tls.secret_name {
243                if tls.hosts.is_empty() {
244                    tls_configs.push((String::new(), Some(secret.clone())));
245                } else {
246                    for host in &tls.hosts {
247                        tls_configs.push((host.clone(), Some(secret.clone())));
248                    }
249                }
250            }
251        }
252
253        // Translate rules.
254        for (rule_idx, rule) in ingress.spec.rules.iter().enumerate() {
255            let host = rule.host.clone();
256
257            for (path_idx, path) in rule.http.iter().enumerate() {
258                let route_name = format!(
259                    "{}-rule-{}-path-{}",
260                    ingress.metadata.name, rule_idx, path_idx
261                );
262
263                let path_kind = match path.path_type.as_str() {
264                    "Exact" => PathMatchKind::Exact,
265                    "Prefix" => PathMatchKind::Prefix,
266                    "ImplementationSpecific" => {
267                        warnings.push(format!(
268                            "Ingress path '{}' uses ImplementationSpecific pathType; \
269                             defaulting to Prefix",
270                            path.path
271                        ));
272                        PathMatchKind::Prefix
273                    }
274                    other => {
275                        warnings.push(format!(
276                            "Ingress path '{}' has unknown pathType '{}'; defaulting to Prefix",
277                            path.path, other
278                        ));
279                        PathMatchKind::Prefix
280                    }
281                };
282
283                let match_ = RouteMatch {
284                    path: PathMatch {
285                        kind: path_kind,
286                        value: path.path.clone(),
287                    },
288                    host: host.clone(),
289                    methods: Vec::new(),
290                    headers: std::collections::BTreeMap::new(),
291                    query: Vec::new(),
292                    cookies: Vec::new(),
293                    accept: None,
294                };
295
296                // Translate backend.
297                let backend = path.backend.service.as_ref().or(ingress
298                    .spec
299                    .default_backend
300                    .as_ref()
301                    .and_then(|b| b.service.as_ref()));
302
303                let mut service_name = String::new();
304
305                if let Some(svc) = backend {
306                    let port = svc.port.as_ref().and_then(|p| p.number).unwrap_or(80);
307
308                    if let Some(port_name) = svc.port.as_ref().and_then(|p| p.name.as_ref()) {
309                        warnings.push(format!(
310                            "Ingress backend {}/{} uses named port '{}'; numeric port \
311                             resolution not available without Service definition, using default 80",
312                            ns, svc.name, port_name
313                        ));
314                    }
315
316                    let endpoint_key = format!("{ns}/{}:{port}", svc.name);
317                    let eps = endpoints.get(&endpoint_key).cloned().unwrap_or_else(|| {
318                        warnings.push(format!(
319                            "no endpoints found for backend {endpoint_key} (route {route_name})"
320                        ));
321                        Vec::new()
322                    });
323
324                    let upstream_name = format!("{}-{}-{}", route_name, svc.name, port);
325                    service_name = format!("{}-svc-{}-{}", route_name, svc.name, port);
326
327                    let upstream = DwaraUpstream {
328                        name: upstream_name.clone(),
329                        load_balancer: LoadBalancer::RoundRobin,
330                        hash_on: None,
331                        protocol: UpstreamProtocol::Http1,
332                        trusted_ca_file: None,
333                        use_system_roots: false,
334                        endpoints: eps,
335                        connection_cap: None,
336                        slow_start_ms: None,
337                        health: None,
338                        active_health: None,
339                        retries: None,
340                        breaker: None,
341                        max_pending: None,
342                        timeouts: None,
343                        oauth2_client_credentials: None,
344                        dns_discovery: None,
345                        locality: None,
346                        pq: false,
347                        peak_ewma: None,
348                        cert_pinning: None,
349                        mtls: None,
350                        pool: None,
351                    };
352                    upstreams.push(upstream);
353
354                    let service = DwaraService {
355                        name: service_name.clone(),
356                        upstream: Some(upstream_name),
357                        split: None,
358                        sticky: None,
359                        base_path: None,
360                        version: None,
361                        policies: Vec::new(),
362                        authorization: None,
363                    };
364                    services.push(service);
365                }
366
367                let action = if service_name.is_empty() {
368                    warnings.push(format!("route {route_name} has no backend"));
369                    RouteAction::Respond {
370                        status: 503,
371                        body: None,
372                        headers: std::collections::BTreeMap::new(),
373                    }
374                } else {
375                    RouteAction::Proxy { rewrite: None }
376                };
377
378                let dwara_route = DwaraRoute {
379                    name: route_name,
380                    service: if service_name.is_empty() {
381                        "none".to_string()
382                    } else {
383                        service_name
384                    },
385                    r#match: match_,
386                    action,
387                    policies: Vec::new(),
388                    priority: None,
389                    auth_required: false,
390                    cors: None,
391                    compression: None,
392                    limits: None,
393                    authorization: None,
394                    security_headers_opt_out: false,
395                    deprecation: None,
396                    maintenance: None,
397                    transforms: None,
398                    security_headers: None,
399                    masking: None,
400                    cache: None,
401                    methods: Vec::new(),
402                    slo: None,
403                    websocket: None,
404                    waf: None,
405                    request_validation: None,
406                    openapi: None,
407                    mirror: None,
408                    fault_injection: None,
409                    plugins: Vec::new(),
410                    filter_chain: None,
411                    graphql: None,
412                    grpc_web: None,
413                    translation: None,
414                    oidc_login: None,
415                };
416                dwara_routes.push(dwara_route);
417            }
418        }
419    }
420
421    // Build listeners: HTTP on port 80, HTTPS on port 443 if any TLS
422    // configs were found.
423    listeners.push(DwaraListener {
424        name: "ingress-http".to_string(),
425        address: "0.0.0.0".to_string(),
426        port: 80,
427        protocol: ListenerProtocol::Http,
428        tls: None,
429        proxy_protocol: false,
430        policies: Vec::new(),
431        authorization: None,
432        alt_svc: None,
433        l4: None,
434        http2: None,
435    });
436
437    if !tls_configs.is_empty() {
438        let certificates: Vec<TlsCertificate> = tls_configs
439            .iter()
440            .filter_map(|(host, secret)| {
441                secret.as_ref().map(|s| TlsCertificate {
442                    server_names: if host.is_empty() {
443                        Vec::new()
444                    } else {
445                        vec![host.clone()]
446                    },
447                    cert_file: format!("k8s-secret:default/{s}"),
448                    key_file: format!("k8s-secret:default/{s}"),
449                })
450            })
451            .collect();
452
453        let tls = ListenerTls {
454            mode: TlsMode::Terminate,
455            cert_file: certificates.first().map(|c| c.cert_file.clone()),
456            key_file: certificates.first().map(|c| c.key_file.clone()),
457            certificates,
458            sni_routes: Vec::new(),
459            client_ca_file: None,
460            zero_rtt: ZeroRttPolicy::Reject,
461            pq: false,
462            acme: None,
463        };
464
465        listeners.push(DwaraListener {
466            name: "ingress-https".to_string(),
467            address: "0.0.0.0".to_string(),
468            port: 443,
469            protocol: ListenerProtocol::Https,
470            tls: Some(tls),
471            proxy_protocol: false,
472            policies: Vec::new(),
473            authorization: None,
474            alt_svc: None,
475            l4: None,
476            http2: None,
477        });
478    }
479
480    let dwara_gateway = DwaraGateway {
481        version: 1,
482        listeners,
483        routes: dwara_routes,
484        services,
485        upstreams,
486        consumers: Vec::new(),
487        policies: Vec::new(),
488        global_policies: Vec::new(),
489        authorization: None,
490        default_security_headers: None,
491        waf: None,
492        trusted_proxies: Vec::new(),
493        max_concurrent_requests: None,
494        load_shed_dry_run: false,
495        jwt_providers: Vec::new(),
496        admin: None,
497        allow_empty_routes: false,
498        hmac_auth: None,
499        webhooks: Vec::new(),
500        analytics: None,
501        analytics_stream: None,
502        geoip: None,
503        admission_queue: None,
504        mtls_consumer_mapping: None,
505        mtls_forward_headers: None,
506        license: None,
507        oidc_providers: Vec::new(),
508        redis_rate_limiter: None,
509        redis_quotas: None,
510        redis_cache: None,
511        config_convergence: None,
512        plugins: Vec::new(),
513        filter_chain: None,
514        plugin_registry: None,
515        ai: None,
516        fleet: None,
517        lifecycle: None,
518        mesh: None,
519        ssrf_filter: None,
520    };
521
522    Ok(TranslationResult {
523        gateway: dwara_gateway,
524        warnings,
525    })
526}
527
528/// Re-export the translation result type from the parent module for
529/// convenience (the Ingress translator produces the same shape).
530pub use super::TranslationResult;