#![cfg(feature = "integration")]
use k8s_openapi::api::core::v1::{ConfigMap, Namespace};
use k8s_openapi::api::rbac::v1::ClusterRole;
use koprs::finalizers::{add_finalizer, add_finalizer_namespaced, remove_finalizers};
use koprs::gc::gc_resources;
use koprs::meta::ObjectMetaBuilder;
use koprs::resources::{
apply_resource, delete_resource, ensure_namespace, list_resource_names, list_resources_scoped,
};
use koprs::scope::{Cluster, Namespaced};
use koprs::status::{patch_status_cluster, patch_status_namespaced};
use kube::api::ListParams;
use kube::{Api, Client, ResourceExt};
use serde::{Deserialize, Serialize};
fn uid(name: &str) -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let ts = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.subsec_nanos();
format!("{}-{}", name, ts)
}
async fn client() -> Client {
Client::try_default()
.await
.expect("Failed to build kube Client — is a cluster reachable?")
}
fn configmap(name: &str, namespace: &str, label: Option<&str>) -> ConfigMap {
let mut builder = ObjectMetaBuilder::new().name(name).namespace(namespace);
if let Some(val) = label {
builder = builder.label("koprs-test", val);
}
ConfigMap {
metadata: builder.build(),
..Default::default()
}
}
fn cluster_role(name: &str, label: Option<&str>) -> ClusterRole {
let mut builder = ObjectMetaBuilder::new().name(name);
if let Some(val) = label {
builder = builder.label("koprs-test", val);
}
ClusterRole {
metadata: builder.build(),
..Default::default()
}
}
#[derive(Serialize, Deserialize, Debug)]
struct TestStatus {
ready: bool,
message: String,
}
#[tokio::test]
async fn test_ensure_namespace_creates_and_is_idempotent() {
let client = client().await;
let name = uid("genops-ns");
ensure_namespace(client.clone(), &name, "koprs-test")
.await
.expect("ensure_namespace failed on first call");
ensure_namespace(client.clone(), &name, "koprs-test")
.await
.expect("ensure_namespace failed on second call");
let api: Api<Namespace> = Api::all(client.clone());
api.get(&name)
.await
.expect("Namespace not found after ensure");
api.delete(&name, &Default::default()).await.ok();
}
#[tokio::test]
async fn test_apply_and_delete_namespaced_configmap() {
let client = client().await;
let ns = "default";
let name = uid("genops-cm");
let cm = configmap(&name, ns, None);
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.expect("apply_resource (namespaced) failed");
let api: Api<ConfigMap> = Api::namespaced(client.clone(), ns);
api.get(&name)
.await
.expect("ConfigMap not found after apply");
let deleted = delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.expect("delete_resource (namespaced) failed");
assert!(deleted);
let deleted_again = delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.expect("delete_resource second call failed");
assert!(!deleted_again);
}
#[tokio::test]
async fn test_apply_namespaced_is_idempotent() {
let client = client().await;
let ns = "default";
let name = uid("genops-cm-idem");
let cm = configmap(&name, ns, None);
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.expect("first apply failed");
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.expect("second apply failed — SSA must be idempotent");
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}
#[tokio::test]
async fn test_apply_and_delete_cluster_role() {
let client = client().await;
let name = uid("genops-cr");
let cr = cluster_role(&name, None);
apply_resource::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs-test")
.await
.expect("apply_resource (cluster) failed");
let api: Api<ClusterRole> = Api::all(client.clone());
api.get(&name)
.await
.expect("ClusterRole not found after apply");
let deleted = delete_resource::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.expect("delete_resource (cluster) failed");
assert!(deleted);
let deleted_again = delete_resource::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.expect("second delete failed unexpectedly");
assert!(!deleted_again);
}
#[tokio::test]
async fn test_apply_cluster_resource_is_idempotent() {
let client = client().await;
let name = uid("genops-cr-idem");
let cr = cluster_role(&name, None);
apply_resource::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs-test")
.await
.expect("first apply_resource (cluster) failed");
apply_resource::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs-test")
.await
.expect("second apply_resource (cluster) failed — SSA must be idempotent");
delete_resource::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.ok();
}
#[tokio::test]
async fn test_list_namespaced_resources() {
let client = client().await;
let ns = "default";
let name = uid("genops-list");
let cm = configmap(&name, ns, None);
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.unwrap();
let list =
list_resources_scoped::<ConfigMap, _>(client.clone(), Namespaced(ns), Default::default())
.await
.expect("list_resources_scoped (namespaced) failed");
assert!(
list.items.iter().any(|c| c.name_any() == name),
"Created ConfigMap not found in list"
);
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}
#[tokio::test]
async fn test_list_resources_by_label() {
let client = client().await;
let ns = "default";
let label_value = uid("genops-label");
let name = uid("genops-labeled");
let cm = configmap(&name, ns, Some(&label_value));
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.unwrap();
let selector = format!("koprs-test={}", label_value);
let list = list_resources_scoped::<ConfigMap, _>(
client.clone(),
Cluster,
ListParams::default().labels(&selector),
)
.await
.expect("list_resources_scoped (by label) failed");
assert_eq!(
list.items.len(),
1,
"Expected exactly one ConfigMap with label {selector}"
);
assert_eq!(list.items[0].name_any(), name);
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}
#[tokio::test]
async fn test_list_resource_names_returns_name_set() {
let client = client().await;
let ns = "default";
let label_value = uid("genops-names");
let name_a = uid("genops-names-a");
let name_b = uid("genops-names-b");
let selector = format!("koprs-test={}", label_value);
apply_resource::<ConfigMap, _>(
client.clone(),
Namespaced(ns),
&configmap(&name_a, ns, Some(&label_value)),
"koprs-test",
)
.await
.unwrap();
apply_resource::<ConfigMap, _>(
client.clone(),
Namespaced(ns),
&configmap(&name_b, ns, Some(&label_value)),
"koprs-test",
)
.await
.unwrap();
let names = list_resource_names::<ConfigMap>(client.clone(), &selector)
.await
.expect("list_resource_names failed");
assert!(names.contains(&name_a));
assert!(names.contains(&name_b));
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name_a)
.await
.ok();
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name_b)
.await
.ok();
}
#[tokio::test]
async fn test_patch_status_namespaced() {
let client = client().await;
let ns = "default";
let name = uid("genops-status");
let cm = configmap(&name, ns, None);
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.unwrap();
let result = patch_status_namespaced::<ConfigMap, _>(
client.clone(),
ns,
&name,
TestStatus {
ready: true,
message: "integration test".to_string(),
},
"koprs-test",
)
.await;
match result {
Ok(_) => {}
Err(koprs::error::KubeGenericError::Kube(kube::Error::Api(e))) => {
assert!(
e.code == 404 || e.code == 405 || e.code == 422,
"unexpected API error code {}: {}",
e.code,
e.message
);
}
Err(e) => panic!("unexpected non-API error from patch_status_namespaced: {e:?}"),
}
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}
#[tokio::test]
async fn test_patch_status_cluster() {
let client = client().await;
let name = uid("genops-cr-status");
let cr = cluster_role(&name, None);
apply_resource::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs-test")
.await
.unwrap();
let result = patch_status_cluster::<ClusterRole, _>(
client.clone(),
&name,
TestStatus {
ready: false,
message: "cluster status test".to_string(),
},
"koprs-test",
)
.await;
match result {
Ok(_) => {}
Err(koprs::error::KubeGenericError::Kube(kube::Error::Api(e))) => {
assert!(
e.code == 404 || e.code == 405 || e.code == 422,
"unexpected API error code {}: {}",
e.code,
e.message
);
}
Err(e) => panic!("unexpected non-API error from patch_status_cluster: {e:?}"),
}
delete_resource::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.ok();
}
#[tokio::test]
async fn test_add_and_remove_namespaced_finalizer() {
let client = client().await;
let ns = "default";
let name = uid("genops-fin");
let cm = configmap(&name, ns, None);
apply_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &cm, "koprs-test")
.await
.unwrap();
let with_fin = add_finalizer_namespaced::<ConfigMap>(client.clone(), &cm, "koprs/finalizer")
.await
.expect("add_finalizer_namespaced failed");
assert!(
with_fin
.metadata
.finalizers
.as_deref()
.unwrap_or_default()
.contains(&"koprs/finalizer".to_string()),
"Finalizer not present after add"
);
let without_fin = remove_finalizers::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.expect("remove_finalizers (namespaced) failed");
assert!(
without_fin
.metadata
.finalizers
.as_deref()
.unwrap_or_default()
.is_empty(),
"Finalizers still present after remove"
);
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}
#[tokio::test]
async fn test_add_and_remove_cluster_finalizer() {
let client = client().await;
let name = uid("genops-cfin");
let cr = cluster_role(&name, None);
apply_resource::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs-test")
.await
.unwrap();
let with_fin = add_finalizer::<ClusterRole, _>(client.clone(), Cluster, &cr, "koprs/finalizer")
.await
.expect("add_finalizer (cluster) failed");
assert!(
with_fin
.metadata
.finalizers
.as_deref()
.unwrap_or_default()
.contains(&"koprs/finalizer".to_string()),
"Finalizer not present after add"
);
remove_finalizers::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.expect("remove_finalizers (cluster) failed");
delete_resource::<ClusterRole, _>(client.clone(), Cluster, &name)
.await
.ok();
}
#[tokio::test]
async fn test_gc_cluster_resources_deletes_orphans() {
let client = client().await;
let label = uid("gc-cluster");
let selector = format!("koprs-test={}", label);
let keep = uid("genops-gc-keep");
let orphan = uid("genops-gc-orphan");
apply_resource::<ClusterRole, _>(
client.clone(),
Cluster,
&cluster_role(&keep, Some(&label)),
"koprs-test",
)
.await
.unwrap();
apply_resource::<ClusterRole, _>(
client.clone(),
Cluster,
&cluster_role(&orphan, Some(&label)),
"koprs-test",
)
.await
.unwrap();
let keep_name = keep.clone();
gc_resources::<ClusterRole, _>(client.clone(), Cluster, &selector, move |r| {
r.metadata.name.as_deref() == Some(&keep_name)
})
.await
.expect("gc_resources (cluster) failed");
let api: Api<ClusterRole> = Api::all(client.clone());
let remaining = api
.list(&ListParams::default().labels(&selector))
.await
.unwrap();
let names: Vec<_> = remaining.items.iter().map(|r| r.name_any()).collect();
assert!(names.contains(&keep), "Kept resource was deleted");
assert!(
!names.contains(&orphan),
"Orphaned resource was not deleted"
);
delete_resource::<ClusterRole, _>(client.clone(), Cluster, &keep)
.await
.ok();
}
#[tokio::test]
async fn test_gc_namespaced_resources_deletes_orphans() {
let client = client().await;
let ns = "default";
let label = uid("gc-ns");
let selector = format!("koprs-test={}", label);
let keep = uid("genops-gc-ns-keep");
let orphan = uid("genops-gc-ns-orphan");
apply_resource::<ConfigMap, _>(
client.clone(),
Namespaced(ns),
&configmap(&keep, ns, Some(&label)),
"koprs-test",
)
.await
.unwrap();
apply_resource::<ConfigMap, _>(
client.clone(),
Namespaced(ns),
&configmap(&orphan, ns, Some(&label)),
"koprs-test",
)
.await
.unwrap();
let keep_name = keep.clone();
gc_resources::<ConfigMap, _>(client.clone(), Namespaced(ns), &selector, move |r| {
r.metadata.name.as_deref() == Some(&keep_name)
})
.await
.expect("gc_resources (namespaced) failed");
let api: Api<ConfigMap> = Api::namespaced(client.clone(), ns);
let remaining = api
.list(&ListParams::default().labels(&selector))
.await
.unwrap();
let names: Vec<_> = remaining.items.iter().map(|r| r.name_any()).collect();
assert!(names.contains(&keep), "Kept ConfigMap was deleted");
assert!(
!names.contains(&orphan),
"Orphaned ConfigMap was not deleted"
);
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &keep)
.await
.ok();
}
#[tokio::test]
async fn test_gc_does_not_delete_desired_resources() {
let client = client().await;
let ns = "default";
let label = uid("gc-noop");
let selector = format!("koprs-test={}", label);
let name = uid("genops-gc-keep-all");
apply_resource::<ConfigMap, _>(
client.clone(),
Namespaced(ns),
&configmap(&name, ns, Some(&label)),
"koprs-test",
)
.await
.unwrap();
gc_resources::<ConfigMap, _>(client.clone(), Namespaced(ns), &selector, |_| true)
.await
.expect("gc_resources failed");
let api: Api<ConfigMap> = Api::namespaced(client.clone(), ns);
api.get(&name)
.await
.expect("Resource was incorrectly deleted by GC");
delete_resource::<ConfigMap, _>(client.clone(), Namespaced(ns), &name)
.await
.ok();
}