n5i-apps 0.12.0-dev.1

Utils for working with n5i apps
// SPDX-FileCopyrightText: 2024-2026 The n5i Project
//
// SPDX-License-Identifier: AGPL-3.0-or-later

use crate::internal::{Ingress, IngressType};
use k8s_crds_gateway::{
    BackendTlsPolicy, BackendTlsPolicySpec, BackendTlsPolicyTargetRefs, BackendTlsPolicyValidation,
    HttpRoute, HttpRouteRules, HttpRouteRulesBackendRefs, HttpRouteRulesFilters,
    HttpRouteRulesFiltersExtensionRef, HttpRouteRulesFiltersRequestRedirectScheme,
    HttpRouteRulesFiltersType, HttpRouteRulesFiltersUrlRewrite,
    HttpRouteRulesFiltersUrlRewritePath, HttpRouteRulesFiltersUrlRewritePathType,
    HttpRouteRulesMatches, HttpRouteRulesMatchesPath, HttpRouteRulesMatchesPathType, HttpRouteSpec,
};
use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
use slugify::slugify;
use std::hash::{DefaultHasher, Hash, Hasher};

fn middleware_filter(mw_name: String) -> HttpRouteRulesFilters {
    HttpRouteRulesFilters {
        cors: None,
        r#type: HttpRouteRulesFiltersType::ExtensionRef,
        extension_ref: Some(HttpRouteRulesFiltersExtensionRef {
            group: "traefik.io".to_string(),
            kind: "Middleware".to_string(),
            name: mw_name,
        }),
        request_header_modifier: None,
        request_mirror: None,
        request_redirect: None,
        response_header_modifier: None,
        url_rewrite: None,
    }
}

const fn strip_prefix_filter(prefix: String) -> HttpRouteRulesFilters {
    HttpRouteRulesFilters {
        cors: None,
        extension_ref: None,
        request_header_modifier: None,
        request_mirror: None,
        request_redirect: None,
        response_header_modifier: None,
        r#type: HttpRouteRulesFiltersType::UrlRewrite,
        url_rewrite: Some(HttpRouteRulesFiltersUrlRewrite {
            hostname: None,
            path: Some(HttpRouteRulesFiltersUrlRewritePath {
                replace_full_path: None,
                replace_prefix_match: Some(prefix),
                r#type: HttpRouteRulesFiltersUrlRewritePathType::ReplacePrefixMatch,
            }),
        }),
    }
}

fn join_path_prefixes(prefix_a: &str, prefix_b: &str) -> String {
    let mut joined = String::new();
    if !prefix_a.starts_with('/') {
        joined.push('/');
    }
    joined.push_str(prefix_a);
    if !prefix_a.ends_with('/') && !prefix_b.starts_with('/') {
        joined.push('/');
    }
    joined.push_str(prefix_b);
    joined
}

trait IngressHash {
    fn get_hash(&self) -> String;
}

impl IngressHash for Ingress {
    fn get_hash(&self) -> String {
        let mut hasher = DefaultHasher::new();
        self.hash(&mut hasher);
        hasher.finish().to_string()
    }
}

#[must_use]
pub fn generate_tls_policies(route_name: &str, sources: &[&Ingress]) -> Vec<BackendTlsPolicy> {
    sources
        .iter()
        .filter(|s| s.use_https || s.use_https_with_hostname.is_some())
        .map(|s| BackendTlsPolicy {
            metadata: ObjectMeta {
                name: Some(route_name.to_string()),
                ..Default::default()
            },
            spec: BackendTlsPolicySpec {
                options: None,
                target_refs: vec![BackendTlsPolicyTargetRefs {
                    group: "gateway.networking.k8s.io".to_string(),
                    kind: "HTTPRoute".to_string(),
                    name: route_name.to_string(),
                    section_name: Some(s.get_hash()),
                }],
                validation: BackendTlsPolicyValidation {
                    ca_certificate_refs: None,
                    hostname: if let Some(ref hostname) = s.use_https_with_hostname {
                        hostname.clone()
                    } else if let Some(ref target_service) = s.target_service {
                        // s.use_https must be true (filter above)
                        target_service.clone()
                    } else {
                        "unknownSvc.local".to_string()
                    },
                    subject_alt_names: None,
                    well_known_ca_certificates: None,
                },
            },
            status: None,
        })
        .collect()
}

