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;