use async_trait::async_trait;
use kube::{
api::{Api, DynamicObject, ListParams, Patch, PatchParams},
discovery::{ApiCapabilities, ApiResource, Discovery, Scope},
Client, ResourceExt,
};
use std::collections::HashMap;
use std::path::Path;
use std::sync::{Arc, Mutex};
use crate::{
kubernetes::resource::{ApplyOutcome, GroupVersionKind, ResourceKey},
profiles::{KubeconfigSource, Profile},
NylError, Result,
};
#[async_trait]
pub trait KubeClient: Send + Sync {
async fn get_resource(
&self,
gvk: &GroupVersionKind,
namespace: Option<&str>,
name: &str,
) -> Result<Option<DynamicObject>>;
async fn apply_resource(
&self,
resource: &DynamicObject,
field_manager: &str,
dry_run: bool,
) -> Result<ApplyOutcome>;
async fn get_server_version(&self) -> Result<String>;
async fn get_api_versions(&self) -> Result<Vec<String>>;
async fn is_namespaced(&self, gvk: &GroupVersionKind) -> Result<bool>;
fn default_namespace(&self) -> &str;
async fn delete_resource(&self, gvk: &GroupVersionKind, namespace: Option<&str>, name: &str) -> Result<()>;
async fn get_normalized_resource(&self, resource: &DynamicObject, field_manager: &str) -> Result<DynamicObject>;
}
pub struct KubeRsClient {
client: Client,
discovery: Arc<Discovery>,
api_resources: HashMap<GroupVersionKind, (ApiResource, ApiCapabilities)>,
crd_scope_cache: Mutex<HashMap<GroupVersionKind, Option<bool>>>,
}
impl KubeRsClient {
pub async fn load_kube_config(path: Option<&Path>, context: Option<&str>) -> Result<kube::Config> {
tracing::debug!(
kubeconfig_path = %path.map_or_else(|| "<default>".to_string(), |p| p.display().to_string()),
kubeconfig_context = %context.unwrap_or("<default>"),
"Starting Kubernetes config load (may invoke kubeconfig exec auth plugin)"
);
if let Some(ctx) = context {
let kubeconfig = if let Some(path) = path {
kube::config::Kubeconfig::read_from(path)?
} else {
kube::config::Kubeconfig::read()?
};
return kube::Config::from_custom_kubeconfig(
kubeconfig,
&kube::config::KubeConfigOptions {
context: Some(ctx.to_string()),
..Default::default()
},
)
.await
.inspect(|_| tracing::debug!("Finished Kubernetes config load from custom kubeconfig"))
.map_err(Into::into);
}
if let Some(path) = path {
return kube::Config::from_custom_kubeconfig(
kube::config::Kubeconfig::read_from(path)?,
&kube::config::KubeConfigOptions::default(),
)
.await
.inspect(|_| tracing::debug!("Finished Kubernetes config load from kubeconfig path"))
.map_err(Into::into);
}
kube::Config::infer()
.await
.inspect(|_| tracing::debug!("Finished Kubernetes config load via inferred kubeconfig"))
.map_err(Into::into)
}
pub async fn load_kube_config_from_profile(
profile: &Profile,
context_override: Option<&str>,
) -> Result<kube::Config> {
let kubeconfig = &profile.kubeconfig;
if matches!(kubeconfig, KubeconfigSource::Ssh { .. }) {
return Err(NylError::Config(
"SSH kubeconfig is not yet supported. This feature is planned for Phase 5.".to_string(),
));
}
let KubeconfigSource::Local { path, context } = kubeconfig else {
unreachable!()
};
let resolved_context = context_override.or(context.as_deref());
Self::load_kube_config(path.as_deref(), resolved_context).await
}
pub async fn from_profile(profile: &Profile, context_override: Option<&str>) -> Result<Self> {
tracing::debug!(
has_context_override = context_override.is_some(),
"Initializing Kubernetes client from profile"
);
let config = Self::load_kube_config_from_profile(profile, context_override).await?;
tracing::debug!("Creating kube-rs client from loaded config");
let client = Client::try_from(config)?;
tracing::debug!("Running Kubernetes API discovery");
let discovery = Arc::new(Discovery::new(client.clone()).run().await?);
let api_resources = Self::build_api_resource_index(&discovery);
tracing::debug!("Kubernetes client initialization complete");
Ok(Self {
client,
discovery,
api_resources,
crd_scope_cache: Mutex::new(HashMap::new()),
})
}
pub async fn from_client(client: Client) -> Result<Self> {
let discovery = Arc::new(Discovery::new(client.clone()).run().await?);
let api_resources = Self::build_api_resource_index(&discovery);
Ok(Self {
client,
discovery,
api_resources,
crd_scope_cache: Mutex::new(HashMap::new()),
})
}
fn build_api_resource_index(discovery: &Discovery) -> HashMap<GroupVersionKind, (ApiResource, ApiCapabilities)> {
let mut index = HashMap::new();
for group in discovery.groups() {
for (ar, caps) in group.recommended_resources() {
let gvk = GroupVersionKind {
group: ar.group.clone(),
version: ar.version.clone(),
kind: ar.kind.clone(),
};
index.entry(gvk).or_insert_with(|| (ar.clone(), caps.clone()));
}
}
index
}
fn discover_api_resource(&self, gvk: &GroupVersionKind) -> Result<(ApiResource, ApiCapabilities)> {
if let Some((ar, caps)) = self.api_resources.get(gvk) {
return Ok((ar.clone(), caps.clone()));
}
let group_version = if gvk.group.is_empty() {
gvk.version.clone()
} else {
format!("{}/{}", gvk.group, gvk.version)
};
let mut available_versions: Vec<String> = self
.api_resources
.keys()
.filter(|known| known.group == gvk.group && known.kind == gvk.kind)
.map(|known| known.version.clone())
.collect();
available_versions.sort();
available_versions.dedup();
let versions_hint = if available_versions.is_empty() {
String::new()
} else {
format!(" (available versions for this kind: {})", available_versions.join(", "))
};
Err(NylError::ApiResourceNotFound(format!(
"{}/{}{}",
group_version, gvk.kind, versions_hint
)))
}
async fn try_resolve_scope_from_crd(&self, gvk: &GroupVersionKind) -> Result<Option<bool>> {
if gvk.group.is_empty() {
return Ok(None);
}
if let Some(cached) = self.crd_scope_cache.lock().unwrap().get(gvk).copied() {
return Ok(cached);
}
let crd_api: Api<DynamicObject> = Api::all_with(self.client.clone(), &crd_api_resource());
let crds = match crd_api.list(&ListParams::default()).await {
Ok(crds) => crds,
Err(kube::Error::Api(err)) if err.code == 403 || err.code == 404 => {
tracing::debug!(
code = err.code,
message = %err.message,
"Unable to list CRDs for scope fallback; proceeding without CRD fallback"
);
self.crd_scope_cache.lock().unwrap().insert(gvk.clone(), None);
return Ok(None);
}
Err(err) => return Err(err.into()),
};
for crd in crds.items {
if let Some(namespaced) = scope_from_crd_for_gvk(&crd, gvk) {
self.crd_scope_cache
.lock()
.unwrap()
.insert(gvk.clone(), Some(namespaced));
return Ok(Some(namespaced));
}
}
self.crd_scope_cache.lock().unwrap().insert(gvk.clone(), None);
Ok(None)
}
}
#[async_trait]
impl KubeClient for KubeRsClient {
async fn get_resource(
&self,
gvk: &GroupVersionKind,
namespace: Option<&str>,
name: &str,
) -> Result<Option<DynamicObject>> {
let (ar, caps) = self.discover_api_resource(gvk)?;
let api: Api<DynamicObject> = if caps.scope == Scope::Namespaced {
let ns = namespace
.ok_or_else(|| NylError::Config(format!("Namespace required for namespaced resource {}", gvk.kind)))?;
Api::namespaced_with(self.client.clone(), ns, &ar)
} else {
Api::all_with(self.client.clone(), &ar)
};
match api.get(name).await {
Ok(obj) => Ok(Some(obj)),
Err(kube::Error::Api(err)) if err.code == 404 => Ok(None),
Err(e) => Err(e.into()),
}
}
async fn apply_resource(
&self,
resource: &DynamicObject,
field_manager: &str,
dry_run: bool,
) -> Result<ApplyOutcome> {
let name = resource.name_any();
let namespace = resource.namespace();
let resource_json = serde_json::to_value(resource)?;
let gvk = crate::kubernetes::resource::extract_gvk(&resource_json)?;
let resource_key = ResourceKey {
gvk: gvk.clone(),
namespace: namespace.clone(),
name: name.clone(),
};
let (ar, caps) = self.discover_api_resource(&gvk)?;
let api: Api<DynamicObject> = if caps.scope == Scope::Namespaced {
let ns = namespace
.as_deref()
.ok_or_else(|| NylError::Config(format!("Namespace required for namespaced resource {}", gvk.kind)))?;
Api::namespaced_with(self.client.clone(), ns, &ar)
} else {
Api::all_with(self.client.clone(), &ar)
};
let existing = self.get_resource(&gvk, namespace.as_deref(), &name).await?;
let old_resource_version = existing.as_ref().and_then(|r| r.metadata.resource_version.clone());
let mut patch_params = PatchParams::apply(field_manager).force();
if dry_run {
patch_params = patch_params.dry_run();
}
let patch = Patch::Apply(resource);
let updated = api.patch(&name, &patch_params, &patch).await?;
let base_outcome = if dry_run {
if let Some(ref existing_resource) = existing {
let desired_json = serde_json::to_value(resource)?;
let existing_json = serde_json::to_value(existing_resource)?;
let would_change = !crate::kubernetes::diff::DiffEngine::are_equivalent(&desired_json, &existing_json)?;
if would_change {
ApplyOutcome::Updated {
resource_key: resource_key.clone(),
}
} else {
ApplyOutcome::Unchanged {
resource_key: resource_key.clone(),
}
}
} else {
ApplyOutcome::Created {
resource_key: resource_key.clone(),
}
}
} else {
let new_resource_version = updated.metadata.resource_version.clone();
if existing.is_none() {
ApplyOutcome::Created {
resource_key: resource_key.clone(),
}
} else if old_resource_version != new_resource_version {
ApplyOutcome::Updated {
resource_key: resource_key.clone(),
}
} else {
ApplyOutcome::Unchanged {
resource_key: resource_key.clone(),
}
}
};
Ok(if dry_run {
ApplyOutcome::DryRun {
would_be: Box::new(base_outcome),
}
} else {
base_outcome
})
}
async fn get_server_version(&self) -> Result<String> {
let version = self.client.apiserver_version().await?;
let version_str = if version.git_version.starts_with('v') {
version.git_version[1..].to_string()
} else {
version.git_version
};
Ok(version_str)
}
async fn get_api_versions(&self) -> Result<Vec<String>> {
use std::collections::HashSet;
let mut api_versions = HashSet::new();
for group in self.discovery.groups() {
for (ar, _caps) in group.recommended_resources() {
api_versions.insert(format!("{}/{}", ar.api_version, ar.kind));
}
}
let mut result: Vec<String> = api_versions.into_iter().collect();
result.sort();
Ok(result)
}
async fn is_namespaced(&self, gvk: &GroupVersionKind) -> Result<bool> {
if is_known_cluster_scoped_gvk(gvk) {
return Ok(false);
}
match self.discover_api_resource(gvk) {
Ok((_ar, caps)) => Ok(caps.scope == Scope::Namespaced),
Err(err) => {
if err.is_api_resource_not_found_error() {
match self.try_resolve_scope_from_crd(gvk).await {
Ok(Some(namespaced)) => return Ok(namespaced),
Ok(None) => {}
Err(crd_err) => {
tracing::debug!(
requested_group = %gvk.group,
requested_version = %gvk.version,
requested_kind = %gvk.kind,
error = %crd_err,
"Failed CRD scope fallback; preserving original API resource discovery error"
);
}
}
}
Err(err)
}
}
}
fn default_namespace(&self) -> &str {
self.client.default_namespace()
}
async fn delete_resource(&self, gvk: &GroupVersionKind, namespace: Option<&str>, name: &str) -> Result<()> {
let (ar, caps) = self.discover_api_resource(gvk)?;
let api: Api<DynamicObject> = if caps.scope == Scope::Namespaced {
let ns = namespace
.ok_or_else(|| NylError::Config(format!("Namespace required for namespaced resource {}", gvk.kind)))?;
Api::namespaced_with(self.client.clone(), ns, &ar)
} else {
Api::all_with(self.client.clone(), &ar)
};
match api.delete(name, &kube::api::DeleteParams::default()).await {
Ok(_) => Ok(()),
Err(kube::Error::Api(err)) if err.code == 404 => {
Ok(())
}
Err(e) => Err(e.into()),
}
}
async fn get_normalized_resource(&self, resource: &DynamicObject, field_manager: &str) -> Result<DynamicObject> {
let name = resource.name_any();
let namespace = resource.namespace();
let resource_json = serde_json::to_value(resource)?;
let gvk = crate::kubernetes::resource::extract_gvk(&resource_json)?;
let (ar, caps) = self.discover_api_resource(&gvk)?;
let api: Api<DynamicObject> = if caps.scope == Scope::Namespaced {
let ns = namespace
.as_deref()
.ok_or_else(|| NylError::Config(format!("Namespace required for namespaced resource {}", gvk.kind)))?;
Api::namespaced_with(self.client.clone(), ns, &ar)
} else {
Api::all_with(self.client.clone(), &ar)
};
let patch_params = PatchParams::apply(field_manager).force().dry_run();
let patch = Patch::Apply(resource);
let normalized = api.patch(&name, &patch_params, &patch).await?;
Ok(normalized)
}
}
pub struct MockKubeClient {
resources: Arc<Mutex<HashMap<ResourceKey, DynamicObject>>>,
kind_scope_overrides: Arc<Mutex<HashMap<String, bool>>>,
default_namespace: String,
}
impl MockKubeClient {
pub fn new() -> Self {
Self::with_default_namespace("default")
}
pub fn with_default_namespace(default_namespace: impl Into<String>) -> Self {
Self {
resources: Arc::new(Mutex::new(HashMap::new())),
kind_scope_overrides: Arc::new(Mutex::new(HashMap::new())),
default_namespace: default_namespace.into(),
}
}
pub fn add_resource(&self, key: ResourceKey, resource: DynamicObject) {
let mut store = self.resources.lock().unwrap();
store.insert(key, resource);
}
pub fn get_all_resources(&self) -> HashMap<ResourceKey, DynamicObject> {
let store = self.resources.lock().unwrap();
store.clone()
}
pub fn set_kind_scope(&self, kind: &str, namespaced: bool) {
let mut overrides = self.kind_scope_overrides.lock().unwrap();
overrides.insert(kind.to_string(), namespaced);
}
fn default_scope_for_gvk(gvk: &GroupVersionKind) -> bool {
!is_known_cluster_scoped_gvk(gvk)
}
}
fn is_known_cluster_scoped_kind(kind: &str) -> bool {
matches!(
kind,
"APIService"
| "ClusterRole"
| "ClusterRoleBinding"
| "CustomResourceDefinition"
| "MutatingWebhookConfiguration"
| "Namespace"
| "Node"
| "PersistentVolume"
| "PriorityClass"
| "RuntimeClass"
| "StorageClass"
| "ValidatingWebhookConfiguration"
| "VolumeAttachment"
)
}
pub(crate) fn is_known_cluster_scoped_gvk(gvk: &GroupVersionKind) -> bool {
match gvk.group.as_str() {
"" => is_known_cluster_scoped_kind(&gvk.kind),
"rbac.authorization.k8s.io" => matches!(gvk.kind.as_str(), "ClusterRole" | "ClusterRoleBinding"),
"storage.k8s.io" => matches!(gvk.kind.as_str(), "StorageClass" | "VolumeAttachment"),
"admissionregistration.k8s.io" => {
matches!(
gvk.kind.as_str(),
"MutatingWebhookConfiguration" | "ValidatingWebhookConfiguration"
)
}
"apiregistration.k8s.io" => matches!(gvk.kind.as_str(), "APIService"),
"apiextensions.k8s.io" => matches!(gvk.kind.as_str(), "CustomResourceDefinition"),
_ => false,
}
}
fn crd_api_resource() -> ApiResource {
ApiResource {
group: "apiextensions.k8s.io".to_string(),
version: "v1".to_string(),
api_version: "apiextensions.k8s.io/v1".to_string(),
kind: "CustomResourceDefinition".to_string(),
plural: "customresourcedefinitions".to_string(),
}
}
fn scope_from_crd_for_gvk(crd: &DynamicObject, gvk: &GroupVersionKind) -> Option<bool> {
let value = serde_json::to_value(crd).ok()?;
let spec = value.get("spec")?;
let group = spec.get("group")?.as_str()?;
let kind = spec.get("names")?.get("kind")?.as_str()?;
let scope = spec.get("scope")?.as_str()?;
let versions = spec.get("versions")?.as_array()?;
if group != gvk.group || kind != gvk.kind {
return None;
}
let version_is_served = versions.iter().any(|v| {
let name = v.get("name").and_then(serde_json::Value::as_str);
let served = v.get("served").and_then(serde_json::Value::as_bool).unwrap_or(false);
name == Some(gvk.version.as_str()) && served
});
if !version_is_served {
return None;
}
match scope {
"Namespaced" => Some(true),
"Cluster" => Some(false),
_ => None,
}
}
impl Default for MockKubeClient {
fn default() -> Self {
Self::new()
}
}
#[async_trait]
impl KubeClient for MockKubeClient {
async fn get_resource(
&self,
gvk: &GroupVersionKind,
namespace: Option<&str>,
name: &str,
) -> Result<Option<DynamicObject>> {
let key = ResourceKey {
gvk: gvk.clone(),
namespace: namespace.map(|s| s.to_string()),
name: name.to_string(),
};
let store = self.resources.lock().unwrap();
Ok(store.get(&key).cloned())
}
async fn apply_resource(
&self,
resource: &DynamicObject,
_field_manager: &str,
dry_run: bool,
) -> Result<ApplyOutcome> {
let name = resource.name_any();
let namespace = resource.namespace();
let resource_json = serde_json::to_value(resource)?;
let gvk = crate::kubernetes::resource::extract_gvk(&resource_json)?;
let key = ResourceKey {
gvk,
namespace: namespace.clone(),
name: name.clone(),
};
let mut store = self.resources.lock().unwrap();
let existing = store.get(&key).cloned();
let changed = existing.as_ref().is_none_or(|old| {
let old_json = serde_json::to_value(old).unwrap_or_default();
let new_json = serde_json::to_value(resource).unwrap_or_default();
old_json != new_json
});
if !dry_run {
store.insert(key.clone(), resource.clone());
}
let base_outcome = if existing.is_none() {
ApplyOutcome::Created {
resource_key: key.clone(),
}
} else if changed {
ApplyOutcome::Updated {
resource_key: key.clone(),
}
} else {
ApplyOutcome::Unchanged {
resource_key: key.clone(),
}
};
Ok(if dry_run {
ApplyOutcome::DryRun {
would_be: Box::new(base_outcome),
}
} else {
base_outcome
})
}
async fn get_server_version(&self) -> Result<String> {
Ok("1.28.0".to_string())
}
async fn get_api_versions(&self) -> Result<Vec<String>> {
Ok(vec![
"apps/v1/Deployment".to_string(),
"batch/v1/Job".to_string(),
"v1/Pod".to_string(),
"v1/Service".to_string(),
])
}
async fn is_namespaced(&self, gvk: &GroupVersionKind) -> Result<bool> {
let overrides = self.kind_scope_overrides.lock().unwrap();
Ok(overrides
.get(&gvk.kind)
.copied()
.unwrap_or_else(|| Self::default_scope_for_gvk(gvk)))
}
fn default_namespace(&self) -> &str {
&self.default_namespace
}
async fn delete_resource(&self, gvk: &GroupVersionKind, namespace: Option<&str>, name: &str) -> Result<()> {
let key = ResourceKey {
gvk: gvk.clone(),
namespace: namespace.map(|s| s.to_string()),
name: name.to_string(),
};
let mut store = self.resources.lock().unwrap();
store.remove(&key);
Ok(())
}
async fn get_normalized_resource(&self, resource: &DynamicObject, _field_manager: &str) -> Result<DynamicObject> {
Ok(resource.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[tokio::test]
async fn test_mock_client_get_missing() {
let client = MockKubeClient::new();
let gvk = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "ConfigMap".to_string(),
};
let result = client.get_resource(&gvk, Some("default"), "test").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_mock_client_apply_create() {
let client = MockKubeClient::new();
let json_data = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
}
});
let resource: DynamicObject = serde_json::from_value(json_data).unwrap();
let outcome = client.apply_resource(&resource, "nyl", false).await.unwrap();
match &outcome {
ApplyOutcome::Created { .. } => {
assert_eq!(outcome.kind(), "ConfigMap");
assert_eq!(outcome.name(), "test");
assert_eq!(outcome.namespace(), Some("default"));
}
_ => panic!("Expected Created outcome"),
}
}
#[tokio::test]
async fn test_mock_client_apply_update() {
let client = MockKubeClient::new();
let json_data = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
},
"data": {
"key": "value1"
}
});
let resource: DynamicObject = serde_json::from_value(json_data).unwrap();
client.apply_resource(&resource, "nyl", false).await.unwrap();
let updated_json = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
},
"data": {
"key": "value2" }
});
let updated_resource: DynamicObject = serde_json::from_value(updated_json).unwrap();
let outcome = client.apply_resource(&updated_resource, "nyl", false).await.unwrap();
match &outcome {
ApplyOutcome::Updated { .. } => {
assert_eq!(outcome.kind(), "ConfigMap");
assert_eq!(outcome.name(), "test");
assert_eq!(outcome.namespace(), Some("default"));
}
_ => panic!("Expected Updated outcome"),
}
}
#[tokio::test]
async fn test_mock_client_apply_unchanged() {
let client = MockKubeClient::new();
let json_data = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
},
"data": {
"key": "value"
}
});
let resource: DynamicObject = serde_json::from_value(json_data).unwrap();
client.apply_resource(&resource, "nyl", false).await.unwrap();
let outcome = client.apply_resource(&resource, "nyl", false).await.unwrap();
match &outcome {
ApplyOutcome::Unchanged { .. } => {
assert_eq!(outcome.kind(), "ConfigMap");
assert_eq!(outcome.name(), "test");
assert_eq!(outcome.namespace(), Some("default"));
}
_ => panic!("Expected Unchanged outcome, got {:?}", outcome),
}
}
#[tokio::test]
async fn test_mock_client_dry_run() {
let client = MockKubeClient::new();
let json_data = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
}
});
let resource: DynamicObject = serde_json::from_value(json_data).unwrap();
let outcome = client.apply_resource(&resource, "nyl", true).await.unwrap();
match outcome {
ApplyOutcome::DryRun { would_be } => match *would_be {
ApplyOutcome::Created { .. } => {}
_ => panic!("Expected Created in DryRun"),
},
_ => panic!("Expected DryRun outcome"),
}
let gvk = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "ConfigMap".to_string(),
};
let result = client.get_resource(&gvk, Some("default"), "test").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_mock_client_delete() {
let client = MockKubeClient::new();
let json_data = json!({
"apiVersion": "v1",
"kind": "ConfigMap",
"metadata": {
"name": "test",
"namespace": "default"
}
});
let resource: DynamicObject = serde_json::from_value(json_data).unwrap();
client.apply_resource(&resource, "nyl", false).await.unwrap();
let gvk = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "ConfigMap".to_string(),
};
let result = client.get_resource(&gvk, Some("default"), "test").await.unwrap();
assert!(result.is_some());
client.delete_resource(&gvk, Some("default"), "test").await.unwrap();
let result = client.get_resource(&gvk, Some("default"), "test").await.unwrap();
assert!(result.is_none());
}
#[tokio::test]
async fn test_mock_client_delete_nonexistent() {
let client = MockKubeClient::new();
let gvk = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "ConfigMap".to_string(),
};
let result = client.delete_resource(&gvk, Some("default"), "test").await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_mock_client_is_namespaced_override() {
let client = MockKubeClient::new();
let gvk = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "ServiceAccount".to_string(),
};
assert!(client.is_namespaced(&gvk).await.unwrap());
client.set_kind_scope("ServiceAccount", false);
assert!(!client.is_namespaced(&gvk).await.unwrap());
}
#[test]
fn test_mock_client_default_namespace() {
let client = MockKubeClient::with_default_namespace("custom");
assert_eq!(client.default_namespace(), "custom");
}
#[test]
fn test_is_known_cluster_scoped_gvk_respects_group() {
let built_in = GroupVersionKind {
group: String::new(),
version: "v1".to_string(),
kind: "Namespace".to_string(),
};
assert!(is_known_cluster_scoped_gvk(&built_in));
let custom_same_kind = GroupVersionKind {
group: "example.com".to_string(),
version: "v1".to_string(),
kind: "Namespace".to_string(),
};
assert!(!is_known_cluster_scoped_gvk(&custom_same_kind));
}
#[test]
fn test_scope_from_crd_for_gvk_cluster() {
let crd: DynamicObject = serde_json::from_value(json!({
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "clusterpolicies.kyverno.io"},
"spec": {
"group": "kyverno.io",
"names": {"kind": "ClusterPolicy"},
"scope": "Cluster",
"versions": [
{"name": "v1", "served": true}
]
}
}))
.unwrap();
let gvk = GroupVersionKind {
group: "kyverno.io".to_string(),
version: "v1".to_string(),
kind: "ClusterPolicy".to_string(),
};
assert_eq!(scope_from_crd_for_gvk(&crd, &gvk), Some(false));
}
#[test]
fn test_scope_from_crd_for_gvk_namespaced() {
let crd: DynamicObject = serde_json::from_value(json!({
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "widgets.example.com"},
"spec": {
"group": "example.com",
"names": {"kind": "Widget"},
"scope": "Namespaced",
"versions": [
{"name": "v1", "served": true}
]
}
}))
.unwrap();
let gvk = GroupVersionKind {
group: "example.com".to_string(),
version: "v1".to_string(),
kind: "Widget".to_string(),
};
assert_eq!(scope_from_crd_for_gvk(&crd, &gvk), Some(true));
}
#[test]
fn test_scope_from_crd_for_gvk_rejects_unserved_version() {
let crd: DynamicObject = serde_json::from_value(json!({
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "clusterpolicies.kyverno.io"},
"spec": {
"group": "kyverno.io",
"names": {"kind": "ClusterPolicy"},
"scope": "Cluster",
"versions": [
{"name": "v1", "served": false},
{"name": "v2beta1", "served": true}
]
}
}))
.unwrap();
let gvk = GroupVersionKind {
group: "kyverno.io".to_string(),
version: "v1".to_string(),
kind: "ClusterPolicy".to_string(),
};
assert_eq!(scope_from_crd_for_gvk(&crd, &gvk), None);
}
#[test]
fn test_scope_from_crd_for_gvk_rejects_missing_version() {
let crd: DynamicObject = serde_json::from_value(json!({
"apiVersion": "apiextensions.k8s.io/v1",
"kind": "CustomResourceDefinition",
"metadata": {"name": "clusterpolicies.kyverno.io"},
"spec": {
"group": "kyverno.io",
"names": {"kind": "ClusterPolicy"},
"scope": "Cluster",
"versions": [
{"name": "v2beta1", "served": true}
]
}
}))
.unwrap();
let gvk = GroupVersionKind {
group: "kyverno.io".to_string(),
version: "v1".to_string(),
kind: "ClusterPolicy".to_string(),
};
assert_eq!(scope_from_crd_for_gvk(&crd, &gvk), None);
}
}