1use 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#[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#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
64pub struct IngressSpec {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub ingress_class_name: Option<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub rules: Vec<IngressRule>,
71 #[serde(default, skip_serializing_if = "Vec::is_empty")]
73 pub tls: Vec<IngressTls>,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub default_backend: Option<IngressBackend>,
77}
78
79#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
81pub struct IngressRule {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub host: Option<String>,
85 #[serde(default, skip_serializing_if = "Vec::is_empty")]
87 pub http: Vec<HTTPIngressPath>,
88}
89
90#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
92pub struct HTTPIngressPath {
93 #[serde(default = "default_path_type", rename = "pathType")]
95 pub path_type: String,
96 #[serde(default = "default_path_value")]
98 pub path: String,
99 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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct IngressBackend {
114 #[serde(default, skip_serializing_if = "Option::is_none")]
116 pub service: Option<IngressServiceBackend>,
117}
118
119#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
121pub struct IngressServiceBackend {
122 pub name: String,
124 #[serde(default, skip_serializing_if = "Option::is_none")]
126 pub port: Option<ServiceBackendPort>,
127}
128
129#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
131pub struct ServiceBackendPort {
132 #[serde(default, skip_serializing_if = "Option::is_none")]
134 pub name: Option<String>,
135 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub number: Option<u16>,
138}
139
140#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
142pub struct IngressTls {
143 #[serde(default, skip_serializing_if = "Vec::is_empty")]
145 pub hosts: Vec<String>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
148 pub secret_name: Option<String>,
149}
150
151#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
162pub struct IngressClassObjectMeta {
163 pub name: String,
164}
165
166#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
168pub struct IngressClassSpec {
169 pub controller: String,
171}
172
173const 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
194pub 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 let mut tls_configs: Vec<(String, Option<String>)> = Vec::new(); for ingress in ingresses {
216 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 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 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 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 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 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
528pub use super::TranslationResult;