bindy/reconcilers/dnszone/
discovery.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Record discovery logic for DNS zones.
5//!
6//! This module handles discovering DNS record resources that match zone label selectors,
7//! tagging/untagging records, and checking record readiness.
8
9#![allow(unused_imports)] // Some imports used in macro-generated code
10
11use anyhow::{Context as AnyhowContext, Result};
12use kube::{
13    api::{ListParams, Patch, PatchParams},
14    Api, Client, ResourceExt,
15};
16use serde_json::json;
17use std::collections::HashSet;
18use tracing::{debug, info, warn};
19
20use crate::crd::DNSZone;
21use crate::reconcilers::pagination::list_all_paginated;
22
23/// Reconciles DNS records for a zone by discovering records that match the zone's label selectors.
24///
25/// **Event-Driven Architecture**: This function implements the core of the zone/record ownership model:
26/// 1. Discovers records matching the zone's `recordsFrom` label selectors
27/// 2. Tags matched records by setting `status.zoneRef` (triggers record reconciliation via watches)
28/// 3. Untags previously matched records by clearing `status.zoneRef` (stops record reconciliation)
29/// 4. Returns references to currently matched records for `DNSZone.status.records` tracking
30///
31/// Record reconcilers watch `status.zoneRef` to determine which zone they belong to.
32/// When `status.zoneRef` is set, the record is reconciled to BIND9.
33/// When `status.zoneRef` is cleared, the record reconciler marks it as `"NotSelected"`.
34///
35/// Before a record is untagged, its data is deleted from the zone's primary
36/// BIND9 instances. If that DNS deletion fails, the record is kept selected
37/// (and in `status.records`) so the cleanup is retried on the next
38/// reconciliation instead of orphaning the data in BIND9.
39///
40/// # Arguments
41///
42/// * `client` - Kubernetes API client for querying DNS records
43/// * `dnszone` - The `DNSZone` resource with label selectors
44/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s
45///
46/// # Returns
47///
48/// * `Ok(Vec<RecordReference>)` - List of currently matched DNS records
49/// * `Err(_)` - If record discovery or tagging fails
50///
51/// # Errors
52///
53/// Returns an error if Kubernetes API operations fail.
54#[allow(clippy::too_many_lines)]
55pub async fn reconcile_zone_records(
56    client: Client,
57    dnszone: DNSZone,
58    stores: &crate::context::Stores,
59) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
60    let namespace = dnszone.namespace().unwrap_or_default();
61    let spec = &dnszone.spec;
62    let zone_name = &spec.zone_name;
63
64    // Early return if no label selectors are defined
65    let Some(ref records_from) = spec.records_from else {
66        info!(
67            "No label selectors defined for zone {}, skipping record discovery",
68            zone_name
69        );
70        // If no selectors, untag ALL previously matched records
71        return Ok(Vec::new());
72    };
73
74    info!(
75        "Discovering DNS records for zone {} using {} label selector(s)",
76        zone_name,
77        records_from.len()
78    );
79
80    let mut all_record_refs = Vec::new();
81
82    // Query all record types and filter by label selectors
83    for record_source in records_from {
84        let selector = &record_source.selector;
85
86        // Discover each record type
87        all_record_refs.extend(discover_a_records(&client, &namespace, selector, zone_name).await?);
88        all_record_refs
89            .extend(discover_aaaa_records(&client, &namespace, selector, zone_name).await?);
90        all_record_refs
91            .extend(discover_txt_records(&client, &namespace, selector, zone_name).await?);
92        all_record_refs
93            .extend(discover_cname_records(&client, &namespace, selector, zone_name).await?);
94        all_record_refs
95            .extend(discover_mx_records(&client, &namespace, selector, zone_name).await?);
96        all_record_refs
97            .extend(discover_ns_records(&client, &namespace, selector, zone_name).await?);
98        all_record_refs
99            .extend(discover_srv_records(&client, &namespace, selector, zone_name).await?);
100        all_record_refs
101            .extend(discover_caa_records(&client, &namespace, selector, zone_name).await?);
102        all_record_refs
103            .extend(discover_ptr_records(&client, &namespace, selector, zone_name).await?);
104    }
105
106    info!(
107        "Discovered {} DNS record(s) for zone {}",
108        all_record_refs.len(),
109        zone_name
110    );
111
112    // Get previously matched records from current status
113    let previous_records: HashSet<String> = dnszone
114        .status
115        .as_ref()
116        .map(|s| {
117            s.records
118                .iter()
119                .map(|r| format!("{}/{}", r.kind, r.name))
120                .collect()
121        })
122        .unwrap_or_default();
123
124    // Create set of currently matched records
125    let current_records: HashSet<String> = all_record_refs
126        .iter()
127        .map(|r| format!("{}/{}", r.kind, r.name))
128        .collect();
129
130    // Tag all matched records to ensure status.zoneRef is set
131    // Previously we only tagged "newly matched" records, but records can exist in
132    // status.records without having status.zoneRef set (e.g., from a previous
133    // implementation or migration). Always tag to ensure consistency.
134    for record_ref in &all_record_refs {
135        let record_key = format!("{}/{}", record_ref.kind, record_ref.name);
136        let is_new = !previous_records.contains(&record_key);
137
138        if is_new {
139            info!(
140                "Newly matched record: {} {}/{}",
141                record_ref.kind, namespace, record_ref.name
142            );
143        } else {
144            debug!(
145                "Re-tagging existing record to ensure status.zoneRef: {} {}/{}",
146                record_ref.kind, namespace, record_ref.name
147            );
148        }
149
150        tag_record_with_zone(
151            &client,
152            &namespace,
153            &record_ref.kind,
154            &record_ref.name,
155            zone_name,
156            &dnszone,
157        )
158        .await?;
159    }
160
161    // Untag previously matched records that no longer match or were deleted
162    // (in previous but not in current). Before untagging, delete the record's
163    // data from this zone's primary BIND9 instances - otherwise the data
164    // would stay live in BIND9 forever (the record reconciler only marks
165    // unselected records as NotSelected, it does not delete them).
166    let previous_refs: Vec<crate::crd::RecordReferenceWithTimestamp> = dnszone
167        .status
168        .as_ref()
169        .map(|s| s.records.clone())
170        .unwrap_or_default();
171
172    // Lazily resolved (and cached) primary instances for this zone
173    let mut primary_refs_cache: Option<Vec<crate::crd::InstanceReference>> = None;
174
175    for record_ref in unselected_previous_records(&previous_refs, &current_records) {
176        let kind = record_ref.kind.as_str();
177        let name = record_ref.name.as_str();
178
179        warn!(
180            "Record no longer matches zone {} (unmatched or deleted): {} {}/{}",
181            zone_name, kind, namespace, name
182        );
183
184        // Delete the record data from BIND9 before untagging
185        if let Err(e) = cleanup_unselected_record_dns(
186            &client,
187            stores,
188            &dnszone,
189            &namespace,
190            &record_ref,
191            &mut primary_refs_cache,
192        )
193        .await
194        {
195            // Do NOT untag: keep the record selected (zoneRef intact and
196            // present in status.records) so the DNS deletion is retried on
197            // the next reconciliation instead of orphaning data in BIND9.
198            warn!(
199                "Failed to delete DNS data for unselected record {} {}/{} from zone {}: {}. \
200                 Keeping record selected for retry.",
201                kind, namespace, name, zone_name, e
202            );
203            all_record_refs.push(record_ref.clone());
204            continue;
205        }
206
207        // Try to untag the record, but don't fail if it was deleted
208        // If the record was deleted, the API will return NotFound, which is fine
209        if let Err(e) = untag_record_from_zone(&client, &namespace, kind, name, zone_name).await {
210            // Check if error is because record was deleted (NotFound)
211            if e.to_string().contains("NotFound") || e.to_string().contains("not found") {
212                info!(
213                    "Record {} {}/{} was deleted, removing from zone {} status",
214                    kind, namespace, name, zone_name
215                );
216            } else {
217                // Other errors should be logged but not fail the reconciliation
218                warn!(
219                    "Failed to untag record {} {}/{} from zone {}: {}",
220                    kind, namespace, name, zone_name, e
221                );
222            }
223        }
224        // Continue regardless - the record will be removed from status.records
225        // when we return all_record_refs (which doesn't include this record)
226    }
227
228    // CRITICAL: Preserve existing timestamps for records that haven't changed
229    // This prevents status updates from triggering unnecessary reconciliation loops
230    if let Some(status) = &dnszone.status {
231        let existing_timestamps: std::collections::HashMap<String, _> = status
232            .records
233            .iter()
234            .filter_map(|r| {
235                r.last_reconciled_at
236                    .as_ref()
237                    .map(|timestamp| (format!("{}/{}", r.kind, r.name), timestamp.clone()))
238            })
239            .collect();
240
241        // Update timestamps for records that already existed
242        for record_ref in &mut all_record_refs {
243            let key = format!("{}/{}", record_ref.kind, record_ref.name);
244            if let Some(existing_timestamp) = existing_timestamps.get(&key) {
245                record_ref.last_reconciled_at = Some(existing_timestamp.clone());
246            }
247        }
248    }
249
250    Ok(all_record_refs)
251}
252
253/// HTTP status code returned by the Kubernetes API when a resource does not exist.
254const HTTP_STATUS_NOT_FOUND: u16 = 404;
255
256/// Returns the previously matched record references that are no longer selected.
257///
258/// # Arguments
259///
260/// * `previous` - Record references from the zone's current `status.records[]`
261/// * `current_keys` - Set of `"Kind/name"` keys for currently matched records
262fn unselected_previous_records(
263    previous: &[crate::crd::RecordReferenceWithTimestamp],
264    current_keys: &HashSet<String>,
265) -> Vec<crate::crd::RecordReferenceWithTimestamp> {
266    previous
267        .iter()
268        .filter(|r| !current_keys.contains(&format!("{}/{}", r.kind, r.name)))
269        .cloned()
270        .collect()
271}
272
273/// Maps a Kubernetes record kind (e.g., `"ARecord"`) to its hickory `RecordType`.
274///
275/// # Errors
276///
277/// Returns an error if the kind is not a known DNS record kind.
278fn hickory_record_type_for_kind(kind: &str) -> Result<hickory_proto::rr::RecordType> {
279    use crate::crd::DNSRecordKind;
280    use hickory_proto::rr::RecordType;
281
282    let record_kind = DNSRecordKind::try_from(kind)
283        .map_err(|e| anyhow::anyhow!("Unknown DNS record kind '{kind}': {e}"))?;
284
285    Ok(match record_kind {
286        DNSRecordKind::A => RecordType::A,
287        DNSRecordKind::AAAA => RecordType::AAAA,
288        DNSRecordKind::TXT => RecordType::TXT,
289        DNSRecordKind::CNAME => RecordType::CNAME,
290        DNSRecordKind::MX => RecordType::MX,
291        DNSRecordKind::NS => RecordType::NS,
292        DNSRecordKind::SRV => RecordType::SRV,
293        DNSRecordKind::CAA => RecordType::CAA,
294        DNSRecordKind::PTR => RecordType::PTR,
295    })
296}
297
298/// Builds a dynamic API client for a DNS record kind in the given namespace.
299fn dynamic_record_api(
300    client: &Client,
301    namespace: &str,
302    kind: &str,
303) -> kube::api::Api<kube::api::DynamicObject> {
304    // Convert kind to plural resource name (e.g., "ARecord" -> "arecords")
305    let plural = format!("{}s", kind.to_lowercase());
306
307    let gvk = kube::core::GroupVersionKind {
308        group: "bindy.firestoned.io".to_string(),
309        version: "v1beta1".to_string(),
310        kind: kind.to_string(),
311    };
312
313    let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural);
314
315    kube::api::Api::<kube::api::DynamicObject>::namespaced_with(
316        client.clone(),
317        namespace,
318        &api_resource,
319    )
320}
321
322/// Deletes the DNS data of a record that is no longer selected by a zone.
323///
324/// Called by `reconcile_zone_records()` before untagging a record. Without this,
325/// unselecting a record (label/selector change) would leave its data live in
326/// BIND9 forever, since the record reconciler only marks unselected records
327/// as `NotSelected`.
328///
329/// The deletion is skipped (returns `Ok`) when:
330/// - The record resource no longer exists (its finalizer handles DNS cleanup)
331/// - The record has no `status.zoneRef` (it was never published to DNS)
332/// - The record's `status.zoneRef` points at a different zone (not ours to delete)
333/// - The zone has no instances or no primary instances (nowhere to delete from)
334///
335/// # Arguments
336///
337/// * `client` - Kubernetes API client
338/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s
339/// * `dnszone` - The zone that previously selected this record
340/// * `namespace` - Namespace of the record
341/// * `record_ref` - Reference to the unselected record
342/// * `primary_refs_cache` - Cache of the zone's primary instances (resolved once)
343///
344/// # Errors
345///
346/// Returns an error if the record cannot be fetched (other than `NotFound`),
347/// primary instances cannot be resolved, or the DNS deletion fails. Callers
348/// must NOT untag the record in that case, so the cleanup is retried.
349async fn cleanup_unselected_record_dns(
350    client: &Client,
351    stores: &crate::context::Stores,
352    dnszone: &DNSZone,
353    namespace: &str,
354    record_ref: &crate::crd::RecordReferenceWithTimestamp,
355    primary_refs_cache: &mut Option<Vec<crate::crd::InstanceReference>>,
356) -> Result<()> {
357    let kind = record_ref.kind.as_str();
358    let name = record_ref.name.as_str();
359
360    // Fetch the record to read its published DNS name and zone ownership
361    let api = dynamic_record_api(client, namespace, kind);
362    let record = match api.get(name).await {
363        Ok(record) => record,
364        Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => {
365            // Record resource was deleted - its finalizer handles DNS cleanup
366            debug!(
367                "Record {} {}/{} no longer exists, skipping DNS cleanup",
368                kind, namespace, name
369            );
370            return Ok(());
371        }
372        Err(e) => {
373            return Err(anyhow::Error::from(e)
374                .context(format!("Failed to fetch {kind} {namespace}/{name}")));
375        }
376    };
377
378    let record_json = serde_json::to_value(&record)?;
379    let status = record_json.get("status");
380
381    // No zoneRef means the record was never published to DNS
382    let Some(zone_ref) = status
383        .and_then(|s| s.get("zoneRef"))
384        .filter(|z| !z.is_null())
385    else {
386        debug!(
387            "Record {} {}/{} has no status.zoneRef, skipping DNS cleanup",
388            kind, namespace, name
389        );
390        return Ok(());
391    };
392
393    // Only delete data for records this zone actually owns
394    let owned_by_this_zone = zone_ref.get("name").and_then(|v| v.as_str())
395        == Some(dnszone.name_any().as_str())
396        && zone_ref.get("namespace").and_then(|v| v.as_str())
397            == Some(dnszone.namespace().unwrap_or_default().as_str());
398    if !owned_by_this_zone {
399        debug!(
400            "Record {} {}/{} is owned by a different zone, skipping DNS cleanup",
401            kind, namespace, name
402        );
403        return Ok(());
404    }
405
406    // Resolve this zone's primary instances once and cache across records
407    if primary_refs_cache.is_none() {
408        let Ok(instance_refs) = crate::reconcilers::dnszone::validation::get_instances_from_zone(
409            dnszone,
410            &stores.bind9_instances,
411        ) else {
412            // Zone has no instances - there is nowhere the data could live
413            debug!(
414                "Zone {} has no instances, skipping DNS cleanup for {} {}/{}",
415                dnszone.spec.zone_name, kind, namespace, name
416            );
417            return Ok(());
418        };
419
420        let primaries =
421            crate::reconcilers::dnszone::primary::filter_primary_instances(client, &instance_refs)
422                .await?;
423        *primary_refs_cache = Some(primaries);
424    }
425
426    let primary_refs = primary_refs_cache.as_deref().unwrap_or_default();
427    if primary_refs.is_empty() {
428        debug!(
429            "Zone {} has no primary instances, skipping DNS cleanup for {} {}/{}",
430            dnszone.spec.zone_name, kind, namespace, name
431        );
432        return Ok(());
433    }
434
435    // The DNS name actually published (handles renames), falling back to spec.name
436    let record_name = status
437        .and_then(|s| s.get("publishedName"))
438        .and_then(|p| p.as_str())
439        .or_else(|| {
440            record_json
441                .get("spec")
442                .and_then(|s| s.get("name"))
443                .and_then(|n| n.as_str())
444        })
445        .unwrap_or(name);
446
447    let record_type_hickory = hickory_record_type_for_kind(kind)?;
448
449    crate::reconcilers::records::delete_record_from_primaries(
450        client,
451        stores,
452        primary_refs,
453        &dnszone.spec.zone_name,
454        record_name,
455        record_type_hickory,
456        true, // fail_on_error: do not untag until the data is really gone
457    )
458    .await?;
459
460    info!(
461        "Deleted DNS data for unselected record {} {}/{} ('{}') from zone {}",
462        kind, namespace, name, record_name, dnszone.spec.zone_name
463    );
464
465    Ok(())
466}
467
468/// Tags a DNS record with zone ownership by setting `status.zoneRef`.
469///
470/// **Event-Driven Architecture**: This function is called when a `DNSZone`'s label selector
471/// matches a record. It sets `status.zoneRef` with a structured reference to the zone,
472/// which triggers the record operator via Kubernetes watch to reconcile the record to BIND9.
473///
474/// # Arguments
475///
476/// * `client` - Kubernetes API client
477/// * `namespace` - Namespace of the record
478/// * `kind` - Record kind (e.g., `ARecord`, `CNAMERecord`)
479/// * `name` - Record name
480/// * `zone_fqdn` - Fully qualified domain name of the zone (e.g., `"example.com"`)
481///
482/// # Returns
483///
484/// * `Ok(())` - If the record was tagged successfully
485/// * `Err(_)` - If tagging failed
486async fn tag_record_with_zone(
487    client: &Client,
488    namespace: &str,
489    kind: &str,
490    name: &str,
491    zone_fqdn: &str,
492    dnszone: &DNSZone,
493) -> Result<()> {
494    debug!(
495        "Tagging {} {}/{} with zone {}",
496        kind, namespace, name, zone_fqdn
497    );
498
499    // Create a dynamic API client
500    let api = dynamic_record_api(client, namespace, kind);
501
502    // Create ZoneReference for status.zoneRef (event-driven architecture)
503    let zone_ref = crate::crd::ZoneReference {
504        api_version: crate::constants::API_GROUP_VERSION.to_string(),
505        kind: crate::constants::KIND_DNS_ZONE.to_string(),
506        name: dnszone.name_any(),
507        namespace: dnszone.namespace().unwrap_or_default(),
508        zone_name: zone_fqdn.to_string(),
509        last_reconciled_at: None, // Not used in DNSZone status
510    };
511
512    // Patch status to set zone field (backward compatibility) AND zoneRef (new event-driven field)
513    let status_patch = json!({
514        "status": {
515            "zone": zone_fqdn,
516            "zoneRef": zone_ref
517        }
518    });
519
520    api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
521        .await
522        .with_context(|| {
523            format!("Failed to set status.zone and status.zoneRef on {kind} {namespace}/{name}")
524        })?;
525
526    info!(
527        "Successfully tagged {} {}/{} with zone {} (set status.zoneRef)",
528        kind, namespace, name, zone_fqdn
529    );
530
531    Ok(())
532}
533
534/// Untags a DNS record that no longer matches a zone's selector.
535///
536/// This function clears the `status.zoneRef` field (event-driven architecture)
537/// and the deprecated `status.zone` field for backward compatibility.
538///
539/// **Event-Driven Architecture**: Records use `status.zoneRef` (not annotations) to track
540/// which zone they belong to. When a record no longer matches a zone's selector, this
541/// function clears the status fields so the record reconciler knows it's no longer selected.
542///
543/// # Arguments
544///
545/// * `client` - Kubernetes API client
546/// * `namespace` - Namespace of the record
547/// * `kind` - Record kind (e.g., `ARecord`, `CNAMERecord`)
548/// * `name` - Record name
549/// * `previous_zone_fqdn` - FQDN of the zone that previously owned this record
550///
551/// # Returns
552///
553/// * `Ok(())` - If the record was untagged successfully
554/// * `Err(_)` - If untagging failed
555async fn untag_record_from_zone(
556    client: &Client,
557    namespace: &str,
558    kind: &str,
559    name: &str,
560    previous_zone_fqdn: &str,
561) -> Result<()> {
562    debug!(
563        "Untagging {} {}/{} from zone {} (clearing status.zoneRef)",
564        kind, namespace, name, previous_zone_fqdn
565    );
566
567    // Create a dynamic API client
568    let api = dynamic_record_api(client, namespace, kind);
569
570    // Patch status to remove zoneRef (event-driven architecture uses status.zoneRef, not annotations)
571    let status_patch = json!({
572        "status": {
573            "zoneRef": null,
574            "zone": null  // Also clear deprecated zone field for backward compatibility
575        }
576    });
577
578    api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
579        .await
580        .with_context(|| format!("Failed to clear status.zoneRef on {kind} {namespace}/{name}"))?;
581
582    info!(
583        "Successfully untagged {} {}/{} from zone {} (cleared status.zoneRef)",
584        kind, namespace, name, previous_zone_fqdn
585    );
586
587    Ok(())
588}
589
590/// Trait for DNS record types that can be discovered by DNSZone controllers.
591///
592/// This trait provides the minimal interface needed for the generic record discovery
593/// function to work across all DNS record types.
594trait DiscoverableRecord:
595    kube::Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
596    + Clone
597    + std::fmt::Debug
598    + serde::de::DeserializeOwned
599    + kube::ResourceExt
600{
601    /// Get the DNS record kind enum variant for this record type.
602    fn dns_record_kind() -> crate::crd::DNSRecordKind;
603
604    /// Get the record name from the spec (e.g., "www", "mail", "@").
605    fn spec_name(&self) -> &str;
606
607    /// Get the record status.
608    fn record_status(&self) -> Option<&crate::crd::RecordStatus>;
609}
610
611// Implementations of DiscoverableRecord for all DNS record types
612
613impl DiscoverableRecord for crate::crd::ARecord {
614    fn dns_record_kind() -> crate::crd::DNSRecordKind {
615        crate::crd::DNSRecordKind::A
616    }
617
618    fn spec_name(&self) -> &str {
619        &self.spec.name
620    }
621
622    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
623        self.status.as_ref()
624    }
625}
626
627impl DiscoverableRecord for crate::crd::AAAARecord {
628    fn dns_record_kind() -> crate::crd::DNSRecordKind {
629        crate::crd::DNSRecordKind::AAAA
630    }
631
632    fn spec_name(&self) -> &str {
633        &self.spec.name
634    }
635
636    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
637        self.status.as_ref()
638    }
639}
640
641impl DiscoverableRecord for crate::crd::TXTRecord {
642    fn dns_record_kind() -> crate::crd::DNSRecordKind {
643        crate::crd::DNSRecordKind::TXT
644    }
645
646    fn spec_name(&self) -> &str {
647        &self.spec.name
648    }
649
650    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
651        self.status.as_ref()
652    }
653}
654
655impl DiscoverableRecord for crate::crd::CNAMERecord {
656    fn dns_record_kind() -> crate::crd::DNSRecordKind {
657        crate::crd::DNSRecordKind::CNAME
658    }
659
660    fn spec_name(&self) -> &str {
661        &self.spec.name
662    }
663
664    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
665        self.status.as_ref()
666    }
667}
668
669impl DiscoverableRecord for crate::crd::MXRecord {
670    fn dns_record_kind() -> crate::crd::DNSRecordKind {
671        crate::crd::DNSRecordKind::MX
672    }
673
674    fn spec_name(&self) -> &str {
675        &self.spec.name
676    }
677
678    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
679        self.status.as_ref()
680    }
681}
682
683impl DiscoverableRecord for crate::crd::NSRecord {
684    fn dns_record_kind() -> crate::crd::DNSRecordKind {
685        crate::crd::DNSRecordKind::NS
686    }
687
688    fn spec_name(&self) -> &str {
689        &self.spec.name
690    }
691
692    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
693        self.status.as_ref()
694    }
695}
696
697impl DiscoverableRecord for crate::crd::SRVRecord {
698    fn dns_record_kind() -> crate::crd::DNSRecordKind {
699        crate::crd::DNSRecordKind::SRV
700    }
701
702    fn spec_name(&self) -> &str {
703        &self.spec.name
704    }
705
706    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
707        self.status.as_ref()
708    }
709}
710
711impl DiscoverableRecord for crate::crd::CAARecord {
712    fn dns_record_kind() -> crate::crd::DNSRecordKind {
713        crate::crd::DNSRecordKind::CAA
714    }
715
716    fn spec_name(&self) -> &str {
717        &self.spec.name
718    }
719
720    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
721        self.status.as_ref()
722    }
723}
724
725impl DiscoverableRecord for crate::crd::PTRRecord {
726    fn dns_record_kind() -> crate::crd::DNSRecordKind {
727        crate::crd::DNSRecordKind::PTR
728    }
729
730    fn spec_name(&self) -> &str {
731        &self.spec.name
732    }
733
734    fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
735        self.status.as_ref()
736    }
737}
738
739/// Generic helper function to discover DNS records matching a label selector.
740///
741/// This function eliminates duplication across the 9 record-type-specific discovery functions.
742/// It works for any record type implementing the `DiscoverableRecord` trait.
743///
744/// # Type Parameters
745///
746/// * `T` - The DNS record type to discover (e.g., `ARecord`, `TXTRecord`)
747///
748/// # Arguments
749///
750/// * `client` - Kubernetes API client
751/// * `namespace` - Namespace to search for records
752/// * `selector` - Label selector to match records against
753/// * `_zone_name` - Zone name (unused but kept for API compatibility)
754///
755/// # Returns
756///
757/// Vector of record references with timestamps for records that match the selector
758///
759/// # Errors
760///
761/// Returns an error if listing records from the Kubernetes API fails
762async fn discover_records_generic<T>(
763    client: &Client,
764    namespace: &str,
765    selector: &crate::crd::LabelSelector,
766    _zone_name: &str,
767) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>>
768where
769    T: DiscoverableRecord,
770{
771    use std::collections::BTreeMap;
772
773    let api: kube::Api<T> = kube::Api::namespaced(client.clone(), namespace);
774    let records = list_all_paginated(&api, kube::api::ListParams::default()).await?;
775
776    let mut record_refs = Vec::new();
777    for record in records {
778        let labels: BTreeMap<String, String> = record.meta().labels.clone().unwrap_or_default();
779
780        if !selector.matches(&labels) {
781            continue;
782        }
783
784        debug!(
785            "Discovered {} record {}/{}",
786            T::dns_record_kind().as_str(),
787            namespace,
788            record.name_any()
789        );
790
791        // Preserve existing last_updated timestamp if record was previously reconciled
792        let last_reconciled_at = record
793            .record_status()
794            .and_then(|s| s.last_updated.as_ref())
795            .and_then(|ts| {
796                // Parse ISO8601 timestamp string into k8s Time
797                ts.parse::<k8s_openapi::jiff::Timestamp>()
798                    .ok()
799                    .map(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time)
800            });
801
802        record_refs.push(crate::crd::RecordReferenceWithTimestamp {
803            api_version: "bindy.firestoned.io/v1beta1".to_string(),
804            kind: T::dns_record_kind().as_str().to_string(),
805            name: record.name_any(),
806            namespace: namespace.to_string(),
807            record_name: Some(record.spec_name().to_string()),
808            last_reconciled_at,
809        });
810    }
811
812    Ok(record_refs)
813}
814
815/// Helper function to discover A records matching a label selector.
816async fn discover_a_records(
817    client: &Client,
818    namespace: &str,
819    selector: &crate::crd::LabelSelector,
820    zone_name: &str,
821) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
822    discover_records_generic::<crate::crd::ARecord>(client, namespace, selector, zone_name).await
823}
824
825/// Helper function to discover AAAA records matching a label selector.
826async fn discover_aaaa_records(
827    client: &Client,
828    namespace: &str,
829    selector: &crate::crd::LabelSelector,
830    zone_name: &str,
831) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
832    discover_records_generic::<crate::crd::AAAARecord>(client, namespace, selector, zone_name).await
833}
834
835/// Helper function to discover TXT records matching a label selector.
836async fn discover_txt_records(
837    client: &Client,
838    namespace: &str,
839    selector: &crate::crd::LabelSelector,
840    zone_name: &str,
841) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
842    discover_records_generic::<crate::crd::TXTRecord>(client, namespace, selector, zone_name).await
843}
844
845/// Helper function to discover CNAME records matching a label selector.
846async fn discover_cname_records(
847    client: &Client,
848    namespace: &str,
849    selector: &crate::crd::LabelSelector,
850    zone_name: &str,
851) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
852    discover_records_generic::<crate::crd::CNAMERecord>(client, namespace, selector, zone_name)
853        .await
854}
855
856/// Helper function to discover MX records matching a label selector.
857async fn discover_mx_records(
858    client: &Client,
859    namespace: &str,
860    selector: &crate::crd::LabelSelector,
861    zone_name: &str,
862) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
863    discover_records_generic::<crate::crd::MXRecord>(client, namespace, selector, zone_name).await
864}
865
866/// Helper function to discover NS records matching a label selector.
867async fn discover_ns_records(
868    client: &Client,
869    namespace: &str,
870    selector: &crate::crd::LabelSelector,
871    zone_name: &str,
872) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
873    discover_records_generic::<crate::crd::NSRecord>(client, namespace, selector, zone_name).await
874}
875
876/// Helper function to discover SRV records matching a label selector.
877async fn discover_srv_records(
878    client: &Client,
879    namespace: &str,
880    selector: &crate::crd::LabelSelector,
881    zone_name: &str,
882) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
883    discover_records_generic::<crate::crd::SRVRecord>(client, namespace, selector, zone_name).await
884}
885
886/// Helper function to discover CAA records matching a label selector.
887async fn discover_caa_records(
888    client: &Client,
889    namespace: &str,
890    selector: &crate::crd::LabelSelector,
891    zone_name: &str,
892) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
893    discover_records_generic::<crate::crd::CAARecord>(client, namespace, selector, zone_name).await
894}
895
896/// Helper function to discover PTR records matching a label selector.
897async fn discover_ptr_records(
898    client: &Client,
899    namespace: &str,
900    selector: &crate::crd::LabelSelector,
901    zone_name: &str,
902) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
903    discover_records_generic::<crate::crd::PTRRecord>(client, namespace, selector, zone_name).await
904}
905
906/// Checks if all DNS records are ready.
907///
908/// Iterates through all record references and verifies their readiness status.
909///
910/// # Arguments
911///
912/// * `client` - Kubernetes API client
913/// * `namespace` - Namespace to check records in
914/// * `record_refs` - List of record references to check
915///
916/// # Returns
917///
918/// `true` if all records are ready, `false` otherwise
919///
920/// # Errors
921///
922/// Returns an error if Kubernetes API calls fail
923pub async fn check_all_records_ready(
924    client: &Client,
925    namespace: &str,
926    record_refs: &[crate::crd::RecordReferenceWithTimestamp],
927) -> Result<bool> {
928    use crate::crd::{
929        AAAARecord, ARecord, CAARecord, CNAMERecord, DNSRecordKind, MXRecord, NSRecord, PTRRecord,
930        SRVRecord, TXTRecord,
931    };
932
933    for record_ref in record_refs {
934        let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
935        let is_ready = match kind {
936            DNSRecordKind::A => {
937                let api: Api<ARecord> = Api::namespaced(client.clone(), namespace);
938                check_record_ready(&api, &record_ref.name).await?
939            }
940            DNSRecordKind::AAAA => {
941                let api: Api<AAAARecord> = Api::namespaced(client.clone(), namespace);
942                check_record_ready(&api, &record_ref.name).await?
943            }
944            DNSRecordKind::TXT => {
945                let api: Api<TXTRecord> = Api::namespaced(client.clone(), namespace);
946                check_record_ready(&api, &record_ref.name).await?
947            }
948            DNSRecordKind::CNAME => {
949                let api: Api<CNAMERecord> = Api::namespaced(client.clone(), namespace);
950                check_record_ready(&api, &record_ref.name).await?
951            }
952            DNSRecordKind::MX => {
953                let api: Api<MXRecord> = Api::namespaced(client.clone(), namespace);
954                check_record_ready(&api, &record_ref.name).await?
955            }
956            DNSRecordKind::NS => {
957                let api: Api<NSRecord> = Api::namespaced(client.clone(), namespace);
958                check_record_ready(&api, &record_ref.name).await?
959            }
960            DNSRecordKind::SRV => {
961                let api: Api<SRVRecord> = Api::namespaced(client.clone(), namespace);
962                check_record_ready(&api, &record_ref.name).await?
963            }
964            DNSRecordKind::CAA => {
965                let api: Api<CAARecord> = Api::namespaced(client.clone(), namespace);
966                check_record_ready(&api, &record_ref.name).await?
967            }
968            DNSRecordKind::PTR => {
969                let api: Api<PTRRecord> = Api::namespaced(client.clone(), namespace);
970                check_record_ready(&api, &record_ref.name).await?
971            }
972        };
973
974        if !is_ready {
975            debug!(
976                "Record {}/{} (kind: {}) is not ready yet",
977                namespace, record_ref.name, record_ref.kind
978            );
979            return Ok(false);
980        }
981    }
982
983    Ok(true)
984}
985
986/// Check if a specific record is ready by examining its status conditions.
987async fn check_record_ready<T>(api: &Api<T>, name: &str) -> Result<bool>
988where
989    T: kube::Resource<DynamicType = ()>
990        + Clone
991        + serde::de::DeserializeOwned
992        + serde::Serialize
993        + std::fmt::Debug
994        + Send
995        + Sync,
996    <T as kube::Resource>::DynamicType: Default,
997{
998    let record = match api.get(name).await {
999        Ok(r) => r,
1000        Err(e) => {
1001            warn!("Failed to get record {}: {}", name, e);
1002            return Ok(false);
1003        }
1004    };
1005
1006    // Use serde_json to access the status field dynamically
1007    let record_json = serde_json::to_value(&record)?;
1008    let status = record_json.get("status");
1009
1010    if let Some(status_obj) = status {
1011        if let Some(conditions) = status_obj.get("conditions").and_then(|c| c.as_array()) {
1012            for condition in conditions {
1013                if let (Some(type_val), Some(status_val)) = (
1014                    condition.get("type").and_then(|t| t.as_str()),
1015                    condition.get("status").and_then(|s| s.as_str()),
1016                ) {
1017                    if type_val == "Ready" && status_val == "True" {
1018                        return Ok(true);
1019                    }
1020                }
1021            }
1022        }
1023    }
1024
1025    Ok(false)
1026}
1027
1028/// Find all `DNSZones` that have selected a given record via label selectors.
1029///
1030/// This function is used by the watch mapper to determine which `DNSZones` should be
1031/// reconciled when a DNS record changes. It checks each `DNSZone`'s `status.records` list
1032/// to see if the record is present.
1033///
1034/// # Arguments
1035///
1036/// * `client` - Kubernetes API client
1037/// * `record_namespace` - Namespace of the record
1038/// * `record_kind` - Kind of the record (e.g., `"ARecord"`, `"TXTRecord"`)
1039/// * `record_name` - Name of the record resource
1040///
1041/// # Returns
1042///
1043/// A vector of tuples containing `(zone_name, zone_namespace)` for all `DNSZones` that have
1044/// selected this record.
1045///
1046/// # Errors
1047///
1048/// Returns an error if Kubernetes API operations fail.
1049pub async fn find_zones_selecting_record(
1050    client: &Client,
1051    record_namespace: &str,
1052    record_kind: &str,
1053    record_name: &str,
1054) -> Result<Vec<(String, String)>> {
1055    let api: Api<DNSZone> = Api::namespaced(client.clone(), record_namespace);
1056    let zones = list_all_paginated(&api, ListParams::default()).await?;
1057
1058    let mut selecting_zones = vec![];
1059
1060    for zone in zones {
1061        let Some(ref status) = zone.status else {
1062            continue;
1063        };
1064
1065        // Check if this record is in the zone's status.records list
1066        let is_selected = status
1067            .records
1068            .iter()
1069            .any(|r| r.kind == record_kind && r.name == record_name);
1070
1071        if is_selected {
1072            let zone_name = zone.name_any();
1073            let zone_namespace = zone.namespace().unwrap_or_default();
1074            selecting_zones.push((zone_name, zone_namespace));
1075        }
1076    }
1077
1078    Ok(selecting_zones)
1079}
1080/// Discover and update DNSZone status with DNS records.
1081///
1082/// This wrapper function orchestrates record discovery and status updates:
1083/// 1. Sets "Progressing" status condition
1084/// 2. Calls `reconcile_zone_records()` to discover records
1085/// 3. Updates DNSZone status with discovered records
1086///
1087/// # Arguments
1088///
1089/// * `client` - Kubernetes API client
1090/// * `dnszone` - The DNSZone resource being reconciled
1091/// * `status_updater` - Status updater for setting conditions and records
1092/// * `stores` - Context stores for resolving instances and creating `Bind9Manager`s
1093///
1094/// # Returns
1095///
1096/// Tuple of (record_refs, records_count) - the discovered record references and their count
1097///
1098/// # Errors
1099///
1100/// Returns an error if record discovery fails (e.g., a transient record list
1101/// failure). The existing `status.records` list is left untouched in that case
1102/// so the watch mapper keeps working until discovery succeeds again; wiping it
1103/// on a transient error would orphan the zone's records.
1104pub async fn discover_and_update_records(
1105    client: &kube::Client,
1106    dnszone: &crate::crd::DNSZone,
1107    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
1108    stores: &crate::context::Stores,
1109) -> Result<(Vec<crate::crd::RecordReferenceWithTimestamp>, usize)> {
1110    let spec = &dnszone.spec;
1111
1112    // Set progressing status
1113    status_updater.set_condition(
1114        "Progressing",
1115        "True",
1116        "RecordsDiscovering",
1117        "Discovering DNS records via label selectors",
1118    );
1119
1120    // Discover records. On failure, propagate the error WITHOUT touching
1121    // status.records: overwriting it with an empty list on a transient list
1122    // failure would break the record watch mapper until the next successful
1123    // discovery.
1124    let record_refs = reconcile_zone_records(client.clone(), dnszone.clone(), stores)
1125        .await
1126        .map_err(|e| {
1127            warn!(
1128                "Failed to discover DNS records for zone {}: {}. Keeping existing status.records for retry.",
1129                spec.zone_name, e
1130            );
1131            e.context(format!(
1132                "Failed to discover DNS records for zone {}",
1133                spec.zone_name
1134            ))
1135        })?;
1136
1137    info!(
1138        "Discovered {} DNS record(s) for zone {} via label selectors",
1139        record_refs.len(),
1140        spec.zone_name
1141    );
1142
1143    let records_count = record_refs.len();
1144
1145    // Update DNSZone status with discovered records (in-memory)
1146    status_updater.set_records(&record_refs);
1147
1148    Ok((record_refs, records_count))
1149}
1150
1151#[cfg(test)]
1152#[path = "discovery_tests.rs"]
1153mod discovery_tests;
1154
1155/// Returns the zones that are configured on a given `Bind9Instance`.
1156///
1157/// Used by the `DNSZone` controller's `Endpoints` watch: an `Endpoints` object
1158/// is named after the instance's Service, and it changes exactly when the set of
1159/// ready BIND9 pods for that instance changes - a pod being replaced, a
1160/// Deployment being rolled, a node draining. That is precisely when a zone may
1161/// have disappeared from a server and must be reconciled, so this maps the
1162/// instance back to every zone that expects to be served by it.
1163///
1164/// Matching is done against `status.bind9Instances`, the authoritative record of
1165/// which instances a zone has been configured on.
1166///
1167/// # Arguments
1168///
1169/// * `zones` - The zones to search (typically the reflector store contents)
1170/// * `instance_namespace` - Namespace of the instance whose pods changed
1171/// * `instance_name` - Name of the instance whose pods changed
1172///
1173/// # Returns
1174///
1175/// `(namespace, name)` of every zone configured on that instance.
1176#[must_use]
1177pub fn zones_configured_on_instance(
1178    zones: &[crate::crd::DNSZone],
1179    instance_namespace: &str,
1180    instance_name: &str,
1181) -> Vec<(String, String)> {
1182    zones
1183        .iter()
1184        .filter_map(|zone| {
1185            let zone_namespace = zone.namespace()?;
1186            let status = zone.status.as_ref()?;
1187
1188            let serves_instance = status
1189                .bind9_instances
1190                .iter()
1191                .any(|inst| inst.name == instance_name && inst.namespace == instance_namespace);
1192
1193            serves_instance.then(|| (zone_namespace, zone.name_any()))
1194        })
1195        .collect()
1196}