lade 0.17.2

Automatically load secrets from your preferred vault as environment variables, and clear them once your shell command is over.
use crate::common;
use predicates::prelude::PredicateBooleanExt;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
use tempfile::tempdir;

#[test]
fn network_k3d_kubectl_provider_lifecycle() {
    assert!(is_ready_for_k3d_test(), "k3d prerequisites are required");

    let cluster = env::var("LADE_K3D_CLUSTER").unwrap_or_else(|_| "lade-k3d-shared".to_string());
    let context = format!("k3d-{cluster}");
    let kube_dir = tempdir().expect("kubeconfig dir");
    let kubeconfig = kube_dir.path().join("config").display().to_string();
    let cluster_config = write_cluster_config(kube_dir.path());
    ensure_cluster_context(&cluster, &context, &kubeconfig, &cluster_config);
    let namespace = "lade-k3d-ns";
    let service = "http-echo";
    let port_local = "18080";
    let port_remote = "8080";
    let payload_arg = r#"'{"ping":"pong"}'"#;

    run_ok(
        "kubectl",
        &kubeconfig,
        &["--context", &context, "apply", "-f", "k3d-manifests.yaml"],
    );
    run_ok(
        "kubectl",
        &kubeconfig,
        &[
            "--context",
            &context,
            "-n",
            namespace,
            "rollout",
            "status",
            &format!("deployment/{service}"),
            "--timeout=120s",
        ],
    );

    let server_url = run_capture(
        "kubectl",
        &kubeconfig,
        &[
            "--context",
            &context,
            "config",
            "view",
            "--raw",
            "-o",
            &format!("jsonpath={{.clusters[?(@.name==\"{context}\")].cluster.server}}"),
        ],
    );
    let authority = normalize_authority(&server_url);

    let dir = tempdir().expect("tmp dir");
    let home = tempdir().expect("home dir");
    let rule = format!(
        "\"^curl .*http://127.0.0.1:{port_local}/$\":\n  \"{port_local}\": kubectl://{authority}/{context}/{namespace}/service/{service}/{port_remote}\n"
    );
    fs::write(dir.path().join("lade.yml"), rule).expect("write lade.yml");

    common::lade(home.path())
        .current_dir(dir.path())
        .env("KUBECONFIG", &kubeconfig)
        .args([
            "inject",
            "--no-mask",
            "curl",
            "-fsS",
            "-X",
            "POST",
            "-H",
            "content-type:application/json",
            "-d",
            payload_arg,
            &format!("http://127.0.0.1:{port_local}/"),
        ])
        .assert()
        .success()
        .stdout(
            predicates::str::contains("\"method\": \"POST\"")
                .and(predicates::str::contains("\"ping\": \"pong\"")),
        );

    let set_output = common::lade(home.path())
        .current_dir(dir.path())
        .env("KUBECONFIG", &kubeconfig)
        .args(["set", &format!("curl http://127.0.0.1:{port_local}/")])
        .assert()
        .success()
        .get_output()
        .stdout
        .clone();
    let set_stdout = String::from_utf8_lossy(&set_output);
    let pid = extract_pid(&set_stdout).expect("LADE_NETWORK_PIDS in set output");
    assert!(
        is_pid_running(&pid),
        "detached provider pid not running: {pid}"
    );

    common::lade(home.path())
        .current_dir(dir.path())
        .env("KUBECONFIG", &kubeconfig)
        .env("LADE_NETWORK_PIDS", &pid)
        .args(["unset", &format!("curl http://127.0.0.1:{port_local}/")])
        .assert()
        .success();

    std::thread::sleep(std::time::Duration::from_secs(1));
    assert!(
        !is_pid_running(&pid),
        "detached provider pid still running after unset: {pid}"
    );
}

fn is_ready_for_k3d_test() -> bool {
    has_cmd("k3d")
        && has_cmd("kubectl")
        && has_cmd("docker")
        && has_cmd("curl")
        && Command::new("docker")
            .arg("info")
            .stdout(Stdio::null())
            .stderr(Stdio::null())
            .status()
            .is_ok_and(|s| s.success())
}

fn has_cmd(cmd: &str) -> bool {
    Command::new("sh")
        .args(["-c", &format!("command -v {cmd} >/dev/null 2>&1")])
        .status()
        .is_ok_and(|s| s.success())
}

