bindy/reconcilers/dnszone/
cleanup.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Cleanup operations for DNS zones.
5//!
6//! This module handles cleanup of deleted instances and stale records from zone status.
7
8use anyhow::Result;
9use kube::{Api, Client};
10use tracing::{debug, info, warn};
11
12use super::helpers::HTTP_STATUS_NOT_FOUND;
13use crate::crd::DNSZone;
14
15/// Converts a Kubernetes `get` result into an existence check.
16///
17/// CRITICAL: Only a 404 (`NotFound`) response means the resource is deleted.
18/// Any other error (timeout, 429, 5xx, auth failure, ...) is potentially
19/// transient and MUST be propagated - treating it as "deleted" would trigger
20/// the self-healing cleanup path and delete live DNS data for a resource that
21/// still exists.
22///
23/// # Arguments
24///
25/// * `result` - The result of an `Api::get` call
26///
27/// # Returns
28///
29/// * `Ok(true)` - The resource exists
30/// * `Ok(false)` - The API returned 404: the resource is deleted
31///
32/// # Errors
33///
34/// Returns the original error for any non-404 failure so the caller aborts
35/// this cleanup pass and retries on the next reconciliation.
36pub(super) fn existence_from_get_result<K>(
37    result: std::result::Result<K, kube::Error>,
38) -> Result<bool> {
39    match result {
40        Ok(_) => Ok(true),
41        Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => Ok(false),
42        Err(e) => Err(e.into()),
43    }
44}
45
46/// Checks whether a namespaced resource exists, distinguishing 404 from
47/// transient API errors (see [`existence_from_get_result`]).
48///
49/// # Errors
50///
51/// Returns an error for any non-404 API failure.
52async fn resource_exists<K>(api: &Api<K>, name: &str) -> Result<bool>
53where
54    K: kube::Resource + Clone + std::fmt::Debug + serde::de::DeserializeOwned,
55{
56    existence_from_get_result(api.get(name).await)
57}
58
59/// Clean up deleted instances from zone status.
60///
61/// Iterates through instances in zone status and removes any that no longer exist
62/// in the Kubernetes API.
63///
64/// # Arguments
65///
66/// * `client` - Kubernetes client
67/// * `dnszone` - The DNSZone resource being reconciled
68/// * `status_updater` - Status updater for modifying zone status
69///
70/// # Returns
71///
72/// Number of instances removed from status
73///
74/// # Errors
75///
76/// Returns an error if Kubernetes API calls fail critically.
77pub async fn cleanup_deleted_instances(
78    client: &Client,
79    dnszone: &DNSZone,
80    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
81) -> Result<usize> {
82    use crate::crd::Bind9Instance;
83    use kube::{Api, ResourceExt};
84
85    let namespace = dnszone.namespace().unwrap_or_default();
86    let zone_name = &dnszone.spec.zone_name;
87
88    // Get current instances from status
89    let current_instances = dnszone
90        .status
91        .as_ref()
92        .map(|s| s.bind9_instances.clone())
93        .unwrap_or_default();
94
95    if current_instances.is_empty() {
96        debug!(
97            "No instances in status for zone {}/{} - skipping cleanup",
98            namespace, zone_name
99        );
100        return Ok(0);
101    }
102
103    info!(
104        "Cleaning up deleted instances for zone {}/{}: checking {} instance(s)",
105        namespace,
106        zone_name,
107        current_instances.len()
108    );
109
110    let mut deleted_count = 0;
111
112    // Check each instance to see if it still exists.
113    // Only a 404 means "deleted": transient API errors abort this cleanup
114    // pass (via `?`) so a live instance is never removed from status by mistake.
115    for instance_ref in current_instances {
116        let instance_api: Api<Bind9Instance> =
117            Api::namespaced(client.clone(), &instance_ref.namespace);
118
119        let instance_exists = resource_exists(&instance_api, &instance_ref.name).await?;
120
121        if !instance_exists {
122            info!(
123                "Instance {}/{} no longer exists - removing from zone {}/{}",
124                instance_ref.namespace, instance_ref.name, namespace, zone_name
125            );
126            status_updater.remove_instance(&instance_ref.name, &instance_ref.namespace);
127            deleted_count += 1;
128        }
129    }
130
131    Ok(deleted_count)
132}
133
134/// Clean up stale records from zone status.
135///
136/// Iterates through records in zone status and removes any that no longer exist
137/// in the Kubernetes API. Also performs self-healing by deleting orphaned records
138/// from BIND9 if they were missed by finalizers.
139///
140/// # Arguments
141///
142/// * `client` - Kubernetes client
143/// * `dnszone` - The DNSZone resource being reconciled
144/// * `status_updater` - Status updater for modifying zone status
145/// * `bind9_instances_store` - Reflector store for querying Bind9Instance resources
146///
147/// # Returns
148///
149/// Number of records removed from status
150///
151/// # Errors
152///
153/// Returns an error if API calls fail critically (non-NotFound errors).
154#[allow(clippy::too_many_lines)]
155pub async fn cleanup_stale_records(
156    client: &Client,
157    dnszone: &DNSZone,
158    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
159    bind9_instances_store: &kube::runtime::reflector::Store<crate::crd::Bind9Instance>,
160) -> Result<usize> {
161    use crate::bind9::records::query_dns_record;
162    use crate::crd::{
163        AAAARecord, ARecord, CAARecord, CNAMERecord, DNSRecordKind, MXRecord, NSRecord,
164        RecordReferenceWithTimestamp, SRVRecord, TXTRecord,
165    };
166    use kube::{Api, ResourceExt};
167
168    let namespace = dnszone.namespace().unwrap_or_default();
169    let zone_name = &dnszone.spec.zone_name;
170
171    // Get current records from status
172    let current_records = dnszone
173        .status
174        .as_ref()
175        .map(|s| s.records.clone())
176        .unwrap_or_default();
177
178    if current_records.is_empty() {
179        debug!(
180            "No records in status for zone {}/{} - skipping cleanup",
181            namespace, zone_name
182        );
183        return Ok(0);
184    }
185
186    info!(
187        "Cleaning up stale records for zone {}/{}: checking {} record(s)",
188        namespace,
189        zone_name,
190        current_records.len()
191    );
192
193    // Get instances to query DNS and delete if needed
194    let instance_refs = super::validation::get_instances_from_zone(dnszone, bind9_instances_store)?;
195    let primary_refs = super::primary::filter_primary_instances(client, &instance_refs).await?;
196
197    let mut records_to_keep: Vec<RecordReferenceWithTimestamp> = Vec::new();
198    let mut stale_count = 0;
199
200    // Check each record to see if it still exists.
201    // Only a 404 means "deleted": transient API errors abort this cleanup pass
202    // (via `?`). Treating a transient error on a live record as "deleted"
203    // would trigger the SELF-HEALING path below and delete live DNS data from
204    // all primaries while the record CR still exists.
205    for record_ref in current_records {
206        let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
207        let record_exists = match kind {
208            DNSRecordKind::A => {
209                let api: Api<ARecord> = Api::namespaced(client.clone(), &record_ref.namespace);
210                resource_exists(&api, &record_ref.name).await?
211            }
212            DNSRecordKind::AAAA => {
213                let api: Api<AAAARecord> = Api::namespaced(client.clone(), &record_ref.namespace);
214                resource_exists(&api, &record_ref.name).await?
215            }
216            DNSRecordKind::TXT => {
217                let api: Api<TXTRecord> = Api::namespaced(client.clone(), &record_ref.namespace);
218                resource_exists(&api, &record_ref.name).await?
219            }
220            DNSRecordKind::CNAME => {
221                let api: Api<CNAMERecord> = Api::namespaced(client.clone(), &record_ref.namespace);
222                resource_exists(&api, &record_ref.name).await?
223            }
224            DNSRecordKind::MX => {
225                let api: Api<MXRecord> = Api::namespaced(client.clone(), &record_ref.namespace);
226                resource_exists(&api, &record_ref.name).await?
227            }
228            DNSRecordKind::NS => {
229                let api: Api<NSRecord> = Api::namespaced(client.clone(), &record_ref.namespace);
230                resource_exists(&api, &record_ref.name).await?
231            }
232            DNSRecordKind::SRV => {
233                let api: Api<SRVRecord> = Api::namespaced(client.clone(), &record_ref.namespace);
234                resource_exists(&api, &record_ref.name).await?
235            }
236            DNSRecordKind::CAA => {
237                let api: Api<CAARecord> = Api::namespaced(client.clone(), &record_ref.namespace);
238                resource_exists(&api, &record_ref.name).await?
239            }
240        };
241
242        if record_exists {
243            // Record still exists in Kubernetes - keep it in status
244            // The record reconciler will handle updating BIND9
245            debug!(
246                "Record {} {}/{} still exists - keeping in status",
247                record_ref.kind, record_ref.namespace, record_ref.name
248            );
249            records_to_keep.push(record_ref);
250        } else {
251            // Record doesn't exist in Kubernetes - need to clean up
252            info!(
253                "Record {} {}/{} no longer exists in Kubernetes",
254                record_ref.kind, record_ref.namespace, record_ref.name
255            );
256
257            // Self-healing: Check if record still exists in BIND9 and delete if found
258            // This catches cases where the finalizer failed to delete
259            let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
260            let record_type = kind.to_hickory_record_type();
261
262            // Extract DNS record name and zone from RecordReference
263            // These fields are populated from spec.name when the record is discovered
264            let dns_record_name = if let Some(name) = &record_ref.record_name {
265                name.as_str()
266            } else {
267                warn!(
268                    "Record {} {}/{} has no recordName in status - skipping BIND9 cleanup",
269                    record_ref.kind, record_ref.namespace, record_ref.name
270                );
271                stale_count += 1;
272                continue;
273            };
274
275            // Check BIND9 on all primary instances and delete if found
276            // Use for_each_instance_endpoint to iterate over all primary endpoints
277            let dns_record_name_clone = dns_record_name.to_string();
278            let dns_zone_name_clone = zone_name.clone();
279            let record_kind = record_ref.kind.clone();
280            let record_namespace = record_ref.namespace.clone();
281            let record_name = record_ref.name.clone();
282
283            // Query and potentially delete from each primary instance
284            let _ = super::helpers::for_each_instance_endpoint(
285                client,
286                &primary_refs,
287                true,      // with_rndc_key (needed for deletion)
288                "dns-tcp", // Use DNS TCP port for queries and updates
289                |pod_endpoint, _instance_name, rndc_key| {
290                    let server = pod_endpoint.clone();
291                    let zone = dns_zone_name_clone.clone();
292                    let dns_name = dns_record_name_clone.clone();
293                    let r_type = record_type;
294                    let r_kind = record_kind.clone();
295                    let r_namespace = record_namespace.clone();
296                    let r_name = record_name.clone();
297
298                    async move {
299                        // Query DNS to check if record exists
300                        match query_dns_record(&zone, &dns_name, r_type, &server).await {
301                            Ok(records) if !records.is_empty() => {
302                                warn!(
303                                    "SELF-HEALING: Record {} {}/{} deleted from K8s but still exists in BIND9 on {}",
304                                    r_kind, r_namespace, r_name, server
305                                );
306
307                                // Delete from BIND9 using the RNDC key
308                                if let Some(key_data) = rndc_key {
309                                    match crate::bind9::records::delete_dns_record(
310                                        &zone,
311                                        &dns_name,
312                                        r_type,
313                                        &server,
314                                        &key_data,
315                                    )
316                                    .await
317                                    {
318                                        Ok(()) => {
319                                            info!(
320                                                "SELF-HEALING: Successfully deleted orphaned {} record {} from BIND9 on {}",
321                                                r_kind, dns_name, server
322                                            );
323                                        }
324                                        Err(e) => {
325                                            warn!(
326                                                "SELF-HEALING: Failed to delete orphaned record from BIND9 on {}: {}",
327                                                server, e
328                                            );
329                                        }
330                                    }
331                                } else {
332                                    warn!(
333                                        "No RNDC key available for {} - cannot delete orphaned record",
334                                        server
335                                    );
336                                }
337                            }
338                            Ok(_) => {
339                                // Record doesn't exist in BIND9 - good, finalizer worked
340                                debug!(
341                                    "Record {} not found in BIND9 on {} - already cleaned up",
342                                    dns_name, server
343                                );
344                            }
345                            Err(e) => {
346                                debug!(
347                                    "Failed to query DNS on {} for {} (may not exist): {}",
348                                    server, dns_name, e
349                                );
350                            }
351                        }
352
353                        Ok(())
354                    }
355                },
356            )
357            .await;
358
359            // Remove from status regardless of whether we found it in BIND9
360            stale_count += 1;
361        }
362    }
363
364    // Update status with cleaned records list
365    if stale_count > 0 {
366        status_updater.set_records(&records_to_keep);
367        info!(
368            "Removed {} stale record(s) from zone {}/{} status",
369            stale_count, namespace, zone_name
370        );
371    }
372
373    Ok(stale_count)
374}
375
376#[cfg(test)]
377#[path = "cleanup_tests.rs"]
378mod cleanup_tests;