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>,
}
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(),
)
}
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}")
}
}
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> {
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()]
};
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()
}
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,
};
let _cmd = build_kubectl_command(backend, &target, &["get", "pods"]);
}
}