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::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/// Trait for resources that require cleanup operations when being deleted.
50///
51/// Implement this trait to define custom cleanup logic that should run
52/// before a finalizer is removed from a resource.
53#[async_trait::async_trait]
54pub trait FinalizerCleanup: Resource + ResourceExt + Clone {
55    /// Perform cleanup operations before the finalizer is removed.
56    ///
57    /// This method is called when a resource with a deletion timestamp
58    /// still has the finalizer present. Implement any cleanup logic needed
59    /// before the resource is fully deleted.
60    ///
61    /// # Arguments
62    ///
63    /// * `client` - Kubernetes client for accessing the API
64    ///
65    /// # Returns
66    ///
67    /// Returns `Ok(())` if cleanup succeeded, or an error if cleanup failed.
68    /// If this method returns an error, the finalizer will NOT be removed and
69    /// deletion will be blocked until cleanup succeeds.
70    ///
71    /// # Errors
72    ///
73    /// Should return an error if:
74    /// - Child resources cannot be deleted
75    /// - External systems cannot be cleaned up
76    /// - Any other cleanup operation fails
77    async fn cleanup(&self, client: &Client) -> Result<()>;
78}
79
80/// Add a finalizer to a resource if not already present.
81///
82/// This function checks if the specified finalizer is present on the resource,
83/// and adds it if missing. The operation is idempotent - calling it multiple
84/// times has no effect if the finalizer is already present.
85///
86/// # Arguments
87///
88/// * `client` - Kubernetes client for accessing the API
89/// * `resource` - The resource to add the finalizer to
90/// * `finalizer` - The finalizer string to add
91///
92/// # Returns
93///
94/// Returns `Ok(())` if the finalizer was added or already present.
95///
96/// # Errors
97///
98/// Returns an error if:
99/// - The resource has no namespace (for namespaced resources)
100/// - The API patch operation fails
101///
102/// # Example
103///
104/// ```rust,no_run
105/// # use bindy::reconcilers::finalizers::ensure_finalizer;
106/// # use bindy::crd::Bind9Cluster;
107/// # use kube::Client;
108/// # async fn example(client: Client, cluster: Bind9Cluster) {
109/// const FINALIZER: &str = "bind9cluster.dns.firestoned.io/finalizer";
110/// ensure_finalizer(&client, &cluster, FINALIZER).await.unwrap();
111/// # }
112/// ```
113pub async fn ensure_finalizer<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
114where
115    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
116        + ResourceExt
117        + Clone
118        + std::fmt::Debug
119        + serde::Serialize
120        + for<'de> serde::Deserialize<'de>,
121{
122    let namespace = resource.namespace().unwrap_or_default();
123    let name = resource.name_any();
124
125    // Check if finalizer is already present
126    if resource
127        .meta()
128        .finalizers
129        .as_ref()
130        .is_none_or(|f| !f.contains(&finalizer.to_string()))
131    {
132        info!(
133            "Adding finalizer {} to {}/{} {}",
134            finalizer,
135            namespace,
136            name,
137            T::kind(&())
138        );
139
140        let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default();
141        finalizers.push(finalizer.to_string());
142
143        let api: Api<T> = Api::namespaced(client.clone(), &namespace);
144        let patch = json!({ "metadata": { "finalizers": finalizers } });
145        api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
146            .await?;
147
148        info!(
149            "Successfully added finalizer {} to {}/{} {}",
150            finalizer,
151            namespace,
152            name,
153            T::kind(&())
154        );
155    }
156
157    Ok(())
158}
159
160/// Remove a finalizer from a resource.
161///
162/// This function removes the specified finalizer from the resource if present.
163/// The operation is idempotent - calling it multiple times has no effect if
164/// the finalizer is already absent.
165///
166/// **Note:** Typically you should use `handle_deletion()` instead of calling
167/// this function directly, as it performs cleanup before removing the finalizer.
168///
169/// # Arguments
170///
171/// * `client` - Kubernetes client for accessing the API
172/// * `resource` - The resource to remove the finalizer from
173/// * `finalizer` - The finalizer string to remove
174///
175/// # Returns
176///
177/// Returns `Ok(())` if the finalizer was removed or already absent.
178///
179/// # Errors
180///
181/// Returns an error if:
182/// - The resource has no namespace (for namespaced resources)
183/// - The API patch operation fails
184pub async fn remove_finalizer<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
185where
186    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
187        + ResourceExt
188        + Clone
189        + std::fmt::Debug
190        + serde::Serialize
191        + for<'de> serde::Deserialize<'de>,
192{
193    let namespace = resource.namespace().unwrap_or_default();
194    let name = resource.name_any();
195
196    // Check if finalizer is present
197    if resource
198        .meta()
199        .finalizers
200        .as_ref()
201        .is_some_and(|f| f.contains(&finalizer.to_string()))
202    {
203        info!(
204            "Removing finalizer {} from {}/{} {}",
205            finalizer,
206            namespace,
207            name,
208            T::kind(&())
209        );
210
211        let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default();
212        finalizers.retain(|f| f != finalizer);
213
214        let api: Api<T> = Api::namespaced(client.clone(), &namespace);
215        let patch = json!({ "metadata": { "finalizers": finalizers } });
216        api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
217            .await?;
218
219        info!(
220            "Successfully removed finalizer {} from {}/{} {}",
221            finalizer,
222            namespace,
223            name,
224            T::kind(&())
225        );
226    }
227
228    Ok(())
229}
230
231/// Handle resource deletion with cleanup and finalizer removal.
232///
233/// This function orchestrates the complete deletion process:
234/// 1. Logs that the resource is being deleted
235/// 2. Calls the resource's `cleanup()` method to perform cleanup operations
236/// 3. Removes the finalizer to allow Kubernetes to delete the resource
237///
238/// This function should be called when a resource has a deletion timestamp
239/// and the finalizer is still present.
240///
241/// # Arguments
242///
243/// * `client` - Kubernetes client for accessing the API
244/// * `resource` - The resource being deleted
245/// * `finalizer` - The finalizer string to check and remove
246///
247/// # Returns
248///
249/// Returns `Ok(())` if cleanup and finalizer removal succeeded.
250///
251/// # Errors
252///
253/// Returns an error if:
254/// - The cleanup operation fails
255/// - The finalizer removal fails
256///
257/// If an error occurs, the finalizer will remain on the resource and deletion
258/// will be blocked until the operation succeeds on a subsequent reconciliation.
259///
260/// # Example
261///
262/// ```text
263/// use bindy::reconcilers::finalizers::{handle_deletion, FinalizerCleanup};
264/// use bindy::crd::Bind9Cluster;
265/// use kube::Client;
266/// use anyhow::Result;
267///
268/// const FINALIZER: &str = "bind9cluster.dns.firestoned.io/finalizer";
269///
270/// async fn reconcile(client: Client, cluster: Bind9Cluster) -> Result<()> {
271///     if cluster.metadata.deletion_timestamp.is_some() {
272///         return handle_deletion(&client, &cluster, FINALIZER).await;
273///     }
274///     // Normal reconciliation...
275///     Ok(())
276/// }
277/// ```
278pub async fn handle_deletion<T>(client: &Client, resource: &T, finalizer: &str) -> Result<()>
279where
280    T: Resource<DynamicType = (), Scope = NamespaceResourceScope>
281        + ResourceExt
282        + FinalizerCleanup
283        + Clone
284        + std::fmt::Debug
285        + serde::Serialize
286        + for<'de> serde::Deserialize<'de>,
287{
288    let namespace = resource.namespace().unwrap_or_default();
289    let name = resource.name_any();
290
291    info!("{} {}/{} is being deleted", T::kind(&()), namespace, name);
292
293    // Only proceed if the finalizer is present
294    if resource
295        .meta()
296        .finalizers
297        .as_ref()
298        .is_some_and(|f| f.contains(&finalizer.to_string()))
299    {
300        info!(
301            "Running cleanup for {} {}/{}",
302            T::kind(&()),
303            namespace,
304            name
305        );
306
307        // Perform cleanup operations
308        resource.cleanup(client).await?;
309
310        // Remove the finalizer
311        remove_finalizer(client, resource, finalizer).await?;
312    }
313
314    Ok(())
315}
316
317/// Add a finalizer to a cluster-scoped resource if not already present.
318///
319/// This function is similar to `ensure_finalizer()` but works with cluster-scoped
320/// resources that don't have a namespace. It checks if the specified finalizer is
321/// present on the resource, and adds it if missing.
322///
323/// # Arguments
324///
325/// * `client` - Kubernetes client for accessing the API
326/// * `resource` - The cluster-scoped resource to add the finalizer to
327/// * `finalizer` - The finalizer string to add
328///
329/// # Returns
330///
331/// Returns `Ok(())` if the finalizer was added or already present.
332///
333/// # Errors
334///
335/// Returns an error if the API patch operation fails.
336///
337/// # Example
338///
339/// ```rust,no_run
340/// # use bindy::reconcilers::finalizers::ensure_cluster_finalizer;
341/// # use bindy::crd::ClusterBind9Provider;
342/// # use kube::Client;
343/// # async fn example(client: Client, cluster: ClusterBind9Provider) {
344/// const FINALIZER: &str = "bind9globalcluster.dns.firestoned.io/finalizer";
345/// ensure_cluster_finalizer(&client, &cluster, FINALIZER).await.unwrap();
346/// # }
347/// ```
348pub async fn ensure_cluster_finalizer<T>(
349    client: &Client,
350    resource: &T,
351    finalizer: &str,
352) -> Result<()>
353where
354    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
355        + ResourceExt
356        + Clone
357        + std::fmt::Debug
358        + serde::Serialize
359        + for<'de> serde::Deserialize<'de>,
360{
361    let name = resource.name_any();
362
363    // Check if finalizer is already present
364    if resource
365        .meta()
366        .finalizers
367        .as_ref()
368        .is_none_or(|f| !f.contains(&finalizer.to_string()))
369    {
370        info!(
371            "Adding finalizer {} to {} {}",
372            finalizer,
373            T::kind(&()),
374            name
375        );
376
377        let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default();
378        finalizers.push(finalizer.to_string());
379
380        let api: Api<T> = Api::all(client.clone());
381        let patch = json!({ "metadata": { "finalizers": finalizers } });
382        api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
383            .await?;
384
385        info!(
386            "Successfully added finalizer {} to {} {}",
387            finalizer,
388            T::kind(&()),
389            name
390        );
391    }
392
393    Ok(())
394}
395
396/// Remove a finalizer from a cluster-scoped resource.
397///
398/// This function removes the specified finalizer from the cluster-scoped resource
399/// if present. The operation is idempotent - calling it multiple times has no effect
400/// if the finalizer is already absent.
401///
402/// **Note:** Typically you should use `handle_cluster_deletion()` instead of calling
403/// this function directly, as it performs cleanup before removing the finalizer.
404///
405/// # Arguments
406///
407/// * `client` - Kubernetes client for accessing the API
408/// * `resource` - The cluster-scoped resource to remove the finalizer from
409/// * `finalizer` - The finalizer string to remove
410///
411/// # Returns
412///
413/// Returns `Ok(())` if the finalizer was removed or already absent.
414///
415/// # Errors
416///
417/// Returns an error if the API patch operation fails.
418pub async fn remove_cluster_finalizer<T>(
419    client: &Client,
420    resource: &T,
421    finalizer: &str,
422) -> Result<()>
423where
424    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
425        + ResourceExt
426        + Clone
427        + std::fmt::Debug
428        + serde::Serialize
429        + for<'de> serde::Deserialize<'de>,
430{
431    let name = resource.name_any();
432
433    // Check if finalizer is present
434    if resource
435        .meta()
436        .finalizers
437        .as_ref()
438        .is_some_and(|f| f.contains(&finalizer.to_string()))
439    {
440        info!(
441            "Removing finalizer {} from {} {}",
442            finalizer,
443            T::kind(&()),
444            name
445        );
446
447        let mut finalizers = resource.meta().finalizers.clone().unwrap_or_default();
448        finalizers.retain(|f| f != finalizer);
449
450        let api: Api<T> = Api::all(client.clone());
451        let patch = json!({ "metadata": { "finalizers": finalizers } });
452        api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
453            .await?;
454
455        info!(
456            "Successfully removed finalizer {} from {} {}",
457            finalizer,
458            T::kind(&()),
459            name
460        );
461    }
462
463    Ok(())
464}
465
466/// Handle cluster-scoped resource deletion with cleanup and finalizer removal.
467///
468/// This function orchestrates the complete deletion process for cluster-scoped resources:
469/// 1. Logs that the resource is being deleted
470/// 2. Calls the resource's `cleanup()` method to perform cleanup operations
471/// 3. Removes the finalizer to allow Kubernetes to delete the resource
472///
473/// This function should be called when a cluster-scoped resource has a deletion
474/// timestamp and the finalizer is still present.
475///
476/// # Arguments
477///
478/// * `client` - Kubernetes client for accessing the API
479/// * `resource` - The cluster-scoped resource being deleted
480/// * `finalizer` - The finalizer string to check and remove
481///
482/// # Returns
483///
484/// Returns `Ok(())` if cleanup and finalizer removal succeeded.
485///
486/// # Errors
487///
488/// Returns an error if:
489/// - The cleanup operation fails
490/// - The finalizer removal fails
491///
492/// If an error occurs, the finalizer will remain on the resource and deletion
493/// will be blocked until the operation succeeds on a subsequent reconciliation.
494///
495/// # Example
496///
497/// ```text
498/// use bindy::reconcilers::finalizers::{handle_cluster_deletion, FinalizerCleanup};
499/// use bindy::crd::ClusterBind9Provider;
500/// use kube::Client;
501/// use anyhow::Result;
502///
503/// const FINALIZER: &str = "bind9globalcluster.dns.firestoned.io/finalizer";
504///
505/// async fn reconcile(client: Client, cluster: ClusterBind9Provider) -> Result<()> {
506///     if cluster.metadata.deletion_timestamp.is_some() {
507///         return handle_cluster_deletion(&client, &cluster, FINALIZER).await;
508///     }
509///     // Normal reconciliation...
510///     Ok(())
511/// }
512/// ```
513pub async fn handle_cluster_deletion<T>(
514    client: &Client,
515    resource: &T,
516    finalizer: &str,
517) -> Result<()>
518where
519    T: Resource<DynamicType = (), Scope = ClusterResourceScope>
520        + ResourceExt
521        + FinalizerCleanup
522        + Clone
523        + std::fmt::Debug
524        + serde::Serialize
525        + for<'de> serde::Deserialize<'de>,
526{
527    let name = resource.name_any();
528
529    info!("{} {} is being deleted", T::kind(&()), name);
530
531    // Only proceed if the finalizer is present
532    if resource
533        .meta()
534        .finalizers
535        .as_ref()
536        .is_some_and(|f| f.contains(&finalizer.to_string()))
537    {
538        info!("Running cleanup for {} {}", T::kind(&()), name);
539
540        // Perform cleanup operations
541        resource.cleanup(client).await?;
542
543        // Remove the finalizer
544        remove_cluster_finalizer(client, resource, finalizer).await?;
545    }
546
547    Ok(())
548}
549
550#[cfg(test)]
551#[path = "finalizers_tests.rs"]
552mod finalizers_tests;