use std::fmt;
use std::sync::Arc;
use kube::Resource;
use tracing::{debug, error, info};
#[cfg(feature = "nats")]
use crate::nats::HasJetStream;
use crate::FlatbedWorkerError;
#[derive(Debug)]
pub enum ReconcileError {
Kube(kube::Error),
#[cfg(feature = "nats")]
Nats(String),
Internal(String),
}
impl fmt::Display for ReconcileError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ReconcileError::Kube(e) => write!(f, "Kubernetes error: {}", e),
#[cfg(feature = "nats")]
ReconcileError::Nats(e) => write!(f, "NATS error: {}", e),
ReconcileError::Internal(e) => write!(f, "internal error: {}", e),
}
}
}
impl std::error::Error for ReconcileError {}
impl From<kube::Error> for ReconcileError {
fn from(e: kube::Error) -> Self {
ReconcileError::Kube(e)
}
}
pub trait HasKubeClient {
fn kube_client(&self) -> &kube::Client;
}
pub trait HasLeaderElection {
fn is_leader_rx(&self) -> ::tokio::sync::watch::Receiver<bool>;
fn ha_enabled(&self) -> bool {
true
}
}
pub async fn wait_for_leadership(
rx: &mut ::tokio::sync::watch::Receiver<bool>,
worker_name: &str,
) -> bool {
loop {
if *rx.borrow_and_update() {
return true;
}
if rx.changed().await.is_err() {
error!(worker = %worker_name, "leader election channel closed");
return false;
}
}
}
pub async fn wait_for_leadership_loss(
rx: &mut ::tokio::sync::watch::Receiver<bool>,
worker_name: &str,
) {
loop {
if !*rx.borrow_and_update() {
return;
}
if rx.changed().await.is_err() {
error!(worker = %worker_name, "leader election channel closed");
return;
}
}
}
pub async fn wait_for_follower(
rx: &mut ::tokio::sync::watch::Receiver<bool>,
worker_name: &str,
) -> bool {
loop {
if !*rx.borrow_and_update() {
return true;
}
if rx.changed().await.is_err() {
error!(worker = %worker_name, "leader election channel closed");
return false;
}
}
}
pub async fn wait_for_follower_loss(
rx: &mut ::tokio::sync::watch::Receiver<bool>,
worker_name: &str,
) {
loop {
if *rx.borrow_and_update() {
return;
}
if rx.changed().await.is_err() {
error!(worker = %worker_name, "leader election channel closed");
return;
}
}
}
#[cfg(feature = "nats")]
pub trait KubeReconciler: Send + Sync + 'static {
type Resource: kube::Resource<DynamicType = ()>
+ Clone
+ serde::de::DeserializeOwned
+ std::fmt::Debug
+ Send
+ Sync
+ 'static;
type Context: HasJetStream + HasKubeClient + HasLeaderElection + Send + Sync + 'static;
type Error: std::error::Error + Into<ReconcileError> + Send + 'static;
const NAME: &'static str;
const DESCRIPTION: Option<&'static str> = None;
const RESTART: ::core::option::Option<crate::RestartPolicy> = ::core::option::Option::None;
const STREAM: &'static str;
const STREAM_SUBJECTS: &'static str;
const FINALIZER: Option<&'static str> = None;
fn reconcile(
&self,
ctx: Arc<Self::Context>,
obj: Arc<Self::Resource>,
) -> crate::BoxFuture<Result<kube::runtime::controller::Action, Self::Error>>;
fn cleanup(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> crate::BoxFuture<Result<(), Self::Error>> {
Box::pin(async { Ok(()) })
}
fn drain(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<Result<(), Self::Error>> {
Box::pin(async { Ok(()) })
}
fn configure_owns(
controller: kube::runtime::controller::Controller<Self::Resource>,
_client: &kube::Client,
) -> kube::runtime::controller::Controller<Self::Resource> {
controller
}
}
#[cfg(feature = "nats")]
struct ReconcileCtx<R: KubeReconciler> {
app_ctx: Arc<R::Context>,
reconciler: Arc<R>,
}
#[doc(hidden)]
pub fn has_finalizer<R>(obj: &R, finalizer: &str) -> bool
where
R: kube::Resource,
{
obj.meta()
.finalizers
.as_ref()
.is_some_and(|f| f.contains(&finalizer.to_string()))
}
async fn add_finalizer<R>(
client: &kube::Client,
obj: &R,
finalizer: &str,
) -> Result<(), ReconcileError>
where
R: kube::Resource<DynamicType = ()>,
{
let name = obj.meta().name.as_deref().unwrap_or_default();
let mut finalizers = obj.meta().finalizers.clone().unwrap_or_default();
if !finalizers.iter().any(|f| f == finalizer) {
finalizers.push(finalizer.to_string());
}
let patch = serde_json::json!({
"metadata": {
"finalizers": finalizers
}
});
let api = dynamic_api_for::<R>(client, obj);
api.patch(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(&patch),
)
.await?;
Ok(())
}
async fn remove_finalizer<R>(
client: &kube::Client,
obj: &R,
finalizer: &str,
) -> Result<(), ReconcileError>
where
R: kube::Resource<DynamicType = ()>,
{
let name = obj.meta().name.as_deref().unwrap_or_default();
let current = obj.meta().finalizers.as_ref().cloned().unwrap_or_default();
let remaining: Vec<&str> = current
.iter()
.filter(|f| f.as_str() != finalizer)
.map(|f| f.as_str())
.collect();
let patch = serde_json::json!({
"metadata": {
"finalizers": remaining
}
});
let api = dynamic_api_for::<R>(client, obj);
api.patch(
name,
&kube::api::PatchParams::default(),
&kube::api::Patch::Merge(&patch),
)
.await?;
Ok(())
}
fn dynamic_api_for<R>(client: &kube::Client, obj: &R) -> kube::Api<kube::api::DynamicObject>
where
R: kube::Resource<DynamicType = ()>,
{
let ar = kube::api::ApiResource::erase::<R>(&());
if let Some(ref ns) = obj.meta().namespace {
kube::Api::namespaced_with(client.clone(), ns, &ar)
} else {
kube::Api::all_with(client.clone(), &ar)
}
}
#[cfg(feature = "nats")]
#[doc(hidden)]
pub async fn reconcile_with_finalizer<R>(
reconciler: &R,
ctx: Arc<R::Context>,
obj: Arc<R::Resource>,
) -> Result<kube::runtime::controller::Action, ReconcileError>
where
R: KubeReconciler,
{
let Some(finalizer) = R::FINALIZER else {
return reconciler.reconcile(ctx, obj).await.map_err(Into::into);
};
let client = ctx.kube_client();
let is_deleting = obj.meta().deletion_timestamp.is_some();
if is_deleting {
if has_finalizer(&*obj, finalizer) {
reconciler
.cleanup(Arc::clone(&ctx), Arc::clone(&obj))
.await
.map_err(Into::into)?;
remove_finalizer(client, &*obj, finalizer).await?;
}
return Ok(kube::runtime::controller::Action::await_change());
}
if !has_finalizer(&*obj, finalizer) {
add_finalizer(client, &*obj, finalizer).await?;
}
reconciler.reconcile(ctx, obj).await.map_err(Into::into)
}
#[cfg(feature = "nats")]
pub async fn run_kube_reconciler<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> Result<(), FlatbedWorkerError>
where
R: KubeReconciler<Context = C> + Default,
C: HasJetStream + HasKubeClient + HasLeaderElection + Send + Sync + 'static,
R::Resource: serde::Serialize,
{
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("kube_reconciler '{}' context type mismatch", R::NAME));
let reconciler = Arc::new(R::default());
let mut is_leader_rx = HasLeaderElection::is_leader_rx(&*ctx);
let mut acquired_leadership = false;
loop {
if !wait_for_leadership(&mut is_leader_rx, R::NAME).await {
return Ok(());
}
let jetstream = HasJetStream::jetstream(&*ctx);
jetstream
.get_or_create_stream(async_nats::jetstream::stream::Config {
name: R::STREAM.to_string(),
subjects: vec![R::STREAM_SUBJECTS.to_string()],
retention: async_nats::jetstream::stream::RetentionPolicy::WorkQueue,
storage: async_nats::jetstream::stream::StorageType::Memory,
num_replicas: HasJetStream::stream_replicas(&*ctx),
..Default::default()
})
.await
.map_err(|e| {
FlatbedWorkerError::new(
"stream_init",
format!("[{}] Failed to create stream: {}", R::NAME, e),
)
})?;
if !acquired_leadership {
info!(reconciler = R::NAME, "became leader, starting controller");
info!(
reconciler = R::NAME,
stream = R::STREAM,
"JetStream stream ready"
);
acquired_leadership = true;
}
let reconcile_ctx = Arc::new(ReconcileCtx {
app_ctx: Arc::clone(&ctx),
reconciler: Arc::clone(&reconciler),
});
let kube_client = HasKubeClient::kube_client(&*ctx);
let resources: kube::Api<R::Resource> = kube::Api::all(kube_client.clone());
let controller = kube::runtime::controller::Controller::new(resources, Default::default());
let controller = R::configure_owns(controller, kube_client);
let mut rx2 = HasLeaderElection::is_leader_rx(&*ctx);
use futures::StreamExt;
tokio::select! {
_ = controller
.run(
|obj, rctx| {
let reconciler_ref = Arc::clone(&rctx.reconciler);
let app_ctx = Arc::clone(&rctx.app_ctx);
async move {
reconcile_with_finalizer(&*reconciler_ref, app_ctx, obj).await
}
},
|_resource, error, _ctx| {
error!(reconciler = R::NAME, error = %error, "reconciliation error");
kube::runtime::controller::Action::requeue(
std::time::Duration::from_secs(60),
)
},
reconcile_ctx,
)
.for_each(|res| async {
match res {
Ok(o) => debug!(reconciler = R::NAME, object = ?o, "reconciled"),
Err(e) => error!(reconciler = R::NAME, error = ?e, "reconcile failed"),
}
}) => {}
_ = wait_for_leadership_loss(&mut rx2, R::NAME) => {
info!(reconciler = R::NAME, "lost leadership, stopping controller");
acquired_leadership = false;
}
}
}
}
pub trait KubeNativeReconciler: Send + Sync + 'static {
type Resource: kube::Resource<DynamicType = ()>
+ Clone
+ serde::de::DeserializeOwned
+ std::fmt::Debug
+ Send
+ Sync
+ 'static;
type Context: HasKubeClient + HasLeaderElection + Send + Sync + 'static;
type Error: std::error::Error + Into<ReconcileError> + Send + 'static;
const NAME: &'static str;
const DESCRIPTION: Option<&'static str> = None;
const RESTART: ::core::option::Option<crate::RestartPolicy> = ::core::option::Option::None;
const FINALIZER: Option<&'static str> = None;
fn reconcile(
&self,
ctx: Arc<Self::Context>,
obj: Arc<Self::Resource>,
) -> crate::BoxFuture<Result<kube::runtime::controller::Action, Self::Error>>;
fn cleanup(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> crate::BoxFuture<Result<(), Self::Error>> {
Box::pin(async { Ok(()) })
}
fn drain(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<Result<(), Self::Error>> {
Box::pin(async { Ok(()) })
}
fn configure_owns(
controller: kube::runtime::controller::Controller<Self::Resource>,
_client: &kube::Client,
) -> kube::runtime::controller::Controller<Self::Resource> {
controller
}
}
struct ReconcileKubeNativeCtx<R: KubeNativeReconciler> {
app_ctx: Arc<R::Context>,
reconciler: Arc<R>,
}
#[doc(hidden)]
pub async fn reconcile_with_finalizer_kube_native<R>(
reconciler: &R,
ctx: Arc<R::Context>,
obj: Arc<R::Resource>,
) -> Result<kube::runtime::controller::Action, ReconcileError>
where
R: KubeNativeReconciler,
{
let Some(finalizer) = R::FINALIZER else {
return reconciler.reconcile(ctx, obj).await.map_err(Into::into);
};
let client = ctx.kube_client();
let is_deleting = obj.meta().deletion_timestamp.is_some();
if is_deleting {
if has_finalizer(&*obj, finalizer) {
reconciler
.cleanup(Arc::clone(&ctx), Arc::clone(&obj))
.await
.map_err(Into::into)?;
remove_finalizer(client, &*obj, finalizer).await?;
}
return Ok(kube::runtime::controller::Action::await_change());
}
if !has_finalizer(&*obj, finalizer) {
add_finalizer(client, &*obj, finalizer).await?;
}
reconciler.reconcile(ctx, obj).await.map_err(Into::into)
}
pub async fn run_kube_native_reconciler<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> Result<(), FlatbedWorkerError>
where
R: KubeNativeReconciler<Context = C> + Default,
C: HasKubeClient + HasLeaderElection + Send + Sync + 'static,
R::Resource: serde::Serialize,
{
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("kube_native_reconciler '{}' context type mismatch", R::NAME));
let reconciler = Arc::new(R::default());
let mut is_leader_rx = HasLeaderElection::is_leader_rx(&*ctx);
let mut acquired_leadership = false;
loop {
if !wait_for_leadership(&mut is_leader_rx, R::NAME).await {
return Ok(());
}
if !acquired_leadership {
info!(reconciler = R::NAME, "became leader, starting controller");
acquired_leadership = true;
}
let reconcile_ctx = Arc::new(ReconcileKubeNativeCtx {
app_ctx: Arc::clone(&ctx),
reconciler: Arc::clone(&reconciler),
});
let kube_client = HasKubeClient::kube_client(&*ctx);
let resources: kube::Api<R::Resource> = kube::Api::all(kube_client.clone());
let controller = kube::runtime::controller::Controller::new(resources, Default::default());
let controller = R::configure_owns(controller, kube_client);
let mut rx2 = HasLeaderElection::is_leader_rx(&*ctx);
use futures::StreamExt;
tokio::select! {
_ = controller
.run(
|obj, rctx| {
let reconciler_ref = Arc::clone(&rctx.reconciler);
let app_ctx = Arc::clone(&rctx.app_ctx);
async move {
reconcile_with_finalizer_kube_native(&*reconciler_ref, app_ctx, obj).await
}
},
|_resource, error, _ctx| {
error!(reconciler = R::NAME, error = %error, "reconciliation error");
kube::runtime::controller::Action::requeue(
std::time::Duration::from_secs(60),
)
},
reconcile_ctx,
)
.for_each(|res| async {
match res {
Ok(o) => debug!(reconciler = R::NAME, object = ?o, "reconciled"),
Err(e) => error!(reconciler = R::NAME, error = ?e, "reconcile failed"),
}
}) => {}
_ = wait_for_leadership_loss(&mut rx2, R::NAME) => {
info!(reconciler = R::NAME, "lost leadership, stopping controller");
acquired_leadership = false;
}
}
}
}
pub trait KubeWatcher: Send + Sync + 'static {
type Resource: kube::Resource<DynamicType = ()>
+ Clone
+ serde::de::DeserializeOwned
+ std::fmt::Debug
+ Send
+ Sync
+ 'static;
type Context: HasKubeClient + HasLeaderElection + Send + Sync + 'static;
const NAME: &'static str;
const DESCRIPTION: Option<&'static str> = None;
const RESTART: ::core::option::Option<crate::RestartPolicy> = ::core::option::Option::None;
fn on_apply(&self, ctx: Arc<Self::Context>, obj: Self::Resource) -> crate::BoxFuture<()>;
fn on_delete(&self, ctx: Arc<Self::Context>, obj: Self::Resource) -> crate::BoxFuture<()>;
fn on_init(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<()> {
Box::pin(async {})
}
fn on_init_apply(&self, ctx: Arc<Self::Context>, obj: Self::Resource) -> crate::BoxFuture<()>;
fn on_init_done(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<()> {
Box::pin(async {})
}
fn watch_config(&self) -> kube::runtime::watcher::Config {
kube::runtime::watcher::Config::default()
}
const LEADER_GATED: bool = true;
fn drain(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<Result<(), FlatbedWorkerError>> {
Box::pin(async { Ok(()) })
}
}
pub async fn run_kube_watcher<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> Result<(), FlatbedWorkerError>
where
R: KubeWatcher<Context = C> + Default,
C: HasKubeClient + HasLeaderElection + Send + Sync + 'static,
{
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("kube_watcher '{}' context type mismatch", R::NAME));
let reconciler = Arc::new(R::default());
if !R::LEADER_GATED {
info!(watcher = R::NAME, "starting non-leader-gated stream");
loop {
drive_kube_watcher::<R, C>(Arc::clone(&reconciler), Arc::clone(&ctx)).await;
info!(watcher = R::NAME, "stream cycle returned; will reopen");
}
}
let mut is_leader_rx = HasLeaderElection::is_leader_rx(&*ctx);
let mut acquired_leadership = false;
loop {
if !wait_for_leadership(&mut is_leader_rx, R::NAME).await {
return Ok(());
}
if !acquired_leadership {
info!(watcher = R::NAME, "became leader, starting stream");
acquired_leadership = true;
}
let mut loss_rx = HasLeaderElection::is_leader_rx(&*ctx);
tokio::select! {
_ = drive_kube_watcher::<R, C>(Arc::clone(&reconciler), Arc::clone(&ctx)) => {
info!(watcher = R::NAME, "stream cycle returned; will reopen");
}
_ = wait_for_leadership_loss(&mut loss_rx, R::NAME) => {
info!(watcher = R::NAME, "lost leadership, stopping stream");
acquired_leadership = false;
}
}
}
}
async fn drive_kube_watcher<R, C>(reconciler: Arc<R>, ctx: Arc<C>)
where
R: KubeWatcher<Context = C>,
C: HasKubeClient + HasLeaderElection + Send + Sync + 'static,
{
use futures::StreamExt;
use kube::runtime::watcher::watcher;
let kube_client = HasKubeClient::kube_client(&*ctx);
let api: kube::Api<R::Resource> = kube::Api::all(kube_client.clone());
let mut stream = watcher(api, reconciler.watch_config()).boxed();
while let Some(event) = stream.next().await {
dispatch_kube_event::<R, C>(&reconciler, Arc::clone(&ctx), event).await;
}
}
async fn dispatch_kube_event<R, C>(
reconciler: &R,
ctx: Arc<C>,
event: Result<kube::runtime::watcher::Event<R::Resource>, kube::runtime::watcher::Error>,
) where
R: KubeWatcher<Context = C>,
C: HasKubeClient + HasLeaderElection + Send + Sync + 'static,
{
use kube::runtime::watcher::Event;
match event {
Ok(Event::Apply(obj)) => reconciler.on_apply(ctx, obj).await,
Ok(Event::Delete(obj)) => reconciler.on_delete(ctx, obj).await,
Ok(Event::Init) => reconciler.on_init(ctx).await,
Ok(Event::InitApply(obj)) => reconciler.on_init_apply(ctx, obj).await,
Ok(Event::InitDone) => reconciler.on_init_done(ctx).await,
Err(e) => {
tracing::warn!(watcher = R::NAME, error = %e, "watch error; will re-list");
}
}
}
#[cfg(feature = "nats")]
pub fn run_kube_reconciler_drain<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> crate::BoxFuture<Result<(), FlatbedWorkerError>>
where
R: KubeReconciler<Context = C> + Default,
C: Send + Sync + 'static,
{
Box::pin(async move {
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("kube_reconciler '{}' context type mismatch", R::NAME));
R::default()
.drain(ctx)
.await
.map_err(|e| FlatbedWorkerError::new("DRAIN_ERROR", e.to_string()))
})
}
pub fn run_kube_native_reconciler_drain<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> crate::BoxFuture<Result<(), FlatbedWorkerError>>
where
R: KubeNativeReconciler<Context = C> + Default,
C: Send + Sync + 'static,
{
Box::pin(async move {
let ctx: Arc<C> = ctx.downcast::<C>().unwrap_or_else(|_| {
panic!("kube_native_reconciler '{}' context type mismatch", R::NAME)
});
R::default()
.drain(ctx)
.await
.map_err(|e| FlatbedWorkerError::new("DRAIN_ERROR", e.to_string()))
})
}
pub fn run_kube_watcher_drain<R, C>(
ctx: Arc<dyn std::any::Any + Send + Sync>,
) -> crate::BoxFuture<Result<(), FlatbedWorkerError>>
where
R: KubeWatcher<Context = C> + Default,
C: Send + Sync + 'static,
{
Box::pin(async move {
let ctx: Arc<C> = ctx
.downcast::<C>()
.unwrap_or_else(|_| panic!("kube_watcher '{}' context type mismatch", R::NAME));
R::default().drain(ctx).await
})
}
#[cfg(test)]
mod tests {
use super::*;
use tokio::sync::watch;
struct TestCtx {
rx: watch::Receiver<bool>,
}
impl HasLeaderElection for TestCtx {
fn is_leader_rx(&self) -> watch::Receiver<bool> {
self.rx.clone()
}
}
#[test]
fn ha_enabled_defaults_to_true() {
let (_tx, rx) = watch::channel(false);
let ctx = TestCtx { rx };
assert!(ctx.ha_enabled());
}
#[tokio::test]
async fn leadership_returns_immediately_when_already_leader() {
let (_tx, mut rx) = watch::channel(true);
assert!(wait_for_leadership(&mut rx, "test").await);
}
#[tokio::test]
async fn leadership_waits_then_returns_on_promotion() {
let (tx, mut rx) = watch::channel(false);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
tx.send(true).unwrap();
});
assert!(wait_for_leadership(&mut rx, "test").await);
}
#[tokio::test]
async fn leadership_returns_false_when_channel_closes() {
let (tx, mut rx) = watch::channel(false);
drop(tx);
assert!(!wait_for_leadership(&mut rx, "test").await);
}
#[tokio::test]
async fn leadership_loss_returns_immediately_when_not_leader() {
let (_tx, mut rx) = watch::channel(false);
wait_for_leadership_loss(&mut rx, "test").await;
}
#[tokio::test]
async fn leadership_loss_waits_then_returns_on_demotion() {
let (tx, mut rx) = watch::channel(true);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
tx.send(false).unwrap();
});
wait_for_leadership_loss(&mut rx, "test").await;
}
#[tokio::test]
async fn leadership_loss_returns_when_channel_closes() {
let (tx, mut rx) = watch::channel(true);
drop(tx);
wait_for_leadership_loss(&mut rx, "test").await;
}
#[tokio::test]
async fn follower_returns_immediately_when_not_leader() {
let (_tx, mut rx) = watch::channel(false);
assert!(wait_for_follower(&mut rx, "test").await);
}
#[tokio::test]
async fn follower_waits_then_returns_on_demotion() {
let (tx, mut rx) = watch::channel(true);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
tx.send(false).unwrap();
});
assert!(wait_for_follower(&mut rx, "test").await);
}
#[tokio::test]
async fn follower_returns_false_when_channel_closes() {
let (tx, mut rx) = watch::channel(true);
drop(tx);
assert!(!wait_for_follower(&mut rx, "test").await);
}
#[tokio::test]
async fn follower_blocks_when_always_leader() {
let (_tx, mut rx) = watch::channel(true);
let result = tokio::time::timeout(
std::time::Duration::from_millis(50),
wait_for_follower(&mut rx, "test"),
)
.await;
assert!(
result.is_err(),
"wait_for_follower should block when leader channel is always true"
);
}
#[tokio::test]
async fn follower_loss_returns_immediately_when_leader() {
let (_tx, mut rx) = watch::channel(true);
wait_for_follower_loss(&mut rx, "test").await;
}
#[tokio::test]
async fn follower_loss_waits_then_returns_on_promotion() {
let (tx, mut rx) = watch::channel(false);
tokio::spawn(async move {
tokio::time::sleep(std::time::Duration::from_millis(10)).await;
tx.send(true).unwrap();
});
wait_for_follower_loss(&mut rx, "test").await;
}
#[tokio::test]
async fn follower_loss_returns_when_channel_closes() {
let (tx, mut rx) = watch::channel(false);
drop(tx);
wait_for_follower_loss(&mut rx, "test").await;
}
#[test]
fn has_finalizer_returns_false_when_no_finalizers() {
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::ObjectMeta;
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("test".to_string()),
finalizers: Some(vec![]),
..Default::default()
},
..Default::default()
};
assert!(!has_finalizer(&cm, "my.finalizer/cleanup"));
}
#[test]
fn has_finalizer_returns_false_when_target_not_present() {
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::ObjectMeta;
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("test".to_string()),
finalizers: Some(vec![
"other.finalizer/one".to_string(),
"other.finalizer/two".to_string(),
]),
..Default::default()
},
..Default::default()
};
assert!(!has_finalizer(&cm, "my.finalizer/cleanup"));
}
#[test]
fn has_finalizer_returns_true_when_target_present() {
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::ObjectMeta;
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("test".to_string()),
finalizers: Some(vec![
"other.finalizer/one".to_string(),
"my.finalizer/cleanup".to_string(),
]),
..Default::default()
},
..Default::default()
};
assert!(has_finalizer(&cm, "my.finalizer/cleanup"));
}
#[test]
fn has_finalizer_returns_false_when_finalizers_is_none() {
use k8s_openapi::api::core::v1::ConfigMap;
use kube::api::ObjectMeta;
let cm = ConfigMap {
metadata: ObjectMeta {
name: Some("test".to_string()),
finalizers: None,
..Default::default()
},
..Default::default()
};
assert!(!has_finalizer(&cm, "my.finalizer/cleanup"));
}
#[cfg(feature = "nats")]
struct TestReconciler;
#[cfg(feature = "nats")]
struct TestReconcilerCtx {
kube_client: kube::Client,
jetstream: async_nats::jetstream::Context,
leader_rx: watch::Receiver<bool>,
}
#[cfg(feature = "nats")]
impl HasJetStream for TestReconcilerCtx {
fn jetstream(&self) -> &async_nats::jetstream::Context {
&self.jetstream
}
}
#[cfg(feature = "nats")]
impl HasKubeClient for TestReconcilerCtx {
fn kube_client(&self) -> &kube::Client {
&self.kube_client
}
}
#[cfg(feature = "nats")]
impl HasLeaderElection for TestReconcilerCtx {
fn is_leader_rx(&self) -> watch::Receiver<bool> {
self.leader_rx.clone()
}
}
#[cfg(feature = "nats")]
impl KubeReconciler for TestReconciler {
type Resource = k8s_openapi::api::core::v1::ConfigMap;
type Context = TestReconcilerCtx;
type Error = ReconcileError;
const NAME: &'static str = "test-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<kube::runtime::controller::Action, Self::Error>,
> + Send,
>,
> {
Box::pin(async {
Ok(kube::runtime::controller::Action::requeue(
std::time::Duration::from_secs(300),
))
})
}
}
#[tokio::test]
async fn cleanup_default_returns_ok() {
let fut: std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), ReconcileError>> + Send>,
> = Box::pin(async { Ok(()) });
let result = fut.await;
assert!(result.is_ok());
}
#[tokio::test]
async fn drain_default_returns_ok() {
let fut: std::pin::Pin<
Box<dyn std::future::Future<Output = Result<(), ReconcileError>> + Send>,
> = Box::pin(async { Ok(()) });
let result = fut.await;
assert!(result.is_ok());
}
#[cfg(feature = "nats")]
#[test]
fn configure_owns_default_returns_controller_unchanged() {
assert!(
<TestReconciler as KubeReconciler>::FINALIZER.is_none(),
"Default FINALIZER should be None"
);
assert!(
<TestReconciler as KubeReconciler>::DESCRIPTION.is_none(),
"Default DESCRIPTION should be None"
);
}
#[cfg(feature = "nats")]
#[test]
fn reconcile_error_display_kube() {
let err = ReconcileError::Nats("connection refused".to_string());
let display = format!("{}", err);
assert!(display.contains("NATS error"));
assert!(display.contains("connection refused"));
}
#[cfg(feature = "nats")]
#[test]
fn reconcile_error_display_nats() {
let err = ReconcileError::Nats("timeout".to_string());
assert_eq!(format!("{}", err), "NATS error: timeout");
}
struct KubeWatcherStubCtx;
impl HasKubeClient for KubeWatcherStubCtx {
fn kube_client(&self) -> &kube::Client {
panic!("stub: kube_client should not be called in dispatch tests");
}
}
impl HasLeaderElection for KubeWatcherStubCtx {
fn is_leader_rx(&self) -> watch::Receiver<bool> {
panic!("stub: is_leader_rx should not be called in dispatch tests");
}
}
#[derive(Default)]
struct DispatchCounters {
apply: std::sync::atomic::AtomicUsize,
delete: std::sync::atomic::AtomicUsize,
init: std::sync::atomic::AtomicUsize,
init_apply: std::sync::atomic::AtomicUsize,
init_done: std::sync::atomic::AtomicUsize,
}
#[derive(Default)]
struct TestKubeWatcher {
counters: Arc<DispatchCounters>,
}
impl KubeWatcher for TestKubeWatcher {
type Resource = k8s_openapi::api::core::v1::ConfigMap;
type Context = KubeWatcherStubCtx;
const NAME: &'static str = "test-kube-stream-reconciler";
fn on_apply(&self, _ctx: Arc<Self::Context>, _obj: Self::Resource) -> crate::BoxFuture<()> {
let counters = Arc::clone(&self.counters);
Box::pin(async move {
counters
.apply
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
fn on_delete(
&self,
_ctx: Arc<Self::Context>,
_obj: Self::Resource,
) -> crate::BoxFuture<()> {
let counters = Arc::clone(&self.counters);
Box::pin(async move {
counters
.delete
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
fn on_init(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<()> {
let counters = Arc::clone(&self.counters);
Box::pin(async move {
counters
.init
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
fn on_init_apply(
&self,
_ctx: Arc<Self::Context>,
_obj: Self::Resource,
) -> crate::BoxFuture<()> {
let counters = Arc::clone(&self.counters);
Box::pin(async move {
counters
.init_apply
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
fn on_init_done(&self, _ctx: Arc<Self::Context>) -> crate::BoxFuture<()> {
let counters = Arc::clone(&self.counters);
Box::pin(async move {
counters
.init_done
.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
})
}
}
fn empty_configmap() -> k8s_openapi::api::core::v1::ConfigMap {
use kube::api::ObjectMeta;
k8s_openapi::api::core::v1::ConfigMap {
metadata: ObjectMeta {
name: Some("dispatch-test".into()),
namespace: Some("default".into()),
..Default::default()
},
..Default::default()
}
}
#[tokio::test]
async fn dispatch_kube_event_routes_each_variant_to_its_callback() {
use kube::runtime::watcher::Event;
let reconciler = TestKubeWatcher::default();
let ctx = Arc::new(KubeWatcherStubCtx);
let cm = empty_configmap();
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(
&reconciler,
Arc::clone(&ctx),
Ok(Event::Apply(cm.clone())),
)
.await;
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(
&reconciler,
Arc::clone(&ctx),
Ok(Event::Delete(cm.clone())),
)
.await;
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(
&reconciler,
Arc::clone(&ctx),
Ok(Event::Init),
)
.await;
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(
&reconciler,
Arc::clone(&ctx),
Ok(Event::InitApply(cm.clone())),
)
.await;
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(
&reconciler,
Arc::clone(&ctx),
Ok(Event::InitDone),
)
.await;
let c = &reconciler.counters;
assert_eq!(c.apply.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.delete.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.init.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.init_apply.load(std::sync::atomic::Ordering::SeqCst), 1);
assert_eq!(c.init_done.load(std::sync::atomic::Ordering::SeqCst), 1);
}
#[tokio::test]
async fn dispatch_kube_event_swallows_errors_without_invoking_callbacks() {
use kube::runtime::watcher::Error as WatcherError;
let reconciler = TestKubeWatcher::default();
let ctx = Arc::new(KubeWatcherStubCtx);
let err: Result<
kube::runtime::watcher::Event<<TestKubeWatcher as KubeWatcher>::Resource>,
WatcherError,
> = Err(WatcherError::NoResourceVersion);
dispatch_kube_event::<TestKubeWatcher, KubeWatcherStubCtx>(&reconciler, ctx, err).await;
let c = &reconciler.counters;
assert_eq!(c.apply.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(c.delete.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(c.init.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(c.init_apply.load(std::sync::atomic::Ordering::SeqCst), 0);
assert_eq!(c.init_done.load(std::sync::atomic::Ordering::SeqCst), 0);
}
#[derive(Default)]
struct MinimalKubeWatcher;
impl KubeWatcher for MinimalKubeWatcher {
type Resource = k8s_openapi::api::core::v1::ConfigMap;
type Context = KubeWatcherStubCtx;
const NAME: &'static str = "minimal-kube-stream-reconciler";
fn on_apply(&self, _ctx: Arc<Self::Context>, _obj: Self::Resource) -> crate::BoxFuture<()> {
Box::pin(async {})
}
fn on_delete(
&self,
_ctx: Arc<Self::Context>,
_obj: Self::Resource,
) -> crate::BoxFuture<()> {
Box::pin(async {})
}
fn on_init_apply(
&self,
_ctx: Arc<Self::Context>,
_obj: Self::Resource,
) -> crate::BoxFuture<()> {
Box::pin(async {})
}
}
#[tokio::test]
async fn kube_watcher_defaults() {
assert!(<MinimalKubeWatcher as KubeWatcher>::DESCRIPTION.is_none());
#[allow(clippy::assertions_on_constants)]
{
assert!(
<MinimalKubeWatcher as KubeWatcher>::LEADER_GATED,
"default must be leader-gated"
);
}
let r = MinimalKubeWatcher;
let ctx = Arc::new(KubeWatcherStubCtx);
r.on_init(Arc::clone(&ctx)).await;
r.on_init_done(ctx).await;
}
crate::register_kube_watcher!(TestKubeWatcher, KubeWatcherStubCtx);
#[test]
fn register_kube_watcher_macro_makes_watcher_discoverable() {
let workers = crate::get_workers();
let found = workers
.iter()
.find(|w| w.name == <TestKubeWatcher as KubeWatcher>::NAME);
assert!(
found.is_some(),
"TestKubeWatcher should be discoverable via inventory"
);
let info = found.unwrap();
assert_eq!(info.name, "test-kube-stream-reconciler");
assert_eq!(
info.description,
<TestKubeWatcher as KubeWatcher>::DESCRIPTION
);
}
struct KubeNativeStubCtx;
impl HasKubeClient for KubeNativeStubCtx {
fn kube_client(&self) -> &kube::Client {
panic!("stub: kube_client should not be called in trait-default tests");
}
}
impl HasLeaderElection for KubeNativeStubCtx {
fn is_leader_rx(&self) -> watch::Receiver<bool> {
panic!("stub: is_leader_rx should not be called in trait-default tests");
}
}
#[derive(Default)]
struct TestKubeNativeReconciler;
impl KubeNativeReconciler for TestKubeNativeReconciler {
type Resource = k8s_openapi::api::core::v1::ConfigMap;
type Context = KubeNativeStubCtx;
type Error = ReconcileError;
const NAME: &'static str = "test-kube-native-reconciler";
const DESCRIPTION: Option<&'static str> = Some("trait-default pin target");
fn reconcile(
&self,
_ctx: Arc<Self::Context>,
_obj: Arc<Self::Resource>,
) -> crate::BoxFuture<Result<kube::runtime::controller::Action, Self::Error>> {
Box::pin(async {
Ok(kube::runtime::controller::Action::requeue(
std::time::Duration::from_secs(300),
))
})
}
}
#[test]
fn kube_native_reconciler_trait_defaults() {
assert!(<TestKubeNativeReconciler as KubeNativeReconciler>::FINALIZER.is_none());
assert_eq!(
<TestKubeNativeReconciler as KubeNativeReconciler>::DESCRIPTION,
Some("trait-default pin target")
);
assert_eq!(
<TestKubeNativeReconciler as KubeNativeReconciler>::NAME,
"test-kube-native-reconciler"
);
}
#[tokio::test]
async fn reconcile_with_finalizer_kube_native_skips_kube_when_no_finalizer() {
let r = TestKubeNativeReconciler;
let cm = k8s_openapi::api::core::v1::ConfigMap {
metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
name: Some("no-finalizer".into()),
namespace: Some("default".into()),
..Default::default()
},
..Default::default()
};
let ctx = Arc::new(KubeNativeStubCtx);
let result = reconcile_with_finalizer_kube_native(&r, ctx, Arc::new(cm)).await;
assert!(result.is_ok(), "no-finalizer path must succeed");
}
crate::register_kube_native_reconciler!(TestKubeNativeReconciler, KubeNativeStubCtx);
#[test]
fn register_kube_native_reconciler_macro_makes_reconciler_discoverable() {
let workers = crate::get_workers();
let found = workers
.iter()
.find(|w| w.name == <TestKubeNativeReconciler as KubeNativeReconciler>::NAME);
assert!(
found.is_some(),
"TestKubeNativeReconciler should be discoverable via inventory"
);
let info = found.unwrap();
assert_eq!(info.name, "test-kube-native-reconciler");
assert_eq!(
info.description,
<TestKubeNativeReconciler as KubeNativeReconciler>::DESCRIPTION
);
}
}