use std::sync::{Arc, Mutex};
use std::time::Duration;
use flatbed::k8s::{
reconcile_with_finalizer, HasKubeClient, HasLeaderElection, KubeReconciler, ReconcileError,
};
use flatbed::nats::HasJetStream;
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::{Api, DeleteParams, ObjectMeta, PostParams};
use kube::runtime::controller::Action;
use kube::Resource;
use tokio::sync::watch;
type CallLog = Arc<Mutex<Vec<String>>>;
struct TestCtx {
kube_client: kube::Client,
leader_rx: watch::Receiver<bool>,
}
impl HasJetStream for TestCtx {
fn jetstream(&self) -> &async_nats::jetstream::Context {
panic!("StubJetStream: reconcile_with_finalizer should not access JetStream");
}
}
impl HasKubeClient for TestCtx {
fn kube_client(&self) -> &kube::Client {
&self.kube_client
}
}
impl HasLeaderElection for TestCtx {
fn is_leader_rx(&self) -> watch::Receiver<bool> {
self.leader_rx.clone()
}
}
async fn test_ctx() -> Arc<TestCtx> {
let client = kube::Client::try_default()
.await
.expect("Failed to create kube client -- is KUBECONFIG set?");
let (_tx, rx) = watch::channel(true);
Arc::new(TestCtx {
kube_client: client,
leader_rx: rx,
})
}
async fn create_test_configmap(client: &kube::Client, name: &str) -> ConfigMap {
let api: Api<ConfigMap> = Api::namespaced(client.clone(), "default");
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(name.to_string()),
..Default::default()
},
..Default::default()
};
api.create(&PostParams::default(), &cm)
.await
.unwrap_or_else(|e| panic!("Failed to create ConfigMap {name}: {e}"))
}
async fn delete_test_configmap(client: &kube::Client, name: &str) {
let api: Api<ConfigMap> = Api::namespaced(client.clone(), "default");
let _ = api.delete(name, &DeleteParams::default()).await;
}
async fn get_configmap(client: &kube::Client, name: &str) -> ConfigMap {
let api: Api<ConfigMap> = Api::namespaced(client.clone(), "default");
api.get(name)
.await
.unwrap_or_else(|e| panic!("Failed to get ConfigMap {name}: {e}"))
}
struct NoFinalizerReconciler {
call_log: CallLog,
}
impl KubeReconciler for NoFinalizerReconciler {
type Resource = ConfigMap;
type Context = TestCtx;
type Error = ReconcileError;
const NAME: &'static str = "no-finalizer-reconciler";
const STREAM: &'static str = "TEST";
const STREAM_SUBJECTS: &'static str = "test.>";
fn reconcile(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Action, Self::Error>> + Send>>
{
let log = Arc::clone(&self.call_log);
Box::pin(async move {
log.lock().unwrap().push("reconcile".to_string());
Ok(Action::requeue(Duration::from_secs(300)))
})
}
fn cleanup(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Self::Error>> + Send>> {
let log = Arc::clone(&self.call_log);
Box::pin(async move {
log.lock().unwrap().push("cleanup".to_string());
Ok(())
})
}
}
struct WithFinalizerReconciler {
call_log: CallLog,
}
impl KubeReconciler for WithFinalizerReconciler {
type Resource = ConfigMap;
type Context = TestCtx;
type Error = ReconcileError;
const NAME: &'static str = "with-finalizer-reconciler";
const STREAM: &'static str = "TEST";
const STREAM_SUBJECTS: &'static str = "test.>";
const FINALIZER: Option<&'static str> = Some("flatbed.test/cleanup");
fn reconcile(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<Action, Self::Error>> + Send>>
{
let log = Arc::clone(&self.call_log);
Box::pin(async move {
log.lock().unwrap().push("reconcile".to_string());
Ok(Action::requeue(Duration::from_secs(300)))
})
}
fn cleanup(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<(), Self::Error>> + Send>> {
let log = Arc::clone(&self.call_log);
Box::pin(async move {
log.lock().unwrap().push("cleanup".to_string());
Ok(())
})
}
}
#[tokio::test]
#[ignore]
async fn no_finalizer_calls_reconcile_directly() {
let ctx = test_ctx().await;
let name = "flatbed-test-no-fin";
delete_test_configmap(ctx.kube_client(), name).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let cm = create_test_configmap(ctx.kube_client(), name).await;
let call_log: CallLog = Arc::new(Mutex::new(Vec::new()));
let reconciler = NoFinalizerReconciler {
call_log: Arc::clone(&call_log),
};
let result = reconcile_with_finalizer(&reconciler, Arc::clone(&ctx), Arc::new(cm)).await;
assert!(result.is_ok(), "reconcile should succeed");
{
let log = call_log.lock().unwrap();
assert_eq!(*log, vec!["reconcile"], "Only reconcile should be called");
}
let refreshed = get_configmap(ctx.kube_client(), name).await;
let finalizers = refreshed.meta().finalizers.as_ref();
assert!(
finalizers.is_none() || finalizers.unwrap().is_empty(),
"No finalizer should be present on the resource"
);
delete_test_configmap(ctx.kube_client(), name).await;
}
#[tokio::test]
#[ignore]
async fn with_finalizer_adds_finalizer_and_reconciles() {
let ctx = test_ctx().await;
let name = "flatbed-test-with-fin";
delete_test_configmap(ctx.kube_client(), name).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let cm = create_test_configmap(ctx.kube_client(), name).await;
let call_log: CallLog = Arc::new(Mutex::new(Vec::new()));
let reconciler = WithFinalizerReconciler {
call_log: Arc::clone(&call_log),
};
let result = reconcile_with_finalizer(&reconciler, Arc::clone(&ctx), Arc::new(cm)).await;
assert!(result.is_ok(), "reconcile should succeed");
{
let log = call_log.lock().unwrap();
assert_eq!(
*log,
vec!["reconcile"],
"reconcile should be called (not cleanup)"
);
}
let refreshed = get_configmap(ctx.kube_client(), name).await;
assert!(
flatbed::k8s::has_finalizer(&refreshed, "flatbed.test/cleanup"),
"Finalizer should be present after reconcile"
);
let api: Api<ConfigMap> = Api::namespaced(ctx.kube_client().clone(), "default");
let patch = serde_json::json!({
"metadata": {
"finalizers": []
}
});
let _ = api
.patch(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(&patch),
)
.await;
delete_test_configmap(ctx.kube_client(), name).await;
}
#[tokio::test]
#[ignore]
async fn with_finalizer_and_deletion_calls_cleanup() {
let ctx = test_ctx().await;
let name = "flatbed-test-del-fin";
delete_test_configmap(ctx.kube_client(), name).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let cm = create_test_configmap(ctx.kube_client(), name).await;
let call_log: CallLog = Arc::new(Mutex::new(Vec::new()));
let reconciler = WithFinalizerReconciler {
call_log: Arc::clone(&call_log),
};
reconcile_with_finalizer(&reconciler, Arc::clone(&ctx), Arc::new(cm))
.await
.unwrap();
let refreshed = get_configmap(ctx.kube_client(), name).await;
assert!(
flatbed::k8s::has_finalizer(&refreshed, "flatbed.test/cleanup"),
"Finalizer should be present before delete"
);
let api: Api<ConfigMap> = Api::namespaced(ctx.kube_client().clone(), "default");
api.delete(name, &DeleteParams::default()).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let deleting_cm = get_configmap(ctx.kube_client(), name).await;
assert!(
deleting_cm.meta().deletion_timestamp.is_some(),
"Resource should have a deletion timestamp"
);
call_log.lock().unwrap().clear();
let result =
reconcile_with_finalizer(&reconciler, Arc::clone(&ctx), Arc::new(deleting_cm)).await;
assert!(result.is_ok(), "reconcile should succeed during cleanup");
{
let log = call_log.lock().unwrap();
assert_eq!(
*log,
vec!["cleanup"],
"cleanup should be called (not reconcile) when deleting"
);
}
tokio::time::sleep(Duration::from_millis(500)).await;
let api: Api<ConfigMap> = Api::namespaced(ctx.kube_client().clone(), "default");
let get_result = api.get(name).await;
assert!(
get_result.is_err(),
"Resource should be deleted after finalizer removal"
);
}
#[tokio::test]
#[ignore]
async fn deletion_without_finalizer_skips_cleanup() {
let ctx = test_ctx().await;
let name = "flatbed-test-del-nofin";
delete_test_configmap(ctx.kube_client(), name).await;
tokio::time::sleep(Duration::from_millis(500)).await;
let api: Api<ConfigMap> = Api::namespaced(ctx.kube_client().clone(), "default");
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some(name.to_string()),
finalizers: Some(vec!["other.finalizer/hold".to_string()]),
..Default::default()
},
..Default::default()
};
api.create(&PostParams::default(), &cm).await.unwrap();
api.delete(name, &DeleteParams::default()).await.unwrap();
tokio::time::sleep(Duration::from_millis(500)).await;
let deleting_cm = get_configmap(ctx.kube_client(), name).await;
assert!(
deleting_cm.meta().deletion_timestamp.is_some(),
"Resource should have a deletion timestamp"
);
assert!(
!flatbed::k8s::has_finalizer(&deleting_cm, "flatbed.test/cleanup"),
"Our finalizer should NOT be present"
);
let call_log: CallLog = Arc::new(Mutex::new(Vec::new()));
let reconciler = WithFinalizerReconciler {
call_log: Arc::clone(&call_log),
};
let result =
reconcile_with_finalizer(&reconciler, Arc::clone(&ctx), Arc::new(deleting_cm)).await;
assert!(result.is_ok(), "reconcile should succeed");
{
let log = call_log.lock().unwrap();
assert!(
log.is_empty(),
"Neither reconcile nor cleanup should be called when resource is deleting but our finalizer is absent"
);
}
let patch = serde_json::json!({
"metadata": {
"finalizers": []
}
});
let _ = api
.patch(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(&patch),
)
.await;
}