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: &crate::context::MultiStore<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, PTRRecord,
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            DNSRecordKind::PTR => {
241                let api: Api<PTRRecord> = Api::namespaced(client.clone(), &record_ref.namespace);
242                resource_exists(&api, &record_ref.name).await?
243            }
244        };
245
246        if record_exists {
247            // Record still exists in Kubernetes - keep it in status
248            // The record reconciler will handle updating BIND9
249            debug!(
250                "Record {} {}/{} still exists - keeping in status",
251                record_ref.kind, record_ref.namespace, record_ref.name
252            );
253            records_to_keep.push(record_ref);
254        } else {
255            // Record doesn't exist in Kubernetes - need to clean up
256            info!(
257                "Record {} {}/{} no longer exists in Kubernetes",
258                record_ref.kind, record_ref.namespace, record_ref.name
259            );
260
261            // Self-healing: Check if record still exists in BIND9 and delete if found
262            // This catches cases where the finalizer failed to delete
263            let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
264            let record_type = kind.to_hickory_record_type();
265
266            // Extract DNS record name and zone from RecordReference
267            // These fields are populated from spec.name when the record is discovered
268            let dns_record_name = if let Some(name) = &record_ref.record_name {
269                name.as_str()
270            } else {
271                warn!(
272                    "Record {} {}/{} has no recordName in status - skipping BIND9 cleanup",
273                    record_ref.kind, record_ref.namespace, record_ref.name
274                );
275                stale_count += 1;
276                continue;
277            };
278
279            // Check BIND9 on all primary instances and delete if found
280            // Use for_each_instance_endpoint to iterate over all primary endpoints
281            let dns_record_name_clone = dns_record_name.to_string();
282            let dns_zone_name_clone = zone_name.clone();
283            let record_kind = record_ref.kind.clone();
284            let record_namespace = record_ref.namespace.clone();
285            let record_name = record_ref.name.clone();
286
287            // Query and potentially delete from each primary instance
288            let _ = super::helpers::for_each_instance_endpoint(
289                client,
290                &primary_refs,
291                true,      // with_rndc_key (needed for deletion)
292                "dns-tcp", // Use DNS TCP port for queries and updates
293                |pod_endpoint, _instance_name, rndc_key| {
294                    let server = pod_endpoint.clone();
295                    let zone = dns_zone_name_clone.clone();
296                    let dns_name = dns_record_name_clone.clone();
297                    let r_type = record_type;
298                    let r_kind = record_kind.clone();
299                    let r_namespace = record_namespace.clone();
300                    let r_name = record_name.clone();
301
302                    async move {
303                        // Query DNS to check if record exists
304                        match query_dns_record(&zone, &dns_name, r_type, &server).await {
305                            Ok(records) if !records.is_empty() => {
306                                warn!(
307                                    "SELF-HEALING: Record {} {}/{} deleted from K8s but still exists in BIND9 on {}",
308                                    r_kind, r_namespace, r_name, server
309                                );
310
311                                // Delete from BIND9 using the RNDC key
312                                if let Some(key_data) = rndc_key {
313                                    match crate::bind9::records::delete_dns_record(
314                                        &zone,
315                                        &dns_name,
316                                        r_type,
317                                        &server,
318                                        &key_data,
319                                    )
320                                    .await
321                                    {
322                                        Ok(()) => {
323                                            info!(
324                                                "SELF-HEALING: Successfully deleted orphaned {} record {} from BIND9 on {}",
325                                                r_kind, dns_name, server
326                                            );
327                                        }
328                                        Err(e) => {
329                                            warn!(
330                                                "SELF-HEALING: Failed to delete orphaned record from BIND9 on {}: {}",
331                                                server, e
332                                            );
333                                        }
334                                    }
335                                } else {
336                                    warn!(
337                                        "No RNDC key available for {} - cannot delete orphaned record",
338                                        server
339                                    );
340                                }
341                            }
342                            Ok(_) => {
343                                // Record doesn't exist in BIND9 - good, finalizer worked
344                                debug!(
345                                    "Record {} not found in BIND9 on {} - already cleaned up",
346                                    dns_name, server
347                                );
348                            }
349                            Err(e) => {
350                                debug!(
351                                    "Failed to query DNS on {} for {} (may not exist): {}",
352                                    server, dns_name, e
353                                );
354                            }
355                        }
356
357                        Ok(())
358                    }
359                },
360            )
361            .await;
362
363            // Remove from status regardless of whether we found it in BIND9
364            stale_count += 1;
365        }
366    }
367
368    // Update status with cleaned records list
369    if stale_count > 0 {
370        status_updater.set_records(&records_to_keep);
371        info!(
372            "Removed {} stale record(s) from zone {}/{} status",
373            stale_count, namespace, zone_name
374        );
375    }
376
377    Ok(stale_count)
378}
379
380#[cfg(test)]
381#[path = "cleanup_tests.rs"]
382mod cleanup_tests;