use std::path::{Path, PathBuf};
use async_trait::async_trait;
use thiserror::Error;
use crate::container_engine::EnvLookup;
use crate::kube::{ClusterKind, KubeSnapshot};
#[derive(Debug, Error, PartialEq, Eq)]
pub enum ClusterError {
#[error("no kubeconfig found")]
KubeconfigNotFound,
#[error("cluster unreachable: {0}")]
Unreachable(String),
#[error("forbidden: cannot access {resource}{}", match namespace {
Some(ns) => format!(" in namespace {ns}"),
None => String::new(),
})]
Forbidden {
resource: &'static str,
namespace: Option<String>,
},
#[error("metrics-server unavailable on this cluster")]
MetricsUnavailable,
#[error("snapshot stale (last update {since_secs}s ago)")]
Stale { since_secs: u64 },
#[error("cluster engine error: {0}")]
Other(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum KubeconfigSource {
Env(PathBuf),
Home(PathBuf),
InCluster,
None,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub enum KubeScope {
#[default]
AllNamespaces,
Namespace(String),
}
impl KubeScope {
pub fn namespace(&self) -> Option<&str> {
match self {
Self::AllNamespaces => None,
Self::Namespace(ns) => Some(ns.as_str()),
}
}
pub fn label(&self) -> &str {
self.namespace().unwrap_or("")
}
pub fn is_all_namespaces(&self) -> bool {
matches!(self, Self::AllNamespaces)
}
}
pub fn is_valid_namespace(name: &str) -> bool {
if name.is_empty() || name.len() > 63 {
return false;
}
let bytes = name.as_bytes();
let is_alnum = |b: u8| b.is_ascii_lowercase() || b.is_ascii_digit();
if !is_alnum(bytes[0]) || !is_alnum(bytes[bytes.len() - 1]) {
return false;
}
bytes.iter().all(|&b| is_alnum(b) || b == b'-')
}
pub fn parse_namespace(raw: &str) -> Result<String, String> {
if is_valid_namespace(raw) {
Ok(raw.to_string())
} else {
Err(format!(
"invalid namespace {raw:?}: expected a DNS-1123 label \
(1-63 chars, lowercase alphanumeric or '-', \
starting and ending alphanumeric)"
))
}
}
#[async_trait]
pub trait ClusterEngine: Send + Sync {
async fn snapshot(&self) -> Result<KubeSnapshot, ClusterError>;
async fn metrics_available(&self) -> bool;
fn kind(&self) -> ClusterKind;
fn server_version(&self) -> Option<&str>;
async fn scope(&self) -> KubeScope {
KubeScope::AllNamespaces
}
async fn toggle_scope(&self) -> KubeScope {
KubeScope::AllNamespaces
}
}
pub fn detect_kubeconfig_with<E: EnvLookup>(
env: &E,
home_kubeconfig: Option<&Path>,
in_cluster_token: &Path,
) -> KubeconfigSource {
if let Some(raw) = env.var("KUBECONFIG") {
let trimmed = raw.trim();
if !trimmed.is_empty() {
return KubeconfigSource::Env(PathBuf::from(trimmed));
}
}
if let Some(home) = home_kubeconfig
&& home.exists()
{
return KubeconfigSource::Home(home.to_path_buf());
}
if in_cluster_token.exists() {
return KubeconfigSource::InCluster;
}
KubeconfigSource::None
}
pub fn detect_kubeconfig() -> KubeconfigSource {
use crate::container_engine::StdEnv;
let env = StdEnv;
let home_kubeconfig: Option<PathBuf> = dirs::home_dir().map(|h| h.join(".kube/config"));
let in_cluster_token = Path::new("/var/run/secrets/kubernetes.io/serviceaccount/token");
detect_kubeconfig_with(&env, home_kubeconfig.as_deref(), in_cluster_token)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;
use std::fs::File;
use tempfile::tempdir;
#[derive(Default)]
struct FakeEnv {
vars: HashMap<String, String>,
}
impl FakeEnv {
fn with(mut self, key: &str, value: &str) -> Self {
self.vars.insert(key.into(), value.into());
self
}
}
impl EnvLookup for FakeEnv {
fn var(&self, name: &str) -> Option<String> {
self.vars.get(name).cloned()
}
}
#[test]
fn detect_with_env_var_returns_env() {
let env = FakeEnv::default().with("KUBECONFIG", "/etc/k8s/admin.conf");
let dir = tempdir().unwrap();
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, None, &token);
assert_eq!(
result,
KubeconfigSource::Env(PathBuf::from("/etc/k8s/admin.conf"))
);
}
#[test]
fn detect_with_env_var_supports_colon_separated_list() {
let env = FakeEnv::default().with("KUBECONFIG", "/a/config:/b/config");
let dir = tempdir().unwrap();
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, None, &token);
assert_eq!(
result,
KubeconfigSource::Env(PathBuf::from("/a/config:/b/config"))
);
}
#[test]
fn detect_with_empty_env_var_falls_through_to_home() {
let env = FakeEnv::default().with("KUBECONFIG", "");
let dir = tempdir().unwrap();
let home_config = dir.path().join("config");
File::create(&home_config).unwrap();
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, Some(&home_config), &token);
assert_eq!(result, KubeconfigSource::Home(home_config));
}
#[test]
fn detect_with_whitespace_only_env_var_falls_through() {
let env = FakeEnv::default().with("KUBECONFIG", " ");
let dir = tempdir().unwrap();
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, None, &token);
assert_eq!(result, KubeconfigSource::None);
}
#[test]
fn detect_with_home_kubeconfig_returns_home() {
let env = FakeEnv::default();
let dir = tempdir().unwrap();
let home_config = dir.path().join("config");
File::create(&home_config).unwrap();
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, Some(&home_config), &token);
assert_eq!(result, KubeconfigSource::Home(home_config));
}
#[test]
fn detect_with_home_kubeconfig_missing_falls_through() {
let env = FakeEnv::default();
let dir = tempdir().unwrap();
let missing = dir.path().join("does-not-exist");
let token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, Some(&missing), &token);
assert_eq!(result, KubeconfigSource::None);
}
#[test]
fn detect_with_in_cluster_token_returns_in_cluster() {
let env = FakeEnv::default();
let dir = tempdir().unwrap();
let token = dir.path().join("token");
File::create(&token).unwrap();
let result = detect_kubeconfig_with(&env, None, &token);
assert_eq!(result, KubeconfigSource::InCluster);
}
#[test]
fn detect_with_returns_none_when_nothing_present() {
let env = FakeEnv::default();
let dir = tempdir().unwrap();
let nope_home = dir.path().join("nope-home");
let nope_token = dir.path().join("nope-token");
let result = detect_kubeconfig_with(&env, Some(&nope_home), &nope_token);
assert_eq!(result, KubeconfigSource::None);
}
#[test]
fn detect_priority_env_beats_home_and_in_cluster() {
let env = FakeEnv::default().with("KUBECONFIG", "/explicit/config");
let dir = tempdir().unwrap();
let home = dir.path().join("config");
File::create(&home).unwrap();
let token = dir.path().join("token");
File::create(&token).unwrap();
let result = detect_kubeconfig_with(&env, Some(&home), &token);
assert_eq!(
result,
KubeconfigSource::Env(PathBuf::from("/explicit/config"))
);
}
#[test]
fn detect_priority_home_beats_in_cluster() {
let env = FakeEnv::default();
let dir = tempdir().unwrap();
let home = dir.path().join("config");
File::create(&home).unwrap();
let token = dir.path().join("token");
File::create(&token).unwrap();
let result = detect_kubeconfig_with(&env, Some(&home), &token);
assert_eq!(result, KubeconfigSource::Home(home));
}
#[test]
fn detect_kubeconfig_does_not_panic() {
let _ = detect_kubeconfig();
}
#[test]
fn scope_all_namespaces_has_no_namespace() {
let scope = KubeScope::AllNamespaces;
assert_eq!(scope.namespace(), None);
assert_eq!(scope.label(), "");
assert!(scope.is_all_namespaces());
}
#[test]
fn scope_namespace_exposes_it() {
let scope = KubeScope::Namespace("kube-system".into());
assert_eq!(scope.namespace(), Some("kube-system"));
assert_eq!(scope.label(), "kube-system");
assert!(!scope.is_all_namespaces());
}
#[test]
fn scope_defaults_to_all_namespaces() {
assert_eq!(KubeScope::default(), KubeScope::AllNamespaces);
}
#[test]
fn valid_namespaces_are_accepted() {
for ns in [
"default",
"kube-system",
"a",
"1",
"my-app-2",
&"a".repeat(63),
] {
assert!(is_valid_namespace(ns), "expected {ns:?} to be valid");
}
}
#[test]
fn invalid_namespaces_are_rejected() {
for ns in [
"", &"a".repeat(64), "Default", "-leading", "trailing-", "has_underscore", "has.dot", "has space", "ns/../other", "ns/pods", "ns?watch=true", "ns%2f", "ns\u{0000}", "ns\u{001b}[31m", ] {
assert!(!is_valid_namespace(ns), "expected {ns:?} to be rejected");
}
}
#[test]
fn namespace_rejection_blocks_uri_injection() {
for ns in ["default", "kube-system", "my-app-2"] {
assert!(is_valid_namespace(ns));
assert!(!ns.contains(['/', '?', '#', '%', '.', ':', '@']));
}
}
#[test]
fn parse_namespace_round_trips_valid_input() {
assert_eq!(parse_namespace("kube-system").unwrap(), "kube-system");
}
#[test]
fn parse_namespace_reports_why_it_failed() {
let err = parse_namespace("Bad NS").unwrap_err();
assert!(err.contains("DNS-1123"), "unhelpful message: {err}");
assert!(err.contains("Bad NS"), "message omits the input: {err}");
}
#[test]
fn cluster_error_display_is_informative() {
let variants: Vec<ClusterError> = vec![
ClusterError::KubeconfigNotFound,
ClusterError::Unreachable("dns failed".into()),
ClusterError::Forbidden {
resource: "pods",
namespace: Some("kube-system".into()),
},
ClusterError::Forbidden {
resource: "nodes",
namespace: None,
},
ClusterError::MetricsUnavailable,
ClusterError::Stale { since_secs: 42 },
ClusterError::Other("kaboom".into()),
];
for err in &variants {
let msg = format!("{err}");
assert!(!msg.is_empty(), "empty Display for {err:?}");
}
assert!(format!("{}", variants[1]).contains("dns failed"));
let scoped = format!("{}", variants[2]);
assert!(scoped.contains("pods"));
assert!(scoped.contains("kube-system"));
let cluster_scoped = format!("{}", variants[3]);
assert!(cluster_scoped.contains("nodes"));
assert!(!cluster_scoped.contains("namespace"));
assert!(format!("{}", variants[5]).contains("42"));
}
#[test]
fn cluster_error_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<ClusterError>();
}
struct StubCluster;
#[async_trait::async_trait]
impl ClusterEngine for StubCluster {
async fn snapshot(&self) -> Result<KubeSnapshot, ClusterError> {
Ok(KubeSnapshot::unavailable())
}
async fn metrics_available(&self) -> bool {
false
}
fn kind(&self) -> ClusterKind {
ClusterKind::Generic
}
fn server_version(&self) -> Option<&str> {
None
}
}
#[test]
fn cluster_engine_is_object_safe() {
fn assert_send_sync<T: Send + Sync + ?Sized>() {}
assert_send_sync::<dyn ClusterEngine>();
let _boxed: Box<dyn ClusterEngine + Send + Sync> = Box::new(StubCluster);
}
#[tokio::test]
async fn cluster_engine_scope_defaults_are_cluster_wide() {
let stub: Box<dyn ClusterEngine + Send + Sync> = Box::new(StubCluster);
assert_eq!(stub.scope().await, KubeScope::AllNamespaces);
assert_eq!(stub.toggle_scope().await, KubeScope::AllNamespaces);
}
#[tokio::test]
async fn cluster_engine_stub_returns_unavailable() {
let stub: Box<dyn ClusterEngine + Send + Sync> = Box::new(StubCluster);
let snap = stub.snapshot().await.expect("stub never errors");
assert!(!snap.reachable);
assert!(snap.pods.is_empty());
assert_eq!(stub.kind(), ClusterKind::Generic);
assert!(stub.server_version().is_none());
assert!(!stub.metrics_available().await);
}
}