bindy/reconcilers/
finalizers.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Generic finalizer management for Kubernetes resources.
5//!
6//! This module provides reusable functions for adding, removing, and handling
7//! finalizers on Kubernetes custom resources. It eliminates duplicate finalizer
8//! management code across reconcilers.
9//!
10//! # Example
11//!
12//! ```rust,ignore
13//! use bindy::reconcilers::finalizers::{ensure_finalizer, handle_deletion, FinalizerCleanup};
14//! use bindy::crd::Bind9Cluster;
15//! use kube::Client;
16//! use anyhow::Result;
17//!
18//! const FINALIZER: &str = "bind9cluster.dns.firestoned.io/finalizer";
19//!
20//! #[async_trait::async_trait]
21//! impl FinalizerCleanup for Bind9Cluster {
22//!     async fn cleanup(&self, client: &Client) -> Result<()> {
23//!         // Perform cleanup operations
24//!         Ok(())
25//!     }
26//! }
27//!
28//! async fn reconcile(client: Client, cluster: Bind9Cluster) -> Result<()> {
29//!     // Ensure finalizer is present
30//!     ensure_finalizer(&client, &cluster, FINALIZER).await?;
31//!
32//!     // Handle deletion if resource is being deleted
33//!     if cluster.metadata.deletion_timestamp.is_some() {
34//!         return handle_deletion(&client, &cluster, FINALIZER).await;
35//!     }
36//!
37//!     // Normal reconciliation logic...
38//!     Ok(())
39//! }
40//! ```
41
42use anyhow::{anyhow, Context as AnyhowContext, Result};
43use kube::api::{Patch, PatchParams};
44use kube::core::{ClusterResourceScope, NamespaceResourceScope};
45use kube::{Api, Client, Resource, ResourceExt};
46use serde_json::json;
47use tracing::info;
48
49/// Builds the JSON Patch that atomically adds `finalizer` to a resource.
50///
51/// Mirrors the pattern used by kube-runtime's own finalizer helper:
52/// - When the resource has no finalizers, the patch `test`s that
53///   `/metadata/finalizers` is absent (null) before creating the array. This
54///   guarantees a racing writer that added a finalizer first is not clobbered.
55/// - When finalizers exist, JSON Patch has no test-for-absence, so the patch
56///   `test`s `/metadata/resourceVersion` instead and appends with the `-`
57///   (end-of-array) pointer, never rewriting existing entries.
58///
59/// If either `test` fails on the server (concurrent modification), the API
60/// call fails and the caller must requeue; the next reconciliation retries
61/// with fresh state.
62///
63/// # Arguments
64///
65/// * `existing_finalizers` - Finalizers currently on the (possibly stale) object
66/// * `resource_version` - The object's `metadata.resourceVersion`
67/// * `finalizer` - The finalizer string to add
68///
69/// # Errors
70///
71/// Returns an error if the resource has existing finalizers but no
72/// `resourceVersion` (cannot build a safe guarded patch), or if patch
73/// serialization fails.
74pub(crate) fn build_add_finalizer_patch(
75    existing_finalizers: &[String],
76    resource_version: Option<&str>,
77    finalizer: &str,
78) -> Result<json_patch::Patch> {
79    let operations = if existing_finalizers.is_empty() {
80        json!([
81            {"op": "test", "path": "/metadata/finalizers", "value": null},
82            {"op": "add", "path": "/metadata/finalizers", "value": [finalizer]},
83        ])
84    } else {
85        let resource_version = resource_version.ok_or_else(|| {
86            anyhow!("resource has no resourceVersion; cannot safely add finalizer {finalizer}")
87        })?;
88        json!([
89            {"op": "test", "path": "/metadata/resourceVersion", "value": resource_version},
90            {"op": "add", "path": "/metadata/finalizers/-", "value": finalizer},
91        ])
92    };
93
94    serde_json::from_value(operations).context("failed to build add-finalizer JSON Patch")
95}
96
97/// Builds the JSON Patch that atomically removes `finalizer` from a resource.
98///
99/// Mirrors the pattern used by kube-runtime's own finalizer helper: the patch
100/// `test`s that the exact array index still contains our finalizer before
101/// removing that index. If a racing writer added or removed a finalizer in the
102/// meantime (shifting indices), the `test` fails server-side and nothing is
103/// removed - so a foreign finalizer can never be dropped by accident.
104///
105/// # Arguments
106///
107/// * `existing_finalizers` - Finalizers currently on the (possibly stale) object
108/// * `finalizer` - The finalizer string to remove
109///
110/// # Returns
111///
112/// `Ok(None)` when the finalizer is not present (nothing to do).
113///
114/// # Errors
115///
116/// Returns an error if patch serialization fails.
117pub(crate) fn build_remove_finalizer_patch(
118    existing_finalizers: &[String],
119    finalizer: &str,
120) -> Result<Option<json_patch::Patch>> {
121    let Some(index) = existing_finalizers.iter().position(|f| f == finalizer) else {
122        return Ok(None);
123    };
124
125    let finalizer_path = format!("/metadata/finalizers/{index}");
126    let operations = json!([
127        {"op": "test", "path": finalizer_path, "value": finalizer},
128        {"op": "remove", "path": finalizer_path},
129    ]);
130
131    serde_json::from_value(operations)
132        .map(Some)
133        .context("failed to build remove-finalizer JSON Patch")
134}
135
136/// Trait for resources that require cleanup operations when being deleted.
137///
138/// Implement this trait to define custom cleanup logic that should run
139/// before a finalizer is removed from a resource.
140#[async_trait::async_trait]
141pub trait FinalizerCleanup: Resource + ResourceExt + Clone {
142    /// Perform cleanup operations before the finalizer is removed.
143    ///
144    /// This method is called when a resource with a deletion timestamp
145    /// still has the finalizer present. Implement any cleanup logic needed
146    /// before the resource is fully deleted.
147    ///
148    /// # Arguments
149    ///
150    /// * `client` - Kubernetes client for accessing the API
151    ///
152    /// # Returns
153    ///
154    /// Returns `Ok(())` if cleanup succeeded, or an error if cleanup failed.
155    /// If this method returns an error, the finalizer will NOT be removed and
156    /// deletion will be blocked until cleanup succeeds.
157    ///
158    /// # Errors
159    ///
160    /// Should return an error if:
161    /// - Child resources cannot be deleted
162    /// - External systems cannot be cleaned up
163    /// - Any other cleanup operation fails
164    async fn cleanup(&self, client: &Client) -> Result<()>;
165}
166
167/// Add a finalizer to a resource if not already present.
168///
169/// This function checks if the specified finalizer is present on the resource,
170/// and adds it if missing. The operation is idempotent - calling it multiple
171/// times has no effect if the finalizer is already present.
172///
173/// # Arguments
174///
175/// * `client` - Kubernetes client for accessing the API
176/// * `resource` - The resource to add the finalizer to
177/// * `finalizer` - The finalizer string to add
178///
179/// # Returns
180///
181/// Returns `Ok(())` if the finalizer was added or already present.
182///
183/// # Concurrency
184///
185/// The patch is a JSON Patch guarded by a `test` operation (mirroring
186/// kube-runtime's finalizer helper), so a racing writer's finalizer edits are
187/// never clobbered. On a conflict the API call fails and the returned error
188/// triggers a requeue; the next reconciliation retries with fresh state.
189///
190/// # Errors
191///
192/// Returns an error if:
193/// - The resource has no namespace (for namespaced resources)
194/// - The API patch operation fails, including when a concurrent writer
195///   modified `metadata.finalizers` and the guarded patch was rejected
196///
197/// # Example
198///
199/// ```rust,no_run
200/// # use bindy::reconcilers::finalizers::ensure_finalizer;
201/// # use bindy::crd::Bind9Cluster;
202/// # use kube::Client;
203/// # async fn example(client: Client, cluster: Bind9Cluster) {
204/// const FINALIZER: &str = "bind9cluster.dns.firestoned.io/finalizer";
205/// ensure_finalizer(&client, &cluster, FINALIZER).await.unwrap();
206/// # }
207/// ```
208pub async fn ensure_finalizer<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
209where
210    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
211        + ResourceExt
212        + Clone
213        + std::fmt::Debug
214        + serde::Serialize
215        + for<'de> serde::Deserialize<'de>,
216{
217    let namespace = resource.namespace().unwrap_or_default();
218    let name = resource.name_any();
219
220    let finalizers = resource.meta().finalizers.clone().unwrap_or_default();
221
222    // Early return: finalizer already present
223    if finalizers.iter().any(|f| f == finalizer) {
224        return Ok(());
225    }
226
227    info!(
228        "Adding finalizer {} to {}/{} {}",
229        finalizer,
230        namespace,
231        name,
232        T::kind(&())
233    );
234
235    let patch = build_add_finalizer_patch(
236        &finalizers,
237        resource.meta().resource_version.as_deref(),
238        finalizer,
239    )?;
240
241    let api: Api<T> = Api::namespaced(client.clone(), &namespace);
242    api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch))
243        .await
244        .with_context(|| {
245            format!(
246                "failed to add finalizer {finalizer} to {namespace}/{name} \
247                 (possibly a concurrent finalizer edit; will retry on next reconciliation)"
248            )
249        })?;
250
251    info!(
252        "Successfully added finalizer {} to {}/{} {}",
253        finalizer,
254        namespace,
255        name,
256        T::kind(&())
257    );
258
259    Ok(())
260}
261
262/// Remove a finalizer from a resource.
263///
264/// This function removes the specified finalizer from the resource if present.
265/// The operation is idempotent - calling it multiple times has no effect if
266/// the finalizer is already absent.
267///
268/// **Note:** Typically you should use `handle_deletion()` instead of calling
269/// this function directly, as it performs cleanup before removing the finalizer.
270///
271/// # Arguments
272///
273/// * `client` - Kubernetes client for accessing the API
274/// * `resource` - The resource to remove the finalizer from
275/// * `finalizer` - The finalizer string to remove
276///
277/// # Returns
278///
279/// Returns `Ok(())` if the finalizer was removed or already absent.
280///
281/// # Concurrency
282///
283/// The patch is a JSON Patch that `test`s the exact array index before
284/// removing it (mirroring kube-runtime's finalizer helper), so a racing
285/// writer's finalizer edits are never clobbered. On a conflict the API call
286/// fails and the returned error triggers a requeue; the next reconciliation
287/// retries with fresh state.
288///
289/// # Errors
290///
291/// Returns an error if:
292/// - The resource has no namespace (for namespaced resources)
293/// - The API patch operation fails, including when a concurrent writer
294///   modified `metadata.finalizers` and the guarded patch was rejected
295pub async fn remove_finalizer<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
296where
297    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
298        + ResourceExt
299        + Clone
300        + std::fmt::Debug
301        + serde::Serialize
302        + for<'de> serde::Deserialize<'de>,
303{
304    let namespace = resource.namespace().unwrap_or_default();
305    let name = resource.name_any();
306
307    let finalizers = resource.meta().finalizers.clone().unwrap_or_default();
308
309    // Early return: finalizer already absent
310    let Some(patch) = build_remove_finalizer_patch(&finalizers, finalizer)? else {
311        return Ok(());
312    };
313
314    info!(
315        "Removing finalizer {} from {}/{} {}",
316        finalizer,
317        namespace,
318        name,
319        T::kind(&())
320    );
321
322    let api: Api<T> = Api::namespaced(client.clone(), &namespace);
323    api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch))
324        .await
325        .with_context(|| {
326            format!(
327                "failed to remove finalizer {finalizer} from {namespace}/{name} \
328                 (possibly a concurrent finalizer edit; will retry on next reconciliation)"
329            )
330        })?;
331
332    info!(
333        "Successfully removed finalizer {} from {}/{} {}",
334        finalizer,
335        namespace,
336        name,
337        T::kind(&())
338    );
339
340    Ok(())
341}
342
343/// Handle resource deletion with cleanup and finalizer removal.
344///
345/// This function orchestrates the complete deletion process:
346/// 1. Logs that the resource is being deleted
347/// 2. Calls the resource's `cleanup()` method to perform cleanup operations
348/// 3. Removes the finalizer to allow Kubernetes to delete the resource
349///
350/// This function should be called when a resource has a deletion timestamp
351/// and the finalizer is still present.
352///
353/// # Arguments
354///
355/// * `client` - Kubernetes client for accessing the API
356/// * `resource` - The resource being deleted
357/// * `finalizer` - The finalizer string to check and remove
358///
359/// # Returns
360///
361/// Returns `Ok(())` if cleanup and finalizer removal succeeded.
362///
363/// # Errors
364///
365/// Returns an error if:
366/// - The cleanup operation fails
367/// - The finalizer removal fails
368///
369/// If an error occurs, the finalizer will remain on the resource and deletion
370/// will be blocked until the operation succeeds on a subsequent reconciliation.
371///
372/// # Example
373///
374/// ```text
375/// use bindy::reconcilers::finalizers::{handle_deletion, FinalizerCleanup};
376/// use bindy::crd::Bind9Cluster;
377/// use kube::Client;
378/// use anyhow::Result;
379///
380/// const FINALIZER: &str = "bind9cluster.dns.firestoned.io/finalizer";
381///
382/// async fn reconcile(client: Client, cluster: Bind9Cluster) -> Result<()> {
383///     if cluster.metadata.deletion_timestamp.is_some() {
384///         return handle_deletion(&client, &cluster, FINALIZER).await;
385///     }
386///     // Normal reconciliation...
387///     Ok(())
388/// }
389/// ```
390pub async fn handle_deletion<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
391where
392    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
393        + ResourceExt
394        + FinalizerCleanup
395        + Clone
396        + std::fmt::Debug
397        + serde::Serialize
398        + for<'de> serde::Deserialize<'de>,
399{
400    let namespace = resource.namespace().unwrap_or_default();
401    let name = resource.name_any();
402
403    info!("{} {}/{} is being deleted", T::kind(&()), namespace, name);
404
405    // Only proceed if the finalizer is present
406    if resource
407        .meta()
408        .finalizers
409        .as_ref()
410        .is_some_and(|f| f.contains(&finalizer.to_string()))
411    {
412        info!(
413            "Running cleanup for {} {}/{}",
414            T::kind(&()),
415            namespace,
416            name
417        );
418
419        // Perform cleanup operations
420        resource.cleanup(client).await?;
421
422        // Remove the finalizer
423        remove_finalizer(client, resource, finalizer).await?;
424    }
425
426    Ok(())
427}
428
429/// Add a finalizer to a cluster-scoped resource if not already present.
430///
431/// This function is similar to `ensure_finalizer()` but works with cluster-scoped
432/// resources that don't have a namespace. It checks if the specified finalizer is
433/// present on the resource, and adds it if missing.
434///
435/// # Arguments
436///
437/// * `client` - Kubernetes client for accessing the API
438/// * `resource` - The cluster-scoped resource to add the finalizer to
439/// * `finalizer` - The finalizer string to add
440///
441/// # Returns
442///
443/// Returns `Ok(())` if the finalizer was added or already present.
444///
445/// # Concurrency
446///
447/// The patch is a JSON Patch guarded by a `test` operation (mirroring
448/// kube-runtime's finalizer helper), so a racing writer's finalizer edits are
449/// never clobbered. On a conflict the API call fails and the returned error
450/// triggers a requeue; the next reconciliation retries with fresh state.
451///
452/// # Errors
453///
454/// Returns an error if the API patch operation fails, including when a
455/// concurrent writer modified `metadata.finalizers` and the guarded patch
456/// was rejected.
457///
458/// # Example
459///
460/// ```rust,no_run
461/// # use bindy::reconcilers::finalizers::ensure_cluster_finalizer;
462/// # use bindy::crd::ClusterBind9Provider;
463/// # use kube::Client;
464/// # async fn example(client: Client, cluster: ClusterBind9Provider) {
465/// const FINALIZER: &str = "bind9globalcluster.dns.firestoned.io/finalizer";
466/// ensure_cluster_finalizer(&client, &cluster, FINALIZER).await.unwrap();
467/// # }
468/// ```
469pub async fn ensure_cluster_finalizer<T>(
470    client: &Client,
471    resource: &T,
472    finalizer: &str,
473) -> Result<()>
474where
475    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
476        + ResourceExt
477        + Clone
478        + std::fmt::Debug
479        + serde::Serialize
480        + for<'de> serde::Deserialize<'de>,
481{
482    let name = resource.name_any();
483
484    let finalizers = resource.meta().finalizers.clone().unwrap_or_default();
485
486    // Early return: finalizer already present
487    if finalizers.iter().any(|f| f == finalizer) {
488        return Ok(());
489    }
490
491    info!(
492        "Adding finalizer {} to {} {}",
493        finalizer,
494        T::kind(&()),
495        name
496    );
497
498    let patch = build_add_finalizer_patch(
499        &finalizers,
500        resource.meta().resource_version.as_deref(),
501        finalizer,
502    )?;
503
504    let api: Api<T> = Api::all(client.clone());
505    api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch))
506        .await
507        .with_context(|| {
508            format!(
509                "failed to add finalizer {finalizer} to {name} \
510                 (possibly a concurrent finalizer edit; will retry on next reconciliation)"
511            )
512        })?;
513
514    info!(
515        "Successfully added finalizer {} to {} {}",
516        finalizer,
517        T::kind(&()),
518        name
519    );
520
521    Ok(())
522}
523
524/// Remove a finalizer from a cluster-scoped resource.
525///
526/// This function removes the specified finalizer from the cluster-scoped resource
527/// if present. The operation is idempotent - calling it multiple times has no effect
528/// if the finalizer is already absent.
529///
530/// **Note:** Typically you should use `handle_cluster_deletion()` instead of calling
531/// this function directly, as it performs cleanup before removing the finalizer.
532///
533/// # Arguments
534///
535/// * `client` - Kubernetes client for accessing the API
536/// * `resource` - The cluster-scoped resource to remove the finalizer from
537/// * `finalizer` - The finalizer string to remove
538///
539/// # Returns
540///
541/// Returns `Ok(())` if the finalizer was removed or already absent.
542///
543/// # Concurrency
544///
545/// The patch is a JSON Patch that `test`s the exact array index before
546/// removing it (mirroring kube-runtime's finalizer helper), so a racing
547/// writer's finalizer edits are never clobbered. On a conflict the API call
548/// fails and the returned error triggers a requeue; the next reconciliation
549/// retries with fresh state.
550///
551/// # Errors
552///
553/// Returns an error if the API patch operation fails, including when a
554/// concurrent writer modified `metadata.finalizers` and the guarded patch
555/// was rejected.
556pub async fn remove_cluster_finalizer<T>(
557    client: &Client,
558    resource: &T,
559    finalizer: &str,
560) -> Result<()>
561where
562    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
563        + ResourceExt
564        + Clone
565        + std::fmt::Debug
566        + serde::Serialize
567        + for<'de> serde::Deserialize<'de>,
568{
569    let name = resource.name_any();
570
571    let finalizers = resource.meta().finalizers.clone().unwrap_or_default();
572
573    // Early return: finalizer already absent
574    let Some(patch) = build_remove_finalizer_patch(&finalizers, finalizer)? else {
575        return Ok(());
576    };
577
578    info!(
579        "Removing finalizer {} from {} {}",
580        finalizer,
581        T::kind(&()),
582        name
583    );
584
585    let api: Api<T> = Api::all(client.clone());
586    api.patch(&name, &PatchParams::default(), &Patch::Json::<()>(patch))
587        .await
588        .with_context(|| {
589            format!(
590                "failed to remove finalizer {finalizer} from {name} \
591                 (possibly a concurrent finalizer edit; will retry on next reconciliation)"
592            )
593        })?;
594
595    info!(
596        "Successfully removed finalizer {} from {} {}",
597        finalizer,
598        T::kind(&()),
599        name
600    );
601
602    Ok(())
603}
604
605/// Handle cluster-scoped resource deletion with cleanup and finalizer removal.
606///
607/// This function orchestrates the complete deletion process for cluster-scoped resources:
608/// 1. Logs that the resource is being deleted
609/// 2. Calls the resource's `cleanup()` method to perform cleanup operations
610/// 3. Removes the finalizer to allow Kubernetes to delete the resource
611///
612/// This function should be called when a cluster-scoped resource has a deletion
613/// timestamp and the finalizer is still present.
614///
615/// # Arguments
616///
617/// * `client` - Kubernetes client for accessing the API
618/// * `resource` - The cluster-scoped resource being deleted
619/// * `finalizer` - The finalizer string to check and remove
620///
621/// # Returns
622///
623/// Returns `Ok(())` if cleanup and finalizer removal succeeded.
624///
625/// # Errors
626///
627/// Returns an error if:
628/// - The cleanup operation fails
629/// - The finalizer removal fails
630///
631/// If an error occurs, the finalizer will remain on the resource and deletion
632/// will be blocked until the operation succeeds on a subsequent reconciliation.
633///
634/// # Example
635///
636/// ```text
637/// use bindy::reconcilers::finalizers::{handle_cluster_deletion, FinalizerCleanup};
638/// use bindy::crd::ClusterBind9Provider;
639/// use kube::Client;
640/// use anyhow::Result;
641///
642/// const FINALIZER: &str = "bind9globalcluster.dns.firestoned.io/finalizer";
643///
644/// async fn reconcile(client: Client, cluster: ClusterBind9Provider) -> Result<()> {
645///     if cluster.metadata.deletion_timestamp.is_some() {
646///         return handle_cluster_deletion(&client, &cluster, FINALIZER).await;
647///     }
648///     // Normal reconciliation...
649///     Ok(())
650/// }
651/// ```
652pub async fn handle_cluster_deletion<T>(
653    client: &Client,
654    resource: &T,
655    finalizer: &str,
656) -> Result<()>
657where
658    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
659        + ResourceExt
660        + FinalizerCleanup
661        + Clone
662        + std::fmt::Debug
663        + serde::Serialize
664        + for<'de> serde::Deserialize<'de>,
665{
666    let name = resource.name_any();
667
668    info!("{} {} is being deleted", T::kind(&()), name);
669
670    // Only proceed if the finalizer is present
671    if resource
672        .meta()
673        .finalizers
674        .as_ref()
675        .is_some_and(|f| f.contains(&finalizer.to_string()))
676    {
677        info!("Running cleanup for {} {}", T::kind(&()), name);
678
679        // Perform cleanup operations
680        resource.cleanup(client).await?;
681
682        // Remove the finalizer
683        remove_cluster_finalizer(client, resource, finalizer).await?;
684    }
685
686    Ok(())
687}
688
689#[cfg(test)]
690#[path = "finalizers_tests.rs"]
691mod finalizers_tests;