kftray-portforward 0.30.1

KFtray library with port forwarding logic
Documentation
use std::collections::HashMap;

use anyhow::Result;
use futures::stream::{
    self,
    StreamExt,
};
use kftray_commons::models::config_model::Config;
use kube::Client;
use log::{
    debug,
    error,
    info,
};

use crate::kube::operations::{
    get_services_with_annotation,
    list_all_namespaces,
};
use crate::kube::shared_client::{
    SHARED_CLIENT_MANAGER,
    ServiceClientKey,
};
pub async fn retrieve_service_configs(
    context: &str, kubeconfig: Option<String>,
) -> Result<Vec<Config>, String> {
    let client_key = ServiceClientKey::new(Some(context.to_string()), kubeconfig.clone());

    let client = SHARED_CLIENT_MANAGER
        .get_connection(client_key)
        .await
        .map_err(|e| e.to_string())?;

    retrieve_service_configs_direct(context, kubeconfig, &client.client).await
}

async fn retrieve_service_configs_direct(
    context: &str, kubeconfig: Option<String>, client: &Client,
) -> Result<Vec<Config>, String> {
    let annotation = "kftray.app/configs";

    let namespaces = list_all_namespaces(Client::clone(client))
        .await
        .map_err(|e| e.to_string())?;

    debug!(
        "Found {} namespaces for direct API discovery",
        namespaces.len()
    );

    let concurrency_limit = 10;

    stream::iter(namespaces)
        .map(|namespace| {
            let client = Client::clone(client);
            let context = context.to_string();
            let kubeconfig = kubeconfig.clone();
            let annotation = annotation.to_string();

            async move {
                info!("Processing namespace with direct API: {namespace}");
                let services =
                    get_services_with_annotation(client.clone(), &namespace, &annotation)
                        .await
                        .map_err(|e| e.to_string())?;

                let mut namespace_configs = Vec::new();

                for (service_name, annotations, ports) in services {
                    debug!("Processing service: {service_name} in namespace: {namespace}");
                    if let Some(configs_str) = annotations.get(&annotation) {
                        namespace_configs.extend(parse_configs(
                            configs_str,
                            &context,
                            &namespace,
                            &service_name,
                            &ports,
                            kubeconfig.clone(),
                        ));
                    } else {
                        namespace_configs.extend(create_default_configs(
                            &context,
                            &namespace,
                            &service_name,
                            &ports,
                            kubeconfig.clone(),
                        ));
                    }
                }

                Ok(namespace_configs)
            }
        })
        .buffer_unordered(concurrency_limit)
        .fold(
            Ok(Vec::new()),
            |mut acc: Result<Vec<Config>, String>, result: Result<Vec<Config>, String>| async {
                match (&mut acc, result) {
                    (Ok(configs), Ok(mut namespace_configs)) => {
                        configs.append(&mut namespace_configs);
                        acc
                    }
                    (Ok(_), Err(e)) => {
                        error!("Error processing namespace with direct API: {e}");
                        acc
                    }
                    (Err(_), _) => acc,
                }
            },
        )
        .await
}

fn parse_configs(
    configs_str: &str, context: &str, namespace: &str, service_name: &str,
    ports: &HashMap<String, i32>, kubeconfig: Option<String>,
) -> Vec<Config> {
    configs_str
        .split(',')
        .filter_map(|config_str| {
            let config_str = config_str.trim();
            let Some((alias, local_port, remote_port)) = parse_config_entry(config_str, ports)
            else {
                debug!("Invalid config format: {config_str}");
                return None;
            };

            Some(Config {
                id: None,
                context: Some(context.to_string()),
                kubeconfig: kubeconfig.clone(),
                namespace: namespace.to_string(),
                service: Some(service_name.to_string()),
                alias: Some(alias.to_string()),
                local_port: Some(local_port),
                remote_port: Some(remote_port),
                protocol: "tcp".to_string(),
                workload_type: Some("service".to_string()),
                target: None,
                local_address: None,
                auto_loopback_address: false,
                remote_address: None,
                domain_enabled: None,
                http_logs_enabled: None,
                http_logs_max_file_size: None,
                http_logs_retention_days: None,
                http_logs_auto_cleanup: None,
                exposure_type: None,
                cert_manager_enabled: None,
                cert_issuer: None,
                cert_issuer_kind: None,
                ingress_class: None,
                ingress_annotations: None,
                tags: Default::default(),
            })
        })
        .collect()
}

fn parse_config_entry<'a>(
    entry: &'a str, ports: &HashMap<String, i32>,
) -> Option<(&'a str, u16, u16)> {
    entry.match_indices('-').rev().find_map(|(index, _)| {
        let (local_port, remote_port) = entry[index + 1..].split_once('-')?;
        let local_port = local_port.parse().ok()?;
        let remote_port = remote_port.parse().ok().or_else(|| {
            ports
                .get(remote_port)
                .and_then(|&port| u16::try_from(port).ok())
        })?;
        Some((&entry[..index], local_port, remote_port))
    })
}

