use std::collections::{BTreeMap, BTreeSet};
use std::process::Stdio;
use colored::Colorize;
use serde::Serialize;
use tokio::process::Command;
use crate::strategies::XbpConfig;
use crate::utils::command_exists;
#[derive(Debug, Clone, Serialize)]
pub struct KubeContextInfo {
pub name: String,
pub current: bool,
pub cluster: Option<String>,
pub user: Option<String>,
pub namespace: Option<String>,
}
#[derive(Debug, Clone, Serialize)]
pub struct LiveNamespace {
pub name: String,
pub status: Option<String>,
pub age: Option<String>,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq, PartialOrd, Ord)]
pub struct ConfiguredNamespace {
pub namespace: String,
pub service: Option<String>,
pub env: Option<String>,
pub source: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct NamespaceInventory {
pub context: Option<String>,
pub contexts: Vec<KubeContextInfo>,
pub live: Vec<LiveNamespace>,
pub configured: Vec<ConfiguredNamespace>,
pub present: Vec<String>,
pub missing: Vec<String>,
pub live_only: Vec<String>,
pub live_error: Option<String>,
pub context_error: Option<String>,
}
pub async fn collect_namespace_inventory(
context: Option<&str>,
config: Option<&XbpConfig>,
debug: bool,
) -> NamespaceInventory {
let (contexts, context_error) = list_contexts(debug).await;
let current = contexts.iter().find(|c| c.current).map(|c| c.name.clone());
let effective_context = context
.map(str::to_string)
.or(current)
.or_else(|| {
config
.and_then(|c| c.kubernetes.as_ref())
.and_then(|k| k.default_context.clone())
});
let (live, live_error) = list_live_namespaces(effective_context.as_deref(), debug).await;
let configured = config
.map(collect_configured_namespaces)
.unwrap_or_default();
let live_names: BTreeSet<String> = live.iter().map(|n| n.name.clone()).collect();
let configured_names: BTreeSet<String> =
configured.iter().map(|c| c.namespace.clone()).collect();
let present: Vec<String> = configured_names
.intersection(&live_names)
.cloned()
.collect();
let missing: Vec<String> = configured_names
.difference(&live_names)
.cloned()
.collect();
let live_only: Vec<String> = live_names
.difference(&configured_names)
.cloned()
.collect();
NamespaceInventory {
context: effective_context,
contexts,
live,
configured,
present,
missing,
live_only,
live_error,
context_error,
}
}
pub fn print_namespace_inventory(inv: &NamespaceInventory, verbose: bool) {
println!(
"{} context={}",
"Kubernetes inventory".bright_cyan().bold(),
inv.context
.as_deref()
.unwrap_or("(none)")
.bright_white()
);
if let Some(err) = &inv.context_error {
println!(" {} {}", "contexts:".yellow(), err);
} else if !inv.contexts.is_empty() {
println!(" {}", "contexts:".bright_black());
for c in &inv.contexts {
let mark = if c.current {
"*".bright_green().to_string()
} else {
" ".into()
};
let ns = c
.namespace
.as_deref()
.map(|n| format!(" ns={n}"))
.unwrap_or_default();
println!(
" {mark} {}{}{}",
c.name.bright_white(),
c.cluster
.as_deref()
.map(|cl| format!(" cluster={cl}"))
.unwrap_or_default()
.bright_black(),
ns.bright_black()
);
}
}
if let Some(err) = &inv.live_error {
println!(" {} {}", "live namespaces:".yellow(), err);
} else {
println!(
" {} ({}):",
"live namespaces".bright_black(),
inv.live.len()
);
if inv.live.is_empty() {
println!(" {}", "(none)".dimmed());
} else {
for ns in &inv.live {
let status = ns.status.as_deref().unwrap_or("?");
let age = ns.age.as_deref().unwrap_or("");
let cfg = inv.configured.iter().any(|c| c.namespace == ns.name);
let tag = if cfg {
" [config]".bright_green().to_string()
} else {
String::new()
};
println!(
" {} {} {}{}",
ns.name.bright_white(),
status.bright_black(),
age.bright_black(),
tag
);
}
}
}
if inv.configured.is_empty() {
println!(
" {} {}",
"configured (xbp.yaml):".bright_black(),
"(none found)".dimmed()
);
} else {
println!(
" {} ({} unique):",
"configured (xbp.yaml)".bright_black(),
inv.configured
.iter()
.map(|c| c.namespace.as_str())
.collect::<BTreeSet<_>>()
.len()
);
let mut by_ns: BTreeMap<String, Vec<&ConfiguredNamespace>> = BTreeMap::new();
for c in &inv.configured {
by_ns.entry(c.namespace.clone()).or_default().push(c);
}
for (ns, refs) in by_ns {
let on_cluster = inv.live.iter().any(|l| l.name == ns);
let mark = if inv.live_error.is_some() {
"?".yellow().to_string()
} else if on_cluster {
"ok".green().to_string()
} else {
"missing".red().to_string()
};
println!(" [{mark}] {}", ns.bright_white());
if verbose {
for r in refs {
let svc = r.service.as_deref().unwrap_or("-");
let env = r.env.as_deref().unwrap_or("-");
println!(
" {} service={} env={}",
r.source.bright_black(),
svc,
env
);
}
} else {
let services: BTreeSet<_> = refs
.iter()
.filter_map(|r| r.service.as_deref())
.collect();
if !services.is_empty() {
println!(
" services: {}",
services.into_iter().collect::<Vec<_>>().join(", ").bright_black()
);
}
}
}
}
if inv.live_error.is_none() && !inv.configured.is_empty() {
if !inv.missing.is_empty() {
println!(
" {} {}",
"missing on cluster:".yellow().bold(),
inv.missing.join(", ")
);
println!(
" {} kubectl create namespace <name>",
"hint:".bright_black()
);
}
if !inv.present.is_empty() {
println!(
" {} {}",
"present on cluster:".green(),
inv.present.join(", ")
);
}
}
}
pub fn collect_configured_namespaces(config: &XbpConfig) -> Vec<ConfiguredNamespace> {
let mut out = Vec::new();
let mut seen: BTreeSet<(String, Option<String>, Option<String>)> = BTreeSet::new();
if let Some(k8s) = &config.kubernetes {
if let Some(ns) = k8s
.default_namespace
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let key = (ns.to_string(), None, None);
if seen.insert(key) {
out.push(ConfiguredNamespace {
namespace: ns.to_string(),
service: None,
env: None,
source: "kubernetes.default_namespace".into(),
});
}
}
}
for svc in config.services.as_deref().unwrap_or(&[]) {
let Some(deploy) = &svc.deploy else {
continue;
};
for (env_name, env_cfg) in &deploy.envs {
if let Some(ns) = env_cfg
.namespace
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty())
{
let key = (
ns.to_string(),
Some(svc.name.clone()),
Some(env_name.clone()),
);
if seen.insert(key) {
out.push(ConfiguredNamespace {
namespace: ns.to_string(),
service: Some(svc.name.clone()),
env: Some(env_name.clone()),
source: format!(
"services.{}.deploy.envs.{}.namespace",
svc.name, env_name
),
});
}
}
}
}
out.sort();
out
}
async fn list_contexts(debug: bool) -> (Vec<KubeContextInfo>, Option<String>) {
if !command_exists("kubectl") {
return (vec![], Some("kubectl not found in PATH".into()));
}
let output = Command::new("kubectl")
.args([
"config",
"get-contexts",
"-o",
"name",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
let names = match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout)
.lines()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.collect::<Vec<_>>(),
Ok(o) => {
let err = String::from_utf8_lossy(&o.stderr).trim().to_string();
return (vec![], Some(if err.is_empty() { "kubectl config get-contexts failed".into() } else { err }));
}
Err(e) => return (vec![], Some(format!("kubectl: {e}"))),
};
let current = Command::new("kubectl")
.args(["config", "current-context"])
.stdout(Stdio::piped())
.stderr(Stdio::null())
.output()
.await
.ok()
.and_then(|o| {
if o.status.success() {
Some(String::from_utf8_lossy(&o.stdout).trim().to_string())
} else {
None
}
});
let detailed = kubectl_contexts_json(debug).await.unwrap_or_default();
let mut by_name: BTreeMap<String, KubeContextInfo> = BTreeMap::new();
for d in detailed {
by_name.insert(d.name.clone(), d);
}
for name in names {
by_name.entry(name.clone()).or_insert_with(|| KubeContextInfo {
name: name.clone(),
current: current.as_deref() == Some(name.as_str()),
cluster: None,
user: None,
namespace: None,
});
if let Some(c) = by_name.get_mut(&name) {
c.current = current.as_deref() == Some(name.as_str());
}
}
(by_name.into_values().collect(), None)
}
async fn kubectl_contexts_json(_debug: bool) -> Result<Vec<KubeContextInfo>, String> {
let output = Command::new("kubectl")
.args(["config", "view", "-o", "json"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| e.to_string())?;
if !output.status.success() {
return Err(String::from_utf8_lossy(&output.stderr).to_string());
}
let v: serde_json::Value =
serde_json::from_slice(&output.stdout).map_err(|e| e.to_string())?;
let current = v
.get("current-context")
.and_then(|c| c.as_str())
.map(str::to_string);
let mut out = Vec::new();
if let Some(arr) = v.get("contexts").and_then(|c| c.as_array()) {
for item in arr {
let name = item
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let ctx = item.get("context");
out.push(KubeContextInfo {
current: current.as_deref() == Some(name.as_str()),
cluster: ctx
.and_then(|c| c.get("cluster"))
.and_then(|c| c.as_str())
.map(str::to_string),
user: ctx
.and_then(|c| c.get("user"))
.and_then(|c| c.as_str())
.map(str::to_string),
namespace: ctx
.and_then(|c| c.get("namespace"))
.and_then(|c| c.as_str())
.map(str::to_string),
name,
});
}
}
Ok(out)
}
async fn list_live_namespaces(
context: Option<&str>,
debug: bool,
) -> (Vec<LiveNamespace>, Option<String>) {
if !command_exists("kubectl") {
return (vec![], Some("kubectl not found in PATH".into()));
}
let mut args = vec![
"get".to_string(),
"namespaces".to_string(),
"-o".to_string(),
"json".to_string(),
"--request-timeout=12s".to_string(),
];
if let Some(ctx) = context.filter(|s| !s.is_empty()) {
args.insert(0, ctx.to_string());
args.insert(0, "--context".to_string());
}
if debug {
eprintln!("debug: kubectl {}", args.join(" "));
}
let str_args: Vec<&str> = args.iter().map(String::as_str).collect();
let output = Command::new("kubectl")
.args(&str_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await;
let output = match output {
Ok(o) => o,
Err(e) => return (vec![], Some(format!("kubectl: {e}"))),
};
if !output.status.success() {
let err = String::from_utf8_lossy(&output.stderr).trim().to_string();
let summarized = xbp_k8s::summarize_kubectl_failure(&err);
return (vec![], Some(summarized));
}
let v: serde_json::Value = match serde_json::from_slice(&output.stdout) {
Ok(v) => v,
Err(e) => return (vec![], Some(format!("parse namespaces json: {e}"))),
};
let mut live = Vec::new();
if let Some(items) = v.get("items").and_then(|i| i.as_array()) {
for item in items {
let name = item
.pointer("/metadata/name")
.and_then(|n| n.as_str())
.unwrap_or("")
.to_string();
if name.is_empty() {
continue;
}
let status = item
.pointer("/status/phase")
.and_then(|p| p.as_str())
.map(str::to_string);
let created = item
.pointer("/metadata/creationTimestamp")
.and_then(|t| t.as_str())
.map(str::to_string);
live.push(LiveNamespace {
name,
status,
age: created,
});
}
}
live.sort_by(|a, b| a.name.cmp(&b.name));
(live, None)
}
pub async fn ensure_configured_namespaces(
context: Option<&str>,
missing: &[String],
yes: bool,
debug: bool,
) -> Result<Vec<String>, String> {
if missing.is_empty() {
return Ok(vec![]);
}
if !command_exists("kubectl") {
return Err("kubectl not found".into());
}
let mut created = Vec::new();
for ns in missing {
if !yes {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
println!(
" {} skip create namespace `{ns}` (non-interactive; pass --yes)",
"note".yellow()
);
continue;
}
let ok = dialoguer::Confirm::new()
.with_prompt(format!("Create missing namespace `{ns}` on the cluster?"))
.default(true)
.interact()
.unwrap_or(false);
if !ok {
continue;
}
}
let mut args = vec![
"create".to_string(),
"namespace".to_string(),
ns.clone(),
"--dry-run=client".to_string(),
"-o".to_string(),
"yaml".to_string(),
];
let mut apply_args = vec![
"create".to_string(),
"namespace".to_string(),
ns.clone(),
];
if let Some(ctx) = context.filter(|s| !s.is_empty()) {
apply_args.insert(0, ctx.to_string());
apply_args.insert(0, "--context".to_string());
args.insert(0, ctx.to_string());
args.insert(0, "--context".to_string());
}
let _ = args; if debug {
eprintln!("debug: kubectl {}", apply_args.join(" "));
}
let str_args: Vec<&str> = apply_args.iter().map(String::as_str).collect();
let output = Command::new("kubectl")
.args(&str_args)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.output()
.await
.map_err(|e| e.to_string())?;
if output.status.success() {
println!(" {} created namespace `{ns}`", "ok".green());
created.push(ns.clone());
} else {
let err = String::from_utf8_lossy(&output.stderr);
if err.contains("AlreadyExists") {
println!(" {} namespace `{ns}` already exists", "ok".green());
created.push(ns.clone());
} else {
println!(
" {} create namespace `{ns}`: {}",
"err".red(),
err.trim()
);
}
}
}
Ok(created)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::strategies::{
KubernetesProjectConfig, ServiceDeployConfig, ServiceDeployEnvConfig, XbpConfig,
};
use std::collections::HashMap;
fn minimal_config_with_ns(default_ns: &str, service_ns: &[(&str, &str, &str)]) -> XbpConfig {
let mut services = Vec::new();
let mut by_service: HashMap<String, HashMap<String, String>> = HashMap::new();
for (svc, env, ns) in service_ns {
by_service
.entry((*svc).into())
.or_default()
.insert((*env).into(), (*ns).into());
}
for (name, envs_map) in by_service {
let mut envs = serde_json::Map::new();
for (env, ns) in envs_map {
envs.insert(
env,
serde_json::json!({ "namespace": ns }),
);
}
services.push(serde_json::json!({
"name": name,
"target": "docker",
"branch": "main",
"port": 8080,
"deploy": {
"provider": "kubernetes",
"envs": envs
}
}));
}
let value = serde_json::json!({
"project_name": "demo",
"version": "1.0.0",
"port": 8080,
"build_dir": "dist",
"kubernetes": { "default_namespace": default_ns },
"services": services
});
serde_json::from_value(value).expect("minimal xbp config")
}
#[test]
fn collects_default_and_service_namespaces() {
let cfg = minimal_config_with_ns(
"default",
&[
("athena", "production", "athena"),
("athena", "staging", "athena-staging"),
],
);
let list = collect_configured_namespaces(&cfg);
let names: BTreeSet<_> = list.iter().map(|c| c.namespace.as_str()).collect();
assert!(names.contains("default"), "{names:?}");
assert!(names.contains("athena"), "{names:?}");
assert!(names.contains("athena-staging"), "{names:?}");
let _ = ServiceDeployConfig::default();
let _ = ServiceDeployEnvConfig::default();
let _ = KubernetesProjectConfig::default();
}
}