xbp 10.46.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Shared kubectl/kustomize helpers for deploy engine and `xbp kubernetes`/`k8s`.

use std::path::{Path, PathBuf};
use std::process::Stdio;

use crate::utils::command_exists;
use tokio::process::Command;

#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum KubectlBackend {
    Kubectl,
    Microk8s,
}

impl KubectlBackend {
    pub fn label(self) -> &'static str {
        match self {
            Self::Kubectl => "kubectl",
            Self::Microk8s => "microk8s kubectl",
        }
    }
}

#[derive(Debug, Clone, Default)]
pub struct KubeTarget {
    pub context: Option<String>,
    pub namespace: Option<String>,
    pub kubeconfig: Option<String>,
}

/// Detect kubectl or microk8s kubectl.
pub fn ensure_kube_cli() -> Result<KubectlBackend, String> {
    if command_exists("kubectl") {
        return Ok(KubectlBackend::Kubectl);
    }
    if command_exists("microk8s") {
        return Ok(KubectlBackend::Microk8s);
    }
    Err(
        "kubectl not found in PATH (and microk8s unavailable). Install kubectl to use Kubernetes deploy/apply."
            .to_string(),
    )
}

/// Normalize workload shorthand: `athena-runtime` → `deployment/athena-runtime`.
pub fn normalize_workload(workload: &str) -> String {
    let w = workload.trim();
    if w.is_empty() {
        return w.to_string();
    }
    if w.contains('/') {
        w.to_string()
    } else {
        format!("deployment/{w}")
    }
}

/// True when path looks like a kustomize root (kustomization.yaml present).
pub fn is_kustomize_dir(path: &Path) -> bool {
    if !path.is_dir() {
        return false;
    }
    path.join("kustomization.yaml").exists()
        || path.join("kustomization.yml").exists()
        || path.join("Kustomization").exists()
}

pub fn build_kubectl_command(
    backend: KubectlBackend,
    target: &KubeTarget,
    args: &[&str],
) -> Command {
    let mut cmd = match backend {
        KubectlBackend::Kubectl => Command::new("kubectl"),
        KubectlBackend::Microk8s => {
            let mut c = Command::new("microk8s");
            c.arg("kubectl");
            c
        }
    };
    if let Some(ctx) = target.context.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
        cmd.args(["--context", ctx]);
    }
    if let Some(ns) = target
        .namespace
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        cmd.args(["-n", ns]);
    }
    if let Some(kubeconfig) = target
        .kubeconfig
        .as_deref()
        .map(str::trim)
        .filter(|s| !s.is_empty())
    {
        cmd.env("KUBECONFIG", kubeconfig);
    }
    cmd.args(args);
    cmd.stdout(Stdio::piped()).stderr(Stdio::piped());
    cmd
}

pub async fn kubectl_output(
    target: &KubeTarget,
    args: &[&str],
    debug: bool,
) -> Result<String, String> {
    let backend = ensure_kube_cli()?;
    let mut cmd = build_kubectl_command(backend, target, args);
    if debug {
        eprintln!("debug: {} {}", backend.label(), args.join(" "));
    }
    let output = cmd
        .output()
        .await
        .map_err(|e| format!("{} failed to start: {e}", backend.label()))?;
    let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
    if output.status.success() {
        if stdout.is_empty() {
            Ok(stderr)
        } else {
            Ok(stdout)
        }
    } else {
        let detail = if stderr.is_empty() { stdout } else { stderr };
        Err(format!(
            "{} {} failed: {detail}",
            backend.label(),
            args.join(" ")
        ))
    }
}

pub async fn kustomize_build(path: &Path, debug: bool) -> Result<String, String> {
    // Prefer kubectl kustomize so users don't need standalone kustomize.
    if command_exists("kubectl") {
        return kubectl_output(
            &KubeTarget::default(),
            &["kustomize", &path.display().to_string()],
            debug,
        )
        .await;
    }
    if command_exists("kustomize") {
        let output = Command::new("kustomize")
            .arg("build")
            .arg(path)
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .output()
            .await
            .map_err(|e| format!("kustomize failed to start: {e}"))?;
        if output.status.success() {
            return Ok(String::from_utf8_lossy(&output.stdout).to_string());
        }
        return Err(format!(
            "kustomize build failed: {}",
            String::from_utf8_lossy(&output.stderr)
        ));
    }
    Err("neither kubectl nor kustomize available for render".to_string())
}