fn create_default_configs(
    context: &str, namespace: &str, service_name: &str, ports: &HashMap<String, i32>,
    kubeconfig: Option<String>,
) -> Vec<Config> {
    ports
        .iter()
        .map(|(_port_name, &port)| Config {
            id: None,
            context: Some(context.to_string()),
            kubeconfig: kubeconfig.clone(),
            namespace: namespace.to_string(),
            service: Some(service_name.to_string()),
            alias: Some(service_name.to_string()),
            local_port: Some(port as u16),
            remote_port: Some(port as u16),
            protocol: "tcp".to_string(),
            workload_type: Some("service".to_string()),
            target: None,
            local_address: None,
            auto_loopback_address: false,
            remote_address: None,
            domain_enabled: None,
            http_logs_enabled: None,
            http_logs_max_file_size: None,
            http_logs_retention_days: None,
            http_logs_auto_cleanup: None,
            exposure_type: None,
            cert_manager_enabled: None,
            cert_issuer: None,
            cert_issuer_kind: None,
            ingress_class: None,
            ingress_annotations: None,
            tags: Default::default(),
        })
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_configs() {
        let mut ports = HashMap::new();
        ports.insert("http".to_string(), 8080);
        ports.insert("https".to_string(), 8443);
        ports.insert("grpc".to_string(), 9090);

        let configs_str = "web-3000-8080,api-3001-http,admin-3002-grpc";
        let configs = parse_configs(
            configs_str,
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            Some("/path/to/config".to_string()),
        );

        assert_eq!(configs.len(), 3);

        let web_config = &configs[0];
        assert_eq!(web_config.context, Some("test-context".to_string()));
        assert_eq!(web_config.namespace, "test-namespace");
        assert_eq!(web_config.service, Some("test-service".to_string()));
        assert_eq!(web_config.alias, Some("web".to_string()));
        assert_eq!(web_config.local_port, Some(3000));
        assert_eq!(web_config.remote_port, Some(8080));
        assert_eq!(web_config.protocol, "tcp");
        assert_eq!(web_config.kubeconfig, Some("/path/to/config".to_string()));
        assert_eq!(web_config.workload_type, Some("service".to_string()));

        let api_config = &configs[1];
        assert_eq!(api_config.alias, Some("api".to_string()));
        assert_eq!(api_config.local_port, Some(3001));
        assert_eq!(api_config.remote_port, Some(8080));

        let admin_config = &configs[2];
        assert_eq!(admin_config.alias, Some("admin".to_string()));
        assert_eq!(admin_config.local_port, Some(3002));
        assert_eq!(admin_config.remote_port, Some(9090));
    }

    #[test]
    fn test_parse_configs_invalid_format() {
        let mut ports = HashMap::new();
        ports.insert("http".to_string(), 8080);

        let configs_str = "invalid-format,web-3000-8080,missing-parts";
        let configs = parse_configs(
            configs_str,
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );

        assert_eq!(configs.len(), 1);
        assert_eq!(configs[0].alias, Some("web".to_string()));
        assert_eq!(configs[0].local_port, Some(3000));
        assert_eq!(configs[0].remote_port, Some(8080));
    }

    #[test]
    fn test_parse_configs_invalid_ports() {
        let ports = HashMap::new();

        let configs_str = "web-invalid-8080,api-3001-unknown";
        let configs = parse_configs(
            configs_str,
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );

        assert_eq!(configs.len(), 0);
    }

    #[test]
    fn test_parse_configs_hyphenated_alias() {
        let ports = HashMap::new();

        let configs = parse_configs(
            "my-api-8080-9090",
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );

        assert_eq!(configs.len(), 1);
        assert_eq!(configs[0].alias, Some("my-api".to_string()));
        assert_eq!(configs[0].local_port, Some(8080));
        assert_eq!(configs[0].remote_port, Some(9090));
    }

    #[test]
    fn test_parse_configs_hyphenated_port_name() {
        let mut ports = HashMap::new();
        ports.insert("http-alt".to_string(), 8081);

        let configs = parse_configs(
            "api-8080-http-alt,my-api-8082-http-alt",
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );

        assert_eq!(configs.len(), 2);
        assert_eq!(configs[0].alias, Some("api".to_string()));
        assert_eq!(configs[0].local_port, Some(8080));
        assert_eq!(configs[0].remote_port, Some(8081));
        assert_eq!(configs[1].alias, Some("my-api".to_string()));
        assert_eq!(configs[1].local_port, Some(8082));
        assert_eq!(configs[1].remote_port, Some(8081));
    }

    #[test]
    fn test_parse_configs_out_of_range_remote_port() {
        let ports = HashMap::new();

        let configs = parse_configs(
            "api-3000-70000",
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );

        assert!(configs.is_empty());
    }

    #[test]
    fn test_create_default_configs() {
        let mut ports = HashMap::new();
        ports.insert("http".to_string(), 8080);
        ports.insert("https".to_string(), 8443);

        let configs = create_default_configs(
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            Some("/path/to/config".to_string()),
        );

        assert_eq!(configs.len(), 2);

        let http_config = configs
            .iter()
            .find(|c| c.remote_port == Some(8080))
            .unwrap();
        assert_eq!(http_config.context, Some("test-context".to_string()));
        assert_eq!(http_config.namespace, "test-namespace");
        assert_eq!(http_config.service, Some("test-service".to_string()));
        assert_eq!(http_config.alias, Some("test-service".to_string()));
        assert_eq!(http_config.local_port, Some(8080));
        assert_eq!(http_config.protocol, "tcp");
        assert_eq!(http_config.kubeconfig, Some("/path/to/config".to_string()));
        assert_eq!(http_config.workload_type, Some("service".to_string()));

        let https_config = configs
            .iter()
            .find(|c| c.remote_port == Some(8443))
            .unwrap();
        assert_eq!(https_config.local_port, Some(8443));
    }

    #[test]
    fn test_create_default_configs_empty_ports() {
        let ports = HashMap::new();
        let configs = create_default_configs(
            "test-context",
            "test-namespace",
            "test-service",
            &ports,
            None,
        );
        assert!(configs.is_empty());
    }
}