pipedash-plugin-tekton 0.10.0

Tekton CD plugin for Pipedash
Documentation
use std::{
    collections::HashMap,
    fmt,
};

use pipedash_plugin_api::{
    PluginError,
    PluginResult,
};

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum NamespaceMode {
    All,

    Custom,
}

impl NamespaceMode {
    pub fn from_config_value(value: &str) -> Self {
        match value.trim().to_lowercase().as_str() {
            "custom" => NamespaceMode::Custom,
            _ => NamespaceMode::All,
        }
    }
}

pub(crate) fn expand_path(path: &str) -> String {
    if let Some(stripped) = path.strip_prefix("~/") {
        if let Some(home) = dirs::home_dir() {
            return home.join(stripped).to_string_lossy().to_string();
        }
    }

    let expanded = shellexpand::env(path).unwrap_or(std::borrow::Cow::Borrowed(path));
    expanded.to_string()
}

pub(crate) fn get_default_kubeconfig_path() -> String {
    if let Ok(kubeconfig_env) = std::env::var("KUBECONFIG") {
        if !kubeconfig_env.trim().is_empty() {
            return kubeconfig_env;
        }
    }

    if let Some(home) = dirs::home_dir() {
        return home.join(".kube/config").to_string_lossy().to_string();
    }

    "~/.kube/config".to_string()
}

pub(crate) fn split_kubeconfig_paths(path: &str) -> Vec<String> {
    let separator = if cfg!(windows) { ';' } else { ':' };

    path.split(separator)
        .map(|p| p.trim())
        .filter(|p| !p.is_empty())
        .map(expand_path)
        .collect()
}

pub(crate) fn get_kubeconfig_path(config: &HashMap<String, String>) -> Option<String> {
    config
        .get("kubeconfig_path")
        .filter(|path| !path.trim().is_empty())
        .map(|path| expand_path(path))
}

pub(crate) const KUBECONFIG_CONTENT_KEY: &str = "token";

#[derive(Clone, PartialEq, Eq)]
pub(crate) enum KubeconfigSource {
    Content(String),
    Path(Option<String>),
}

impl fmt::Debug for KubeconfigSource {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Content(_) => f.write_str("Content(<redacted>)"),
            Self::Path(path) => f.debug_tuple("Path").field(path).finish(),
        }
    }
}

pub(crate) fn get_kubeconfig_source(config: &HashMap<String, String>) -> KubeconfigSource {
    match config
        .get(KUBECONFIG_CONTENT_KEY)
        .filter(|content| !content.trim().is_empty())
    {
        Some(content) => KubeconfigSource::Content(content.clone()),
        None => KubeconfigSource::Path(get_kubeconfig_path(config)),
    }
}

pub(crate) fn get_context(config: &HashMap<String, String>) -> Option<String> {
    config
        .get("context")
        .filter(|ctx| !ctx.trim().is_empty())
        .cloned()
}