#[must_use]
pub fn get_app_ingress(
    domain: Option<&String>,
    sources: &[Ingress],
    is_tls: bool,
    user: &str,
    protect: bool,
    custom_prefix: Option<&String>,
    component_id: Option<&String>,
) -> (HttpRoute, Vec<BackendTlsPolicy>) {
    let sources: Vec<&Ingress> = sources
        .iter()
        .filter(|source| source.component.as_ref() == component_id)
        .collect();
    let use_http_fallback_routes = !is_tls
        && sources
            .iter()
            .any(|source| source.r#type == IngressType::HttpFallback);

    // TODO: Generate BackendTlsPolicies somewhere
    let mapper = |source: &Ingress| {
        let mut middlewares = Vec::new();
        if source.enable_compression {
            middlewares.push(middleware_filter("compress-v2".to_string()));
        }
        if protect && !source.auth_exclude {
            middlewares.push(middleware_filter(format!("auth-{user}")));
        }
        let prefix;
        if source.strip_prefix
            && let Some(ref path_prefix) = source.path_prefix
            && path_prefix != "/"
        {
            if let Some(custom_prefix) = custom_prefix {
                prefix = Some(join_path_prefixes(custom_prefix, path_prefix));
            } else {
                prefix = Some(path_prefix.clone());
            }
        } else {
            prefix = custom_prefix.cloned();
        }
        if let Some(ref prefix) = prefix {
            middlewares.push(strip_prefix_filter(prefix.clone()));
        }
        HttpRouteRules {
            backend_refs: Some(
                if let (Some(svc), Some(port)) =
                    (source.target_service.as_ref(), source.target_port)
                {
                    vec![HttpRouteRulesBackendRefs {
                        filters: None,
                        group: None,
                        kind: Some("Service".to_string()),
                        name: svc.clone(),
                        port: Some(i32::from(port)),
                        namespace: source.target_ns.clone().or_else(|| {
                            source
                                .target_app
                                .as_ref()
                                .map(|app| format!("{user}-{app}"))
                        }),
                        weight: None,
                    }]
                } else {
                    vec![]
                },
            ),
            filters: if middlewares.is_empty() {
                None
            } else {
                Some(middlewares)
            },
            matches: if let Some(prefix) = prefix {
                Some(vec![HttpRouteRulesMatches {
                    headers: None,
                    method: None,
                    path: Some(HttpRouteRulesMatchesPath {
                        r#type: Some(HttpRouteRulesMatchesPathType::PathPrefix),
                        value: Some(prefix),
                    }),
                    query_params: None,
                }])
            } else {
                None
            },
            name: Some(source.get_hash()),
            timeouts: None,
        }
    };
    let route_name = if let Some(domain) = domain.as_ref() {
        slugify!(domain)
    } else {
        "fallback-ingress".to_string()
    };
    let sources: Vec<_> = sources
        .into_iter()
        .filter(|source| {
            if use_http_fallback_routes {
                source.r#type == IngressType::HttpFallback
            } else {
                source.r#type == IngressType::Https
            }
        })
        .collect();
    let policies = generate_tls_policies(&route_name, &sources);
    (
        HttpRoute {
            metadata: ObjectMeta {
                name: Some(route_name),
                ..Default::default()
            },
            spec: HttpRouteSpec {
                hostnames: domain.map(|d| vec![d.clone()]),
                parent_refs: Some(vec![k8s_crds_gateway::HttpRouteParentRefs {
                    group: Some("gateway.networking.k8s.io".to_string()),
                    kind: Some("Gateway".to_string()),
                    name: "n5i".to_string(),
                    namespace: Some(n5i::distro::DISTRO_ID.to_string()),
                    port: Some(
                        std::env::var("GATEWAY_TLS_LISTENER_PORT")
                            .ok()
                            .and_then(|p| p.parse().ok())
                            .unwrap_or(443),
                    ),
                    section_name: None,
                }]),
                rules: Some(sources.into_iter().map(mapper).collect()),
            },
            status: None,
        },
        policies,
    )
}

// Returns an ingress route for the given app, as well as one that implements a redirect from http to https.
#[must_use]
pub fn get_ingress_routes(
    domain: &String,
    sources: &[Ingress],
    user: &str,
    protect: bool,
    custom_prefix: Option<&String>,
    component_id: Option<&String>,
) -> ([HttpRoute; 2], Vec<BackendTlsPolicy>) {
    let sources: Vec<Ingress> = sources
        .iter()
        .filter_map(|source| {
            if source.component.as_ref() == component_id {
                Some(source.clone())
            } else {
                None
            }
        })
        .collect();
    let has_fallback = sources
        .iter()
        .any(|source| source.r#type == IngressType::HttpFallback);
    let (main_ingress, mut policies) = get_app_ingress(
        Some(domain),
        &sources,
        true,
        user,
        protect,
        custom_prefix,
        component_id,
    );
    let http_route = if has_fallback {
        let (mut http_route, mut additional_policies) = get_app_ingress(
            Some(domain),
            &sources,
            false,
            user,
            protect,
            custom_prefix,
            component_id,
        );
        http_route.metadata.name = Some(format!("{}-http", slugify!(&domain)));
        policies.append(&mut additional_policies);
        http_route
    } else {
        HttpRoute {
            metadata: ObjectMeta {
                name: Some(format!("{}-http", slugify!(&domain))),
                ..Default::default()
            },
            spec: HttpRouteSpec {
                hostnames: Some(vec![domain.clone()]),
                parent_refs: Some(vec![k8s_crds_gateway::HttpRouteParentRefs {
                    group: Some("gateway.networking.k8s.io".to_string()),
                    kind: Some("Gateway".to_string()),
                    name: "n5i".to_string(),
                    namespace: Some(n5i::distro::DISTRO_ID.to_string()),
                    port: Some(80),
                    section_name: None,
                }]),
                rules: Some(vec![HttpRouteRules {
                    name: Some("http-redirect".to_string()),
                    matches: None,
                    filters: Some(vec![HttpRouteRulesFilters {
                        r#type: HttpRouteRulesFiltersType::RequestRedirect,
                        request_redirect: Some(
                            k8s_crds_gateway::HttpRouteRulesFiltersRequestRedirect {
                                hostname: None,
                                scheme: Some(HttpRouteRulesFiltersRequestRedirectScheme::Https),
                                port: None,
                                status_code: None,
                                path: None,
                            },
                        ),
                        cors: None,
                        extension_ref: None,
                        request_header_modifier: None,
                        request_mirror: None,
                        response_header_modifier: None,
                        url_rewrite: None,
                    }]),
                    backend_refs: None,
                    timeouts: None,
                }]),
            },
            status: None,
        }
    };
    ([main_ingress, http_route], policies)
}