pub async fn apply_path(
    target: &KubeTarget,
    path: &Path,
    dry_run: bool,
    server_side: bool,
    debug: bool,
) -> Result<String, String> {
    if !path.exists() {
        return Err(format!("manifest path does not exist: {}", path.display()));
    }
    let path_str = path.display().to_string();
    let mut args: Vec<String> = vec!["apply".to_string()];
    if is_kustomize_dir(path) {
        args.push("-k".to_string());
        args.push(path_str);
    } else {
        args.push("-f".to_string());
        args.push(path_str);
    }
    if dry_run {
        args.push("--dry-run=server".to_string());
    }
    if server_side {
        args.push("--server-side".to_string());
    }
    let arg_refs: Vec<&str> = args.iter().map(String::as_str).collect();
    kubectl_output(target, &arg_refs, debug).await
}

pub async fn diff_path(target: &KubeTarget, path: &Path, debug: bool) -> Result<String, String> {
    let path_str = path.display().to_string();
    let args = if is_kustomize_dir(path) {
        vec!["diff", "-k", path_str.as_str()]
    } else {
        vec!["diff", "-f", path_str.as_str()]
    };
    // kubectl diff exits 1 when differences exist — treat that as success with output.
    let backend = ensure_kube_cli()?;
    let mut cmd = build_kubectl_command(backend, target, &args);
    if debug {
        eprintln!("debug: {} {}", backend.label(), args.join(" "));
    }
    let output = cmd
        .output()
        .await
        .map_err(|e| format!("kubectl diff failed to start: {e}"))?;
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
    if output.status.success() || output.status.code() == Some(1) {
        if stdout.trim().is_empty() {
            Ok(if stderr.trim().is_empty() {
                "(no diff)".to_string()
            } else {
                stderr
            })
        } else {
            Ok(stdout)
        }
    } else {
        Err(format!("kubectl diff failed: {stderr}"))
    }
}

pub async fn rollout_status(
    target: &KubeTarget,
    workload: &str,
    timeout: &str,
    debug: bool,
) -> Result<String, String> {
    let wl = normalize_workload(workload);
    kubectl_output(
        target,
        &["rollout", "status", &wl, &format!("--timeout={timeout}")],
        debug,
    )
    .await
}

pub fn summarize_kubectl(out: &str) -> String {
    out.lines()
        .next()
        .unwrap_or("ok")
        .trim()
        .chars()
        .take(200)
        .collect()
}

/// Resolve a manifest path relative to project root or service root.
pub fn resolve_manifest_path(
    project_root: &Path,
    service_root: Option<&Path>,
    relative: &str,
) -> PathBuf {
    let rel = PathBuf::from(relative);
    if rel.is_absolute() {
        return rel;
    }
    if let Some(root) = service_root {
        let under = root.join(&rel);
        if under.exists() {
            return under;
        }
    }
    project_root.join(rel)
}

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

    #[test]
    fn workload_shorthand() {
        assert_eq!(
            normalize_workload("athena-runtime"),
            "deployment/athena-runtime"
        );
        assert_eq!(
            normalize_workload("deployment/athena-runtime"),
            "deployment/athena-runtime"
        );
        assert_eq!(
            normalize_workload("statefulset/db"),
            "statefulset/db"
        );
    }

    #[test]
    fn kubectl_args_include_context() {
        let backend = KubectlBackend::Kubectl;
        let target = KubeTarget {
            context: Some("prod".to_string()),
            namespace: Some("athena-system".to_string()),
            kubeconfig: None,
        };
        // Just ensure builder doesn't panic; Command isn't easily introspected.
        let _cmd = build_kubectl_command(backend, &target, &["get", "pods"]);
    }
}