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;