pub(crate) fn get_selected_pipelines(config: &HashMap<String, String>) -> Vec<String> {
    config
        .get("selected_items")
        .map(|items| {
            items
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

pub(crate) fn get_namespace_mode(config: &HashMap<String, String>) -> NamespaceMode {
    config
        .get("namespace_mode")
        .filter(|mode| !mode.trim().is_empty())
        .map(|mode| NamespaceMode::from_config_value(mode))
        .unwrap_or(NamespaceMode::All)
}

pub(crate) fn get_namespaces(config: &HashMap<String, String>) -> Vec<String> {
    config
        .get("namespaces")
        .map(|items| {
            items
                .split(',')
                .map(|s| s.trim().to_string())
                .filter(|s| !s.is_empty())
                .collect()
        })
        .unwrap_or_default()
}

pub(crate) const DEFAULT_CONTROLLER_NAMESPACE: &str = "tekton-pipelines";

pub(crate) fn get_controller_namespace(config: &HashMap<String, String>) -> String {
    config
        .get("tekton_namespace")
        .map(|namespace| namespace.trim())
        .filter(|namespace| !namespace.is_empty())
        .unwrap_or(DEFAULT_CONTROLLER_NAMESPACE)
        .to_string()
}

pub(crate) fn parse_pipeline_id(id: &str) -> PluginResult<(i64, String, String)> {
    let parts: Vec<&str> = id.split("__").collect();

    if parts.len() < 4 || parts[0] != "tekton" {
        return Err(PluginError::InvalidConfig(format!(
            "Invalid Tekton pipeline ID format: {}. Expected: tekton__{{provider_id}}__{{namespace}}__{{pipeline_name}}",
            id
        )));
    }

    let provider_id = parts[1].parse::<i64>().map_err(|_| {
        PluginError::InvalidConfig(format!("Invalid provider ID in pipeline ID: {}", id))
    })?;

    let namespace = parts[2].to_string();
    let pipeline_name = parts[3..].join("__");

    Ok((provider_id, namespace, pipeline_name))
}

const MAX_KUBE_NAME_BYTES: usize = 253;

pub(crate) fn is_kube_name(name: &str) -> bool {
    let bytes = name.as_bytes();
    let alphanumeric = |b: &u8| b.is_ascii_lowercase() || b.is_ascii_digit();
    bytes.len() <= MAX_KUBE_NAME_BYTES
        && bytes.first().is_some_and(alphanumeric)
        && bytes.last().is_some_and(alphanumeric)
        && bytes
            .iter()
            .all(|b| alphanumeric(b) || *b == b'-' || *b == b'.')
}

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

    #[test]
    fn test_parse_pipeline_id() {
        let id = "tekton__1__default__my-pipeline";
        let (provider_id, namespace, pipeline_name) = parse_pipeline_id(id).unwrap();
        assert_eq!(provider_id, 1);
        assert_eq!(namespace, "default");
        assert_eq!(pipeline_name, "my-pipeline");
    }

    #[test]
    fn test_parse_pipeline_id_with_dashes() {
        let id = "tekton__2__tekton-pipelines__complex-pipeline-name";
        let (provider_id, namespace, pipeline_name) = parse_pipeline_id(id).unwrap();
        assert_eq!(provider_id, 2);
        assert_eq!(namespace, "tekton-pipelines");
        assert_eq!(pipeline_name, "complex-pipeline-name");
    }

    #[test]
    fn test_parse_invalid_pipeline_id() {
        let id = "invalid__1__namespace__pipeline";
        assert!(parse_pipeline_id(id).is_err());
    }

    #[test]
    fn kube_names_are_lowercase_dns_subdomains_up_to_253_bytes() {
        let longest = "a".repeat(253);
        let valid = ["a", "build-1-clone", "pod.name-1", "0", longest.as_str()];
        for name in valid {
            assert!(is_kube_name(name), "{name}");
        }

        let too_long = "a".repeat(254);
        let invalid = [
            "",
            "UPPER",
            "has space",
            "a/b",
            "../x",
            "..",
            "a%2fb",
            "a:b",
            "a_b",
            "-lead",
            "trail-",
            ".lead",
            "trail.",
            "new\nline",
            "naïve",
            too_long.as_str(),
        ];
        for name in invalid {
            assert!(!is_kube_name(name), "{name:?}");
        }
    }

    fn config(pairs: &[(&str, &str)]) -> HashMap<String, String> {
        pairs
            .iter()
            .map(|(key, value)| (key.to_string(), value.to_string()))
            .collect()
    }

    #[test]
    fn kubeconfig_content_is_parsed_from_config() {
        let source = get_kubeconfig_source(&config(&[("token", "apiVersion: v1\nkind: Config\n")]));

        assert_eq!(
            source,
            KubeconfigSource::Content("apiVersion: v1\nkind: Config\n".to_string())
        );
    }

    #[test]
    fn kubeconfig_content_wins_over_the_path() {
        let source = get_kubeconfig_source(&config(&[
            ("kubeconfig_path", "/home/me/.kube/config"),
            ("token", "apiVersion: v1"),
        ]));

        assert_eq!(
            source,
            KubeconfigSource::Content("apiVersion: v1".to_string())
        );
    }

    #[test]
    fn blank_content_falls_back_to_the_path() {
        let source = get_kubeconfig_source(&config(&[
            ("kubeconfig_path", "/home/me/.kube/config"),
            ("token", "  \n "),
        ]));

        assert_eq!(
            source,
            KubeconfigSource::Path(Some("/home/me/.kube/config".to_string()))
        );
    }

    #[test]
    fn kubeconfig_content_is_never_printed() {
        let source = get_kubeconfig_source(&config(&[("token", "token: s3cr3t-value")]));

        assert!(!format!("{source:?}").contains("s3cr3t-value"));
    }
}