fn ensure_cluster_context(cluster: &str, context: &str, kubeconfig: &str, config: &Path) {
    if cluster_exists(cluster) {
        start_cluster(cluster);
        write_cluster_kubeconfig(cluster, kubeconfig);
        assert!(
            has_kube_context(context, kubeconfig),
            "exported kubeconfig for cluster {cluster}, but context {context} is missing"
        );
        return;
    }
    let status = Command::new("k3d")
        .env("KUBECONFIG", kubeconfig)
        .arg("cluster")
        .arg("create")
        .arg("--config")
        .arg(config)
        .arg("--wait")
        .output()
        .expect("spawn k3d cluster create");
    assert!(
        status.status.success(),
        "k3d cluster create failed: {}",
        String::from_utf8_lossy(&status.stderr)
    );
    assert!(
        has_kube_context(context, kubeconfig),
        "created cluster {cluster}, but context {context} is missing from isolated kubeconfig"
    );
}

fn start_cluster(cluster: &str) {
    let output = Command::new("k3d")
        .args(["cluster", "start", cluster, "--wait"])
        .output()
        .expect("spawn k3d cluster start");
    assert!(
        output.status.success(),
        "k3d cluster start failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
}

fn write_cluster_config(dir: &Path) -> PathBuf {
    let manifest = std::env::current_dir()
        .expect("current dir")
        .join("k3d-manifests.yaml");
    let config = fs::read_to_string("k3d.yaml")
        .expect("read k3d.yaml")
        .replace("./k3d-manifests.yaml", &manifest.display().to_string());
    let path = dir.join("k3d.yaml");
    fs::write(&path, config).expect("write generated k3d config");
    path
}

fn cluster_exists(cluster: &str) -> bool {
    let output = Command::new("k3d")
        .args(["cluster", "list", "-o", "json"])
        .output()
        .expect("spawn k3d cluster list");
    assert!(
        output.status.success(),
        "k3d cluster list failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    let clusters: serde_json::Value =
        serde_json::from_slice(&output.stdout).expect("parse k3d cluster list JSON");
    clusters
        .as_array()
        .expect("k3d cluster list JSON array")
        .iter()
        .any(|c| c.get("name").and_then(serde_json::Value::as_str) == Some(cluster))
}

fn write_cluster_kubeconfig(cluster: &str, kubeconfig: &str) {
    let output = Command::new("k3d")
        .args(["kubeconfig", "get", cluster])
        .output()
        .expect("spawn k3d kubeconfig get");
    assert!(
        output.status.success(),
        "k3d kubeconfig get failed: {}",
        String::from_utf8_lossy(&output.stderr)
    );
    fs::write(kubeconfig, output.stdout).expect("write isolated kubeconfig");
}

fn has_kube_context(context: &str, kubeconfig: &str) -> bool {
    let output = Command::new("kubectl")
        .env("KUBECONFIG", kubeconfig)
        .args(["config", "get-contexts", "-o", "name"])
        .output();
    let Ok(output) = output else {
        return false;
    };
    if !output.status.success() {
        return false;
    }
    String::from_utf8_lossy(&output.stdout)
        .lines()
        .any(|line| line.trim() == context)
}

fn run_ok(cmd: &str, kubeconfig: &str, args: &[&str]) {
    let output = Command::new(cmd)
        .env("KUBECONFIG", kubeconfig)
        .args(args)
        .output()
        .expect("spawn command");
    if output.status.success() {
        return;
    }
    panic!(
        "{cmd} {:?} failed: {}",
        args,
        String::from_utf8_lossy(&output.stderr)
    );
}

fn run_capture(cmd: &str, kubeconfig: &str, args: &[&str]) -> String {
    let output = Command::new(cmd)
        .env("KUBECONFIG", kubeconfig)
        .args(args)
        .output()
        .expect("spawn command");
    if !output.status.success() {
        panic!(
            "{cmd} {:?} failed: {}",
            args,
            String::from_utf8_lossy(&output.stderr)
        );
    }
    String::from_utf8_lossy(&output.stdout).trim().to_string()
}

fn normalize_authority(server_url: &str) -> String {
    server_url
        .trim_start_matches("https://")
        .trim_start_matches("http://")
        .split('/')
        .next()
        .expect("server authority")
        .to_string()
}

fn extract_pid(set_stdout: &str) -> Option<String> {
    for prefix in ["LADE_NETWORK_PIDS='", "LADE_NETWORK_PIDS="] {
        let Some(start) = set_stdout.find(prefix) else {
            continue;
        };
        let rest = &set_stdout[start + prefix.len()..];
        let raw = rest
            .split(';')
            .next()
            .unwrap_or(rest)
            .trim()
            .trim_matches('\'')
            .trim_matches('"');
        if !raw.is_empty() {
            return Some(raw.to_string());
        }
    }
    None
}

fn is_pid_running(pid: &str) -> bool {
    Command::new("kill")
        .args(["-0", pid])
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status()
        .is_ok_and(|s| s.success())
}