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