bindy/reconcilers/records/
mod.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! DNS record reconciliation logic.
5//!
6//! This module contains reconcilers for all DNS record types supported by Bindy.
7//!
8//! **Event-Driven Architecture**: DNS record reconcilers react to status changes.
9
10// Submodules
11pub mod status_helpers;
12pub mod types;
13
14// Internal imports
15use status_helpers::update_record_status;
16
17// Removed ANNOTATION_ZONE_OWNER - using status.zoneRef instead (event-driven architecture)
18use crate::crd::{
19    AAAARecord, ARecord, CAARecord, CNAMERecord, DNSZone, MXRecord, NSRecord, SRVRecord, TXTRecord,
20};
21use anyhow::{Context, Result};
22use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
23
24use kube::{
25    api::{Patch, PatchParams},
26    client::Client,
27    Api, Resource, ResourceExt,
28};
29use serde_json::json;
30use tracing::{debug, info, warn};
31
32/// Gets the `DNSZone` reference from the record's status.
33///
34/// The `DNSZone` controller sets `status.zoneRef` when the zone's `recordsFrom` selector
35/// matches this record's labels. This field contains the complete Kubernetes object reference.
36///
37/// # Arguments
38///
39/// * `client` - Kubernetes API client
40/// * `zone_ref` - Zone reference from record status
41///
42/// # Returns
43///
44/// The `DNSZone` resource
45///
46/// # Errors
47///
48/// Returns an error if the `DNSZone` resource cannot be found or queried.
49async fn get_zone_from_ref(
50    client: &Client,
51    zone_ref: &crate::crd::ZoneReference,
52) -> Result<DNSZone> {
53    let dns_zones_api: Api<DNSZone> = Api::namespaced(client.clone(), &zone_ref.namespace);
54
55    dns_zones_api.get(&zone_ref.name).await.context(format!(
56        "Failed to get DNSZone {}/{}",
57        zone_ref.namespace, zone_ref.name
58    ))
59}
60
61/// Generic result type for record reconciliation helper.
62///
63/// Contains all the information needed to add a record to BIND9 primaries.
64struct RecordReconciliationContext {
65    /// Zone reference from record status
66    zone_ref: crate::crd::ZoneReference,
67    /// Primary instance references to use for DNS updates
68    primary_refs: Vec<crate::crd::InstanceReference>,
69    /// Current hash of the record spec
70    current_hash: String,
71}
72
73/// Generic helper function for record reconciliation.
74///
75/// This function handles the common logic for all record types:
76/// 1. Check if record has status.zoneRef (set by `DNSZone` controller)
77/// 2. Look up the `DNSZone` resource
78/// 3. Get instances from the zone
79/// 4. Filter to primary instances only
80/// 5. Return context for adding record to BIND9
81///
82/// # Arguments
83///
84/// * `client` - Kubernetes API client
85/// * `record` - The DNS record resource
86/// * `record_type` - Human-readable record type name (e.g., "A", "TXT", "AAAA")
87/// * `spec_hashable` - The record spec to hash for change detection
88///
89/// # Returns
90///
91/// * `Ok(Some(context))` - Record is selected and ready to be added to BIND9
92/// * `Ok(None)` - Record is not selected or generation unchanged (status already updated)
93/// * `Err(_)` - Fatal error occurred
94///
95/// # Errors
96///
97/// Returns an error if status updates fail or critical Kubernetes API errors occur.
98#[allow(clippy::too_many_lines)]
99async fn prepare_record_reconciliation<T, S>(
100    client: &Client,
101    record: &T,
102    record_type: &str,
103    spec_hashable: &S,
104    bind9_instances_store: &kube::runtime::reflector::Store<crate::crd::Bind9Instance>,
105) -> Result<Option<RecordReconciliationContext>>
106where
107    T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
108        + ResourceExt
109        + Clone
110        + std::fmt::Debug
111        + serde::Serialize
112        + for<'de> serde::Deserialize<'de>,
113    S: serde::Serialize,
114{
115    let namespace = record.namespace().unwrap_or_default();
116    let name = record.name_any();
117
118    // Extract status fields generically
119    let record_json = serde_json::to_value(record)?;
120    let status = record_json.get("status");
121
122    let zone_ref = status
123        .and_then(|s| s.get("zoneRef"))
124        .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z.clone()).ok());
125
126    let observed_generation = status
127        .and_then(|s| s.get("observedGeneration"))
128        .and_then(serde_json::Value::as_i64);
129
130    let current_generation = record.meta().generation;
131
132    // Check if record has zoneRef (set by DNSZone controller)
133    let Some(zone_ref) = zone_ref else {
134        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
135        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
136            debug!("Spec unchanged and no zoneRef, skipping reconciliation");
137            return Ok(None);
138        }
139
140        info!(
141            "{} record {}/{} not selected by any DNSZone (no zoneRef in status)",
142            record_type, namespace, name
143        );
144        update_record_status(
145            client,
146            record,
147            "Ready",
148            "False",
149            "NotSelected",
150            "Record not selected by any DNSZone recordsFrom selector",
151            current_generation,
152            None, // record_hash
153            None, // last_updated
154            None, // addresses
155            None, // published_name
156        )
157        .await?;
158        return Ok(None);
159    };
160
161    // Calculate hash of current spec to detect actual data changes
162    let current_hash = crate::ddns::calculate_record_hash(spec_hashable);
163
164    // Get the DNSZone resource via zoneRef
165    let dnszone = match get_zone_from_ref(client, &zone_ref).await {
166        Ok(zone) => zone,
167        Err(e) => {
168            warn!(
169                "Failed to get DNSZone {}/{} for {} record {}/{}: {}",
170                zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
171            );
172            update_record_status(
173                client,
174                record,
175                "Ready",
176                "False",
177                "ZoneNotFound",
178                &format!(
179                    "Referenced DNSZone {}/{} not found: {e}",
180                    zone_ref.namespace, zone_ref.name
181                ),
182                current_generation,
183                None, // record_hash
184                None, // last_updated
185                None, // addresses
186                None, // published_name
187            )
188            .await?;
189            return Ok(None);
190        }
191    };
192
193    // Get instances from the DNSZone
194    let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
195        &dnszone,
196        bind9_instances_store,
197    ) {
198        Ok(refs) => refs,
199        Err(e) => {
200            warn!(
201                "DNSZone {}/{} has no instances assigned for {} record {}/{}: {}",
202                zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
203            );
204            update_record_status(
205                client,
206                record,
207                "Ready",
208                "False",
209                "ZoneNotConfigured",
210                &format!("DNSZone has no instances: {e}"),
211                current_generation,
212                None, // record_hash
213                None, // last_updated
214                None, // addresses
215                None, // published_name
216            )
217            .await?;
218            return Ok(None);
219        }
220    };
221
222    // Filter to PRIMARY instances only
223    let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
224        client,
225        &instance_refs,
226    )
227    .await
228    {
229        Ok(refs) => refs,
230        Err(e) => {
231            warn!(
232                "Failed to filter primary instances for {} record {}/{}: {}",
233                record_type, namespace, name, e
234            );
235            update_record_status(
236                client,
237                record,
238                "Ready",
239                "False",
240                "InstanceFilterError",
241                &format!("Failed to filter primary instances: {e}"),
242                current_generation,
243                None, // record_hash
244                None, // last_updated
245                None, // addresses
246                None, // published_name
247            )
248            .await?;
249            return Ok(None);
250        }
251    };
252
253    if primary_refs.is_empty() {
254        warn!(
255            "DNSZone {}/{} has no primary instances for {} record {}/{}",
256            zone_ref.namespace, zone_ref.name, record_type, namespace, name
257        );
258        update_record_status(
259            client,
260            record,
261            "Ready",
262            "False",
263            "NoPrimaryInstances",
264            "DNSZone has no primary instances configured",
265            current_generation,
266            None, // record_hash
267            None, // last_updated
268            None, // addresses
269            None, // published_name
270        )
271        .await?;
272        return Ok(None);
273    }
274
275    Ok(Some(RecordReconciliationContext {
276        zone_ref,
277        primary_refs,
278        current_hash,
279    }))
280}
281
282/// Reconciles an `ARecord` (IPv4 address) resource.
283///
284/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
285/// the record in BIND9 primaries for those zones using dynamic DNS updates.
286///
287/// # Arguments
288///
289/// * `client` - Kubernetes API client
290/// * `record` - The `ARecord` resource to reconcile
291///
292/// # Example
293///
294/// ```rust,no_run
295/// use bindy::reconcilers::reconcile_a_record;
296/// use bindy::crd::ARecord;
297/// use bindy::context::Context;
298/// use std::sync::Arc;
299///
300/// async fn handle_a_record(ctx: Arc<Context>, record: ARecord) -> anyhow::Result<()> {
301///     reconcile_a_record(ctx, record).await?;
302///     Ok(())
303/// }
304/// ```
305/// Trait for record-specific BIND9 operations.
306///
307/// This trait abstracts over the different record types and provides a uniform interface
308/// for adding records to BIND9 instances via the `Bind9Manager`.
309///
310/// Each DNS record type implements this trait to define how it should be added to BIND9
311/// using dynamic DNS updates (RFC 2136 nsupdate protocol).
312trait RecordOperation: Clone + Send + Sync {
313    /// Get the record type name (e.g., "A", "TXT", "AAAA") for logging and events.
314    fn record_type_name(&self) -> &'static str;
315
316    /// Add this record to a BIND9 instance via the `Bind9Manager`.
317    ///
318    /// # Arguments
319    ///
320    /// * `zone_manager` - The `Bind9Manager` instance to use for the operation
321    /// * `zone_name` - The DNS zone name (e.g., "example.com")
322    /// * `record_name` - The record name within the zone (e.g., "www")
323    /// * `ttl` - Optional TTL value
324    /// * `server` - The BIND9 server endpoint (IP:port)
325    /// * `key_data` - RNDC key data for authentication
326    ///
327    /// # Errors
328    ///
329    /// Returns an error if the dynamic DNS update fails.
330    fn add_to_bind9(
331        &self,
332        zone_manager: &crate::bind9::Bind9Manager,
333        zone_name: &str,
334        record_name: &str,
335        ttl: Option<i32>,
336        server: &str,
337        key_data: &crate::bind9::RndcKeyData,
338    ) -> impl std::future::Future<Output = Result<()>> + Send;
339}
340
341/// Trait for DNS record resources that can be reconciled.
342///
343/// This trait provides the interface for generic record reconciliation,
344/// allowing a single `reconcile_record<T>()` function to handle all record types.
345/// It eliminates duplication across 8 record type reconcilers by providing
346/// type-specific operations through trait methods.
347///
348/// # Example
349///
350/// ```rust,ignore
351/// impl ReconcilableRecord for ARecord {
352///     type Spec = crate::crd::ARecordSpec;
353///     type Operation = ARecordOp;
354///
355///     fn get_spec(&self) -> &Self::Spec {
356///         &self.spec
357///     }
358///
359///     fn record_type_name() -> &'static str {
360///         "A"
361///     }
362///
363///     fn create_operation(spec: &Self::Spec) -> Self::Operation {
364///         ARecordOp {
365///             ipv4_address: spec.ipv4_address.clone(),
366///         }
367///     }
368///
369///     fn get_record_name(spec: &Self::Spec) -> &str {
370///         &spec.name
371///     }
372///
373///     fn get_ttl(spec: &Self::Spec) -> Option<i32> {
374///         spec.ttl
375///     }
376/// }
377/// ```
378trait ReconcilableRecord:
379    Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
380    + ResourceExt
381    + Clone
382    + std::fmt::Debug
383    + serde::Serialize
384    + for<'de> serde::Deserialize<'de>
385    + Send
386    + Sync
387{
388    /// The spec type for this record (e.g., `ARecordSpec`, `TXTRecordSpec`)
389    type Spec: serde::Serialize + Clone;
390
391    /// The operation type for BIND9 updates (e.g., `ARecordOp`, `TXTRecordOp`)
392    type Operation: RecordOperation;
393
394    /// Get the record's spec
395    fn get_spec(&self) -> &Self::Spec;
396
397    /// Get the record's status, if any
398    fn get_status(&self) -> Option<&crate::crd::RecordStatus>;
399
400    /// Get the record type name (e.g., "A", "TXT", "AAAA") for logging
401    fn record_type_name() -> &'static str;
402
403    /// Get the `hickory_proto` record type used for DNS deletion operations
404    fn record_type_hickory() -> hickory_proto::rr::RecordType;
405
406    /// Create the BIND9 operation from the spec
407    fn create_operation(spec: &Self::Spec) -> Self::Operation;
408
409    /// Get the record name from the spec
410    fn get_record_name(spec: &Self::Spec) -> &str;
411
412    /// Get the TTL from the spec
413    fn get_ttl(spec: &Self::Spec) -> Option<i32>;
414
415    /// Comma-separated display addresses for `status.addresses` (A/AAAA only).
416    ///
417    /// Returns `None` for record types that do not publish addresses.
418    fn get_display_addresses(_spec: &Self::Spec) -> Option<String> {
419        None
420    }
421}
422
423/// Generic helper to add a record to all primary instances.
424///
425/// This function eliminates duplication across the 8 `add_*_record_to_instances` functions
426/// by providing a generic implementation that works for any record type implementing
427/// the `RecordOperation` trait.
428///
429/// # Type Parameters
430///
431/// * `R` - The record operation type implementing `RecordOperation`
432///
433/// # Arguments
434///
435/// * `client` - Kubernetes API client
436/// * `stores` - Context stores for creating `Bind9Manager` instances
437/// * `instance_refs` - Primary instance references
438/// * `zone_name` - DNS zone name
439/// * `record_name` - Record name within the zone
440/// * `ttl` - Optional TTL value
441/// * `record_op` - The record-specific operation to perform
442///
443/// # Errors
444///
445/// Returns an error if any dynamic DNS update fails.
446async fn add_record_to_instances_generic<R>(
447    client: &Client,
448    stores: &crate::context::Stores,
449    instance_refs: &[crate::crd::InstanceReference],
450    zone_name: &str,
451    record_name: &str,
452    ttl: Option<i32>,
453    record_op: R,
454) -> Result<()>
455where
456    R: RecordOperation,
457{
458    use crate::reconcilers::dnszone::helpers::for_each_instance_endpoint;
459
460    // Create a map of instance name -> namespace for quick lookup
461    let instance_map: std::collections::HashMap<String, String> = instance_refs
462        .iter()
463        .map(|inst| (inst.name.clone(), inst.namespace.clone()))
464        .collect();
465
466    let (_first, _total) = for_each_instance_endpoint(
467        client,
468        instance_refs,
469        true,      // with_rndc_key
470        "dns-tcp", // Use DNS TCP port for dynamic updates
471        |pod_endpoint, instance_name, rndc_key| {
472            let zone_name = zone_name.to_string();
473            let record_name = record_name.to_string();
474
475            // Get namespace for this instance
476            let instance_namespace = instance_map
477                .get(&instance_name)
478                .expect("Instance should be in map")
479                .clone();
480
481            // Create Bind9Manager for this specific instance with deployment-aware auth
482            let zone_manager =
483                stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
484
485            // Clone record_op for the async block
486            let record_op_clone = record_op.clone();
487
488            async move {
489                let key_data = rndc_key.expect("RNDC key should be loaded");
490
491                record_op_clone
492                    .add_to_bind9(&zone_manager, &zone_name, &record_name, ttl, &pod_endpoint, &key_data)
493                    .await
494                    .context(format!(
495                        "Failed to add {} record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})",
496                        record_op_clone.record_type_name()
497                    ))?;
498
499                Ok(())
500            }
501        },
502    )
503    .await?;
504
505    Ok(())
506}
507
508// Record operation implementations for each DNS record type
509
510/// A record operation wrapper.
511#[derive(Clone)]
512struct ARecordOp {
513    ipv4_addresses: Vec<String>,
514}
515
516impl RecordOperation for ARecordOp {
517    fn record_type_name(&self) -> &'static str {
518        "A"
519    }
520
521    async fn add_to_bind9(
522        &self,
523        zone_manager: &crate::bind9::Bind9Manager,
524        zone_name: &str,
525        record_name: &str,
526        ttl: Option<i32>,
527        server: &str,
528        key_data: &crate::bind9::RndcKeyData,
529    ) -> Result<()> {
530        zone_manager
531            .add_a_record(
532                zone_name,
533                record_name,
534                &self.ipv4_addresses,
535                ttl,
536                server,
537                key_data,
538            )
539            .await
540    }
541}
542
543/// Implement `ReconcilableRecord` for `ARecord`.
544impl ReconcilableRecord for ARecord {
545    type Spec = crate::crd::ARecordSpec;
546    type Operation = ARecordOp;
547
548    fn get_spec(&self) -> &Self::Spec {
549        &self.spec
550    }
551
552    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
553        self.status.as_ref()
554    }
555
556    fn record_type_name() -> &'static str {
557        "A"
558    }
559
560    fn record_type_hickory() -> hickory_proto::rr::RecordType {
561        hickory_proto::rr::RecordType::A
562    }
563
564    fn create_operation(spec: &Self::Spec) -> Self::Operation {
565        ARecordOp {
566            ipv4_addresses: spec.ipv4_addresses.clone(),
567        }
568    }
569
570    fn get_record_name(spec: &Self::Spec) -> &str {
571        &spec.name
572    }
573
574    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
575        spec.ttl
576    }
577
578    fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
579        Some(spec.ipv4_addresses.join(","))
580    }
581}
582
583/// AAAA record operation wrapper.
584#[derive(Clone)]
585struct AAAARecordOp {
586    ipv6_addresses: Vec<String>,
587}
588
589impl RecordOperation for AAAARecordOp {
590    fn record_type_name(&self) -> &'static str {
591        "AAAA"
592    }
593
594    async fn add_to_bind9(
595        &self,
596        zone_manager: &crate::bind9::Bind9Manager,
597        zone_name: &str,
598        record_name: &str,
599        ttl: Option<i32>,
600        server: &str,
601        key_data: &crate::bind9::RndcKeyData,
602    ) -> Result<()> {
603        zone_manager
604            .add_aaaa_record(
605                zone_name,
606                record_name,
607                &self.ipv6_addresses,
608                ttl,
609                server,
610                key_data,
611            )
612            .await
613    }
614}
615
616/// Implement `ReconcilableRecord` for `AAAARecord`.
617impl ReconcilableRecord for AAAARecord {
618    type Spec = crate::crd::AAAARecordSpec;
619    type Operation = AAAARecordOp;
620
621    fn get_spec(&self) -> &Self::Spec {
622        &self.spec
623    }
624
625    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
626        self.status.as_ref()
627    }
628
629    fn record_type_name() -> &'static str {
630        "AAAA"
631    }
632
633    fn record_type_hickory() -> hickory_proto::rr::RecordType {
634        hickory_proto::rr::RecordType::AAAA
635    }
636
637    fn create_operation(spec: &Self::Spec) -> Self::Operation {
638        AAAARecordOp {
639            ipv6_addresses: spec.ipv6_addresses.clone(),
640        }
641    }
642
643    fn get_record_name(spec: &Self::Spec) -> &str {
644        &spec.name
645    }
646
647    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
648        spec.ttl
649    }
650
651    fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
652        Some(spec.ipv6_addresses.join(","))
653    }
654}
655
656/// CNAME record operation wrapper.
657#[derive(Clone)]
658struct CNAMERecordOp {
659    target: String,
660}
661
662impl RecordOperation for CNAMERecordOp {
663    fn record_type_name(&self) -> &'static str {
664        "CNAME"
665    }
666
667    async fn add_to_bind9(
668        &self,
669        zone_manager: &crate::bind9::Bind9Manager,
670        zone_name: &str,
671        record_name: &str,
672        ttl: Option<i32>,
673        server: &str,
674        key_data: &crate::bind9::RndcKeyData,
675    ) -> Result<()> {
676        zone_manager
677            .add_cname_record(zone_name, record_name, &self.target, ttl, server, key_data)
678            .await
679    }
680}
681
682/// Implement `ReconcilableRecord` for `CNAMERecord`.
683impl ReconcilableRecord for CNAMERecord {
684    type Spec = crate::crd::CNAMERecordSpec;
685    type Operation = CNAMERecordOp;
686
687    fn get_spec(&self) -> &Self::Spec {
688        &self.spec
689    }
690
691    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
692        self.status.as_ref()
693    }
694
695    fn record_type_name() -> &'static str {
696        "CNAME"
697    }
698
699    fn record_type_hickory() -> hickory_proto::rr::RecordType {
700        hickory_proto::rr::RecordType::CNAME
701    }
702
703    fn create_operation(spec: &Self::Spec) -> Self::Operation {
704        CNAMERecordOp {
705            target: spec.target.clone(),
706        }
707    }
708
709    fn get_record_name(spec: &Self::Spec) -> &str {
710        &spec.name
711    }
712
713    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
714        spec.ttl
715    }
716}
717
718/// TXT record operation wrapper.
719#[derive(Clone)]
720struct TXTRecordOp {
721    texts: Vec<String>,
722}
723
724impl RecordOperation for TXTRecordOp {
725    fn record_type_name(&self) -> &'static str {
726        "TXT"
727    }
728
729    async fn add_to_bind9(
730        &self,
731        zone_manager: &crate::bind9::Bind9Manager,
732        zone_name: &str,
733        record_name: &str,
734        ttl: Option<i32>,
735        server: &str,
736        key_data: &crate::bind9::RndcKeyData,
737    ) -> Result<()> {
738        zone_manager
739            .add_txt_record(zone_name, record_name, &self.texts, ttl, server, key_data)
740            .await
741    }
742}
743
744/// Implement `ReconcilableRecord` for `TXTRecord`.
745impl ReconcilableRecord for TXTRecord {
746    type Spec = crate::crd::TXTRecordSpec;
747    type Operation = TXTRecordOp;
748
749    fn get_spec(&self) -> &Self::Spec {
750        &self.spec
751    }
752
753    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
754        self.status.as_ref()
755    }
756
757    fn record_type_name() -> &'static str {
758        "TXT"
759    }
760
761    fn record_type_hickory() -> hickory_proto::rr::RecordType {
762        hickory_proto::rr::RecordType::TXT
763    }
764
765    fn create_operation(spec: &Self::Spec) -> Self::Operation {
766        TXTRecordOp {
767            texts: spec.text.clone(),
768        }
769    }
770
771    fn get_record_name(spec: &Self::Spec) -> &str {
772        &spec.name
773    }
774
775    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
776        spec.ttl
777    }
778}
779
780/// MX record operation wrapper.
781#[derive(Clone)]
782struct MXRecordOp {
783    priority: i32,
784    mail_server: String,
785}
786
787impl RecordOperation for MXRecordOp {
788    fn record_type_name(&self) -> &'static str {
789        "MX"
790    }
791
792    async fn add_to_bind9(
793        &self,
794        zone_manager: &crate::bind9::Bind9Manager,
795        zone_name: &str,
796        record_name: &str,
797        ttl: Option<i32>,
798        server: &str,
799        key_data: &crate::bind9::RndcKeyData,
800    ) -> Result<()> {
801        zone_manager
802            .add_mx_record(
803                zone_name,
804                record_name,
805                self.priority,
806                &self.mail_server,
807                ttl,
808                server,
809                key_data,
810            )
811            .await
812    }
813}
814
815/// Implement `ReconcilableRecord` for `MXRecord`.
816impl ReconcilableRecord for MXRecord {
817    type Spec = crate::crd::MXRecordSpec;
818    type Operation = MXRecordOp;
819
820    fn get_spec(&self) -> &Self::Spec {
821        &self.spec
822    }
823
824    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
825        self.status.as_ref()
826    }
827
828    fn record_type_name() -> &'static str {
829        "MX"
830    }
831
832    fn record_type_hickory() -> hickory_proto::rr::RecordType {
833        hickory_proto::rr::RecordType::MX
834    }
835
836    fn create_operation(spec: &Self::Spec) -> Self::Operation {
837        MXRecordOp {
838            priority: spec.priority,
839            mail_server: spec.mail_server.clone(),
840        }
841    }
842
843    fn get_record_name(spec: &Self::Spec) -> &str {
844        &spec.name
845    }
846
847    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
848        spec.ttl
849    }
850}
851
852/// NS record operation wrapper.
853#[derive(Clone)]
854struct NSRecordOp {
855    nameserver: String,
856}
857
858impl RecordOperation for NSRecordOp {
859    fn record_type_name(&self) -> &'static str {
860        "NS"
861    }
862
863    async fn add_to_bind9(
864        &self,
865        zone_manager: &crate::bind9::Bind9Manager,
866        zone_name: &str,
867        record_name: &str,
868        ttl: Option<i32>,
869        server: &str,
870        key_data: &crate::bind9::RndcKeyData,
871    ) -> Result<()> {
872        zone_manager
873            .add_ns_record(
874                zone_name,
875                record_name,
876                &self.nameserver,
877                ttl,
878                server,
879                key_data,
880            )
881            .await
882    }
883}
884
885/// Implement `ReconcilableRecord` for `NSRecord`.
886impl ReconcilableRecord for NSRecord {
887    type Spec = crate::crd::NSRecordSpec;
888    type Operation = NSRecordOp;
889
890    fn get_spec(&self) -> &Self::Spec {
891        &self.spec
892    }
893
894    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
895        self.status.as_ref()
896    }
897
898    fn record_type_name() -> &'static str {
899        "NS"
900    }
901
902    fn record_type_hickory() -> hickory_proto::rr::RecordType {
903        hickory_proto::rr::RecordType::NS
904    }
905
906    fn create_operation(spec: &Self::Spec) -> Self::Operation {
907        NSRecordOp {
908            nameserver: spec.nameserver.clone(),
909        }
910    }
911
912    fn get_record_name(spec: &Self::Spec) -> &str {
913        &spec.name
914    }
915
916    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
917        spec.ttl
918    }
919}
920
921/// SRV record operation wrapper.
922#[derive(Clone)]
923struct SRVRecordOp {
924    priority: i32,
925    weight: i32,
926    port: i32,
927    target: String,
928}
929
930impl RecordOperation for SRVRecordOp {
931    fn record_type_name(&self) -> &'static str {
932        "SRV"
933    }
934
935    async fn add_to_bind9(
936        &self,
937        zone_manager: &crate::bind9::Bind9Manager,
938        zone_name: &str,
939        record_name: &str,
940        ttl: Option<i32>,
941        server: &str,
942        key_data: &crate::bind9::RndcKeyData,
943    ) -> Result<()> {
944        let srv_data = crate::bind9::SRVRecordData {
945            priority: self.priority,
946            weight: self.weight,
947            port: self.port,
948            target: self.target.clone(),
949            ttl,
950        };
951        zone_manager
952            .add_srv_record(zone_name, record_name, &srv_data, server, key_data)
953            .await
954    }
955}
956
957/// Implement `ReconcilableRecord` for `SRVRecord`.
958impl ReconcilableRecord for SRVRecord {
959    type Spec = crate::crd::SRVRecordSpec;
960    type Operation = SRVRecordOp;
961
962    fn get_spec(&self) -> &Self::Spec {
963        &self.spec
964    }
965
966    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
967        self.status.as_ref()
968    }
969
970    fn record_type_name() -> &'static str {
971        "SRV"
972    }
973
974    fn record_type_hickory() -> hickory_proto::rr::RecordType {
975        hickory_proto::rr::RecordType::SRV
976    }
977
978    fn create_operation(spec: &Self::Spec) -> Self::Operation {
979        SRVRecordOp {
980            priority: spec.priority,
981            weight: spec.weight,
982            port: spec.port,
983            target: spec.target.clone(),
984        }
985    }
986
987    fn get_record_name(spec: &Self::Spec) -> &str {
988        &spec.name
989    }
990
991    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
992        spec.ttl
993    }
994}
995
996/// CAA record operation wrapper.
997#[derive(Clone)]
998struct CAARecordOp {
999    flags: i32,
1000    tag: String,
1001    value: String,
1002}
1003
1004impl RecordOperation for CAARecordOp {
1005    fn record_type_name(&self) -> &'static str {
1006        "CAA"
1007    }
1008
1009    async fn add_to_bind9(
1010        &self,
1011        zone_manager: &crate::bind9::Bind9Manager,
1012        zone_name: &str,
1013        record_name: &str,
1014        ttl: Option<i32>,
1015        server: &str,
1016        key_data: &crate::bind9::RndcKeyData,
1017    ) -> Result<()> {
1018        zone_manager
1019            .add_caa_record(
1020                zone_name,
1021                record_name,
1022                self.flags,
1023                &self.tag,
1024                &self.value,
1025                ttl,
1026                server,
1027                key_data,
1028            )
1029            .await
1030    }
1031}
1032
1033/// Implement `ReconcilableRecord` for `CAARecord`.
1034impl ReconcilableRecord for CAARecord {
1035    type Spec = crate::crd::CAARecordSpec;
1036    type Operation = CAARecordOp;
1037
1038    fn get_spec(&self) -> &Self::Spec {
1039        &self.spec
1040    }
1041
1042    fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
1043        self.status.as_ref()
1044    }
1045
1046    fn record_type_name() -> &'static str {
1047        "CAA"
1048    }
1049
1050    fn record_type_hickory() -> hickory_proto::rr::RecordType {
1051        hickory_proto::rr::RecordType::CAA
1052    }
1053
1054    fn create_operation(spec: &Self::Spec) -> Self::Operation {
1055        CAARecordOp {
1056            flags: spec.flags,
1057            tag: spec.tag.clone(),
1058            value: spec.value.clone(),
1059        }
1060    }
1061
1062    fn get_record_name(spec: &Self::Spec) -> &str {
1063        &spec.name
1064    }
1065
1066    fn get_ttl(spec: &Self::Spec) -> Option<i32> {
1067        spec.ttl
1068    }
1069}
1070
1071/// Generic record reconciliation function.
1072///
1073/// This function handles reconciliation for all DNS record types that implement
1074/// the `ReconcilableRecord` trait. It eliminates duplication across 8 record types
1075/// by providing a single implementation of the reconciliation logic.
1076///
1077/// The function:
1078/// 1. Checks if the record is selected by a `DNSZone` (via status.zoneRef)
1079/// 2. Looks up the `DNSZone` and gets primary instances
1080/// 3. Deletes the previously published name from BIND9 if `spec.name` changed
1081///    (rename detection via `status.publishedName`)
1082/// 4. Adds the record to BIND9 primaries using dynamic DNS updates
1083/// 5. Updates the record status based on success/failure; `status.addresses`
1084///    and `status.publishedName` are only set after a successful reconcile
1085///
1086/// # Type Parameters
1087///
1088/// * `T` - The record type (e.g., `ARecord`, `TXTRecord`) implementing `ReconcilableRecord`
1089///
1090/// # Arguments
1091///
1092/// * `ctx` - Operator context with Kubernetes client and reflector stores
1093/// * `record` - The DNS record resource to reconcile
1094///
1095/// # Returns
1096///
1097/// * `Ok(())` - If reconciliation succeeded or record is not selected
1098/// * `Err(_)` - If a fatal error occurred
1099///
1100/// # Errors
1101///
1102/// Returns an error if status updates fail or BIND9 record creation fails.
1103async fn reconcile_record<T>(ctx: std::sync::Arc<crate::context::Context>, record: T) -> Result<()>
1104where
1105    T: ReconcilableRecord,
1106{
1107    let client = ctx.client.clone();
1108    let bind9_instances_store = &ctx.stores.bind9_instances;
1109    let namespace = record.namespace().unwrap_or_default();
1110    let name = record.name_any();
1111
1112    info!(
1113        "Reconciling {}Record: {}/{}",
1114        T::record_type_name(),
1115        namespace,
1116        name
1117    );
1118
1119    let spec = record.get_spec();
1120    let current_generation = record.meta().generation;
1121
1122    // Use generic helper to get zone and instances
1123    let Some(rec_ctx) = prepare_record_reconciliation(
1124        &client,
1125        &record,
1126        T::record_type_name(),
1127        spec,
1128        bind9_instances_store,
1129    )
1130    .await?
1131    else {
1132        return Ok(()); // Record not selected or status already updated
1133    };
1134
1135    // Handle renames: if the record was previously published under a different
1136    // DNS name (status.publishedName), delete the old FQDN from the zone first.
1137    // Otherwise the old name would remain orphaned in BIND9 forever.
1138    if let Some(old_name) = detect_renamed_record(record.get_status(), T::get_record_name(spec)) {
1139        info!(
1140            "{} record {}/{} renamed from '{}' to '{}' - deleting old name from zone {}",
1141            T::record_type_name(),
1142            namespace,
1143            name,
1144            old_name,
1145            T::get_record_name(spec),
1146            rec_ctx.zone_ref.zone_name
1147        );
1148
1149        if let Err(e) = delete_record_from_primaries(
1150            &client,
1151            &ctx.stores,
1152            &rec_ctx.primary_refs,
1153            &rec_ctx.zone_ref.zone_name,
1154            &old_name,
1155            T::record_type_hickory(),
1156            true, // fail_on_error: do not publish the new name until the old one is gone
1157        )
1158        .await
1159        {
1160            warn!(
1161                "Failed to delete renamed {} record '{}' from zone {}: {}",
1162                T::record_type_name(),
1163                old_name,
1164                rec_ctx.zone_ref.zone_name,
1165                e
1166            );
1167            update_record_status(
1168                &client,
1169                &record,
1170                "Ready",
1171                "False",
1172                "ReconcileFailed",
1173                &format!("Failed to delete renamed record '{old_name}' from zone: {e}"),
1174                current_generation,
1175                None, // record_hash
1176                None, // last_updated
1177                None, // addresses
1178                None, // published_name (preserve old name so deletion is retried)
1179            )
1180            .await?;
1181            return Ok(());
1182        }
1183    }
1184
1185    // Create type-specific operation from spec
1186    let record_op = T::create_operation(spec);
1187
1188    // Add record to BIND9 primaries using generic helper
1189    match add_record_to_instances_generic(
1190        &client,
1191        &ctx.stores,
1192        &rec_ctx.primary_refs,
1193        &rec_ctx.zone_ref.zone_name,
1194        T::get_record_name(spec),
1195        T::get_ttl(spec),
1196        record_op,
1197    )
1198    .await
1199    {
1200        Ok(()) => {
1201            info!(
1202                "Successfully added {} record {}.{} via {} primary instance(s)",
1203                T::record_type_name(),
1204                T::get_record_name(spec),
1205                rec_ctx.zone_ref.zone_name,
1206                rec_ctx.primary_refs.len()
1207            );
1208
1209            // Update lastReconciledAt timestamp in DNSZone.status.records[]
1210            update_record_reconciled_timestamp(
1211                &client,
1212                &rec_ctx.zone_ref.namespace,
1213                &rec_ctx.zone_ref.name,
1214                &format!("{}Record", T::record_type_name()),
1215                &name,
1216                &namespace,
1217            )
1218            .await?;
1219
1220            // Update record status to Ready. Addresses (A/AAAA display field) and
1221            // publishedName are only set after a successful, selected reconcile.
1222            update_record_status(
1223                &client,
1224                &record,
1225                "Ready",
1226                "True",
1227                "ReconcileSucceeded",
1228                &format!(
1229                    "{} record added to zone {}",
1230                    T::record_type_name(),
1231                    rec_ctx.zone_ref.zone_name
1232                ),
1233                current_generation,
1234                Some(rec_ctx.current_hash),
1235                Some(chrono::Utc::now().to_rfc3339()),
1236                T::get_display_addresses(spec),
1237                Some(T::get_record_name(spec).to_string()),
1238            )
1239            .await?;
1240        }
1241        Err(e) => {
1242            warn!(
1243                "Failed to add {} record {}.{}: {}",
1244                T::record_type_name(),
1245                T::get_record_name(spec),
1246                rec_ctx.zone_ref.zone_name,
1247                e
1248            );
1249            update_record_status(
1250                &client,
1251                &record,
1252                "Ready",
1253                "False",
1254                "ReconcileFailed",
1255                &format!("Failed to add record to zone: {e}"),
1256                current_generation,
1257                None, // record_hash
1258                None, // last_updated
1259                None, // addresses
1260                None, // published_name
1261            )
1262            .await?;
1263        }
1264    }
1265
1266    Ok(())
1267}
1268
1269/// Detects whether a record was renamed since it was last published to BIND9.
1270///
1271/// Compares the record's `status.publishedName` (the DNS name most recently
1272/// written to BIND9) against the current `spec.name`.
1273///
1274/// # Arguments
1275///
1276/// * `status` - The record's current status, if any
1277/// * `current_name` - The record name from the current spec
1278///
1279/// # Returns
1280///
1281/// * `Some(old_name)` - The record was renamed; `old_name` must be deleted from DNS
1282/// * `None` - No rename occurred (never published, or name unchanged)
1283pub(crate) fn detect_renamed_record(
1284    status: Option<&crate::crd::RecordStatus>,
1285    current_name: &str,
1286) -> Option<String> {
1287    let published = status?.published_name.as_deref()?;
1288    if published == current_name {
1289        return None;
1290    }
1291    Some(published.to_string())
1292}
1293
1294/// Reconciles an `ARecord` (IPv4 address) resource.
1295///
1296/// This is a thin wrapper around the generic `reconcile_record<T>()` function.
1297/// It finds `DNSZones` that have selected this record via label selectors and
1298/// creates/updates the record in BIND9 primaries for those zones using dynamic DNS updates.
1299///
1300/// `status.addresses` (comma-separated IPv4 addresses for kubectl output) is only
1301/// published after a successful, selected reconcile.
1302///
1303/// # Errors
1304///
1305/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1306pub async fn reconcile_a_record(
1307    ctx: std::sync::Arc<crate::context::Context>,
1308    record: ARecord,
1309) -> Result<()> {
1310    reconcile_record(ctx, record).await
1311}
1312
1313/// Reconciles a `TXTRecord` (text) resource.
1314///
1315/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1316/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1317/// Commonly used for SPF, DKIM, DMARC, and domain verification.
1318///
1319/// # Errors
1320///
1321/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1322pub async fn reconcile_txt_record(
1323    ctx: std::sync::Arc<crate::context::Context>,
1324    record: TXTRecord,
1325) -> Result<()> {
1326    reconcile_record(ctx, record).await
1327}
1328
1329/// Reconciles an `AAAARecord` (IPv6 address) resource.
1330///
1331/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1332/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1333///
1334/// # Errors
1335///
1336/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1337pub async fn reconcile_aaaa_record(
1338    ctx: std::sync::Arc<crate::context::Context>,
1339    record: AAAARecord,
1340) -> Result<()> {
1341    reconcile_record(ctx, record).await
1342}
1343
1344/// Reconciles a `CNAMERecord` \(canonical name alias\) resource.
1345///
1346/// This is a thin wrapper around the generic `reconcile_record<T>()` function.
1347/// It finds `DNSZones` that have selected this record via label selectors and
1348/// creates/updates the record in BIND9 primaries for those zones using dynamic DNS updates.
1349///
1350/// # Errors
1351///
1352/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1353pub async fn reconcile_cname_record(
1354    ctx: std::sync::Arc<crate::context::Context>,
1355    record: CNAMERecord,
1356) -> Result<()> {
1357    reconcile_record(ctx, record).await
1358}
1359
1360/// Reconciles an `MXRecord` (mail exchange) resource.
1361///
1362/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1363/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1364/// MX records specify mail servers for email delivery.
1365///
1366/// # Errors
1367///
1368/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1369pub async fn reconcile_mx_record(
1370    ctx: std::sync::Arc<crate::context::Context>,
1371    record: MXRecord,
1372) -> Result<()> {
1373    reconcile_record(ctx, record).await
1374}
1375
1376/// Reconciles an `NSRecord` (nameserver delegation) resource.
1377///
1378/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1379/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1380/// NS records delegate a subdomain to different nameservers.
1381///
1382/// # Errors
1383///
1384/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1385pub async fn reconcile_ns_record(
1386    ctx: std::sync::Arc<crate::context::Context>,
1387    record: NSRecord,
1388) -> Result<()> {
1389    reconcile_record(ctx, record).await
1390}
1391
1392/// Reconciles an `SRVRecord` (service location) resource.
1393///
1394/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1395/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1396/// SRV records specify the location of services (e.g., _ldap._tcp).
1397///
1398/// # Errors
1399///
1400/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1401pub async fn reconcile_srv_record(
1402    ctx: std::sync::Arc<crate::context::Context>,
1403    record: SRVRecord,
1404) -> Result<()> {
1405    reconcile_record(ctx, record).await
1406}
1407
1408/// Reconciles a `CAARecord` \(certificate authority authorization\) resource.
1409///
1410/// This is a thin wrapper around the generic `reconcile_record<T>()` function.
1411/// It finds `DNSZones` that have selected this record via label selectors and
1412/// creates/updates the record in BIND9 primaries for those zones using dynamic DNS updates.
1413/// CAA records specify which certificate authorities can issue certificates.
1414///
1415/// # Errors
1416///
1417/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1418pub async fn reconcile_caa_record(
1419    ctx: std::sync::Arc<crate::context::Context>,
1420    record: CAARecord,
1421) -> Result<()> {
1422    reconcile_record(ctx, record).await
1423}
1424
1425/// Generic function to delete a DNS record from BIND9 primaries.
1426///
1427/// This function handles deletion of any record type using the generic approach:
1428/// 1. Gets the zone reference from the record's status
1429/// 2. Looks up the `DNSZone` to get instances
1430/// 3. Filters to primary instances
1431/// 4. Deletes the record from all primaries (best-effort), using
1432///    `status.publishedName` when present so renamed records delete the
1433///    name actually published to DNS, falling back to `spec.name`
1434///
1435/// # Arguments
1436///
1437/// * `client` - Kubernetes API client
1438/// * `record` - The DNS record resource being deleted
1439/// * `record_type` - Human-readable record type (e.g., "A", "TXT")
1440/// * `record_type_hickory` - hickory-client `RecordType` enum value
1441/// * `stores` - Reflector stores containing `DNSZones` and instances
1442///
1443/// # Returns
1444///
1445/// Returns `Ok(())` if deletion succeeded (or if record didn't exist).
1446///
1447/// # Errors
1448///
1449/// Returns an error if instance lookup fails or DNS deletion fails critically.
1450///
1451/// # Panics
1452///
1453/// Panics if RNDC key is not found for an instance (should never happen in production).
1454#[allow(clippy::too_many_lines)]
1455pub async fn delete_record<T>(
1456    client: &Client,
1457    record: &T,
1458    record_type: &str,
1459    record_type_hickory: hickory_proto::rr::RecordType,
1460    stores: &crate::context::Stores,
1461) -> Result<()>
1462where
1463    T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
1464        + ResourceExt
1465        + Clone
1466        + std::fmt::Debug
1467        + serde::Serialize
1468        + for<'de> serde::Deserialize<'de>,
1469{
1470    let namespace = record.namespace().unwrap_or_default();
1471    let name = record.name_any();
1472
1473    info!("Deleting {} record: {}/{}", record_type, namespace, name);
1474
1475    // Extract status fields generically
1476    let record_json = serde_json::to_value(record).ok();
1477    let status = record_json.as_ref().and_then(|v| v.get("status").cloned());
1478
1479    let zone_ref = status
1480        .as_ref()
1481        .and_then(|s| s.get("zoneRef"))
1482        .cloned()
1483        .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z).ok());
1484
1485    // If no zone ref, record was never added to DNS (or already cleaned up)
1486    let Some(zone_ref) = zone_ref else {
1487        info!(
1488            "{} record {}/{} has no zoneRef - was never added to DNS or already cleaned up",
1489            record_type, namespace, name
1490        );
1491        return Ok(());
1492    };
1493
1494    // Get the DNSZone
1495    let dnszone = match get_zone_from_ref(client, &zone_ref).await {
1496        Ok(zone) => zone,
1497        Err(e) => {
1498            warn!(
1499                "DNSZone {}/{} not found for {} record {}/{}: {}. Allowing deletion anyway.",
1500                zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1501            );
1502            return Ok(());
1503        }
1504    };
1505
1506    // Get instances from DNSZone
1507    let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
1508        &dnszone,
1509        &stores.bind9_instances,
1510    ) {
1511        Ok(refs) => refs,
1512        Err(e) => {
1513            warn!(
1514                "DNSZone {}/{} has no instances for {} record {}/{}: {}. Allowing deletion anyway.",
1515                zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1516            );
1517            return Ok(());
1518        }
1519    };
1520
1521    // Filter to primary instances
1522    let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
1523        client,
1524        &instance_refs,
1525    )
1526    .await
1527    {
1528        Ok(refs) => refs,
1529        Err(e) => {
1530            warn!(
1531                    "Failed to filter primary instances for {} record {}/{}: {}. Allowing deletion anyway.",
1532                    record_type, namespace, name, e
1533                );
1534            return Ok(());
1535        }
1536    };
1537
1538    if primary_refs.is_empty() {
1539        warn!(
1540            "No primary instances found for {} record {}/{}. Allowing deletion anyway.",
1541            record_type, namespace, name
1542        );
1543        return Ok(());
1544    }
1545
1546    // Determine the DNS name actually published to BIND9. Prefer
1547    // status.publishedName (handles renames), then spec.name, then the
1548    // resource name as a last resort.
1549    let record_name_str = status
1550        .as_ref()
1551        .and_then(|s| s.get("publishedName"))
1552        .and_then(|p| p.as_str())
1553        .map(ToString::to_string)
1554        .or_else(|| {
1555            record_json
1556                .as_ref()
1557                .and_then(|v| v.get("spec"))
1558                .and_then(|s| s.get("name"))
1559                .and_then(|n| n.as_str())
1560                .map(ToString::to_string)
1561        })
1562        .unwrap_or_else(|| name.clone());
1563
1564    // Delete record from all primaries (best-effort: finalizer removal must
1565    // not be blocked by unreachable endpoints)
1566    delete_record_from_primaries(
1567        client,
1568        stores,
1569        &primary_refs,
1570        &zone_ref.zone_name,
1571        &record_name_str,
1572        record_type_hickory,
1573        false, // fail_on_error: allow Kubernetes deletion to proceed
1574    )
1575    .await?;
1576
1577    info!(
1578        "Successfully deleted {} record {}/{} from {} primary instance(s)",
1579        record_type,
1580        namespace,
1581        name,
1582        primary_refs.len()
1583    );
1584
1585    Ok(())
1586}
1587
1588/// Deletes a DNS record (by name and type) from all given primary instances.
1589///
1590/// Shared by the record finalizer (`delete_record`), the rename cleanup in
1591/// `reconcile_record`, and the `DNSZone` controller when a record is no longer
1592/// selected by the zone's `recordsFrom` selectors.
1593///
1594/// # Arguments
1595///
1596/// * `client` - Kubernetes API client
1597/// * `stores` - Context stores for creating `Bind9Manager` instances
1598/// * `primary_refs` - Primary instance references to delete the record from
1599/// * `zone_name` - DNS zone name (e.g., "example.com")
1600/// * `record_name` - Record name within the zone (e.g., "www")
1601/// * `record_type_hickory` - hickory-proto `RecordType` of the record
1602/// * `fail_on_error` - When `true`, a failed DNS deletion on any endpoint fails
1603///   the call (used when the record data must be gone before proceeding).
1604///   When `false`, failures are logged and skipped (best-effort finalizer cleanup).
1605///
1606/// # Errors
1607///
1608/// Returns an error if endpoint resolution fails, or if a DNS deletion fails
1609/// and `fail_on_error` is `true`.
1610pub(crate) async fn delete_record_from_primaries(
1611    client: &Client,
1612    stores: &crate::context::Stores,
1613    primary_refs: &[crate::crd::InstanceReference],
1614    zone_name: &str,
1615    record_name: &str,
1616    record_type_hickory: hickory_proto::rr::RecordType,
1617    fail_on_error: bool,
1618) -> Result<()> {
1619    // Create a map of instance name -> namespace for quick lookup
1620    let instance_map: std::collections::HashMap<String, String> = primary_refs
1621        .iter()
1622        .map(|inst| (inst.name.clone(), inst.namespace.clone()))
1623        .collect();
1624
1625    // Collect per-endpoint failures ourselves: for_each_instance_endpoint only
1626    // fails when ALL endpoints fail, but with fail_on_error we must also fail
1627    // on PARTIAL failures (a record left on any endpoint is still an orphan).
1628    // The closure always returns Ok so every endpoint is attempted.
1629    let failures: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
1630        std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1631
1632    // Best-effort finalizer cleanup (fail_on_error=false) must not be blocked
1633    // forever by an instance whose RNDC Secret is gone or that has zero ready
1634    // endpoints - the DNS data there is unreachable anyway. Strict callers
1635    // (fail_on_error=true) keep propagating those lookup failures.
1636    let failure_policy = if fail_on_error {
1637        crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::Strict
1638    } else {
1639        crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::SkipUnavailable
1640    };
1641
1642    let (_first_endpoint, _total_endpoints) =
1643        crate::reconcilers::dnszone::helpers::for_each_instance_endpoint_with_policy(
1644            client,
1645            primary_refs,
1646            true,      // with_rndc_key
1647            "dns-tcp", // Use DNS TCP port for dynamic updates
1648            failure_policy,
1649            |pod_endpoint, instance_name, rndc_key| {
1650                let zone_name = zone_name.to_string();
1651                let record_name_str = record_name.to_string();
1652                let instance_namespace = instance_map
1653                    .get(&instance_name)
1654                    .expect("Instance should be in map")
1655                    .clone();
1656                let failures = std::sync::Arc::clone(&failures);
1657
1658                // Create Bind9Manager for this specific instance with deployment-aware auth
1659                let zone_manager =
1660                    stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
1661
1662                async move {
1663                    let key_data = rndc_key.expect("RNDC key should be loaded");
1664
1665                    let delete_result = zone_manager
1666                        .delete_record(
1667                            &zone_name,
1668                            &record_name_str,
1669                            record_type_hickory,
1670                            &pod_endpoint,
1671                            &key_data,
1672                        )
1673                        .await;
1674
1675                    match delete_result {
1676                        Ok(()) => {
1677                            info!(
1678                                "Successfully deleted {} record {}.{} from endpoint {} (instance: {})",
1679                                record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name
1680                            );
1681                        }
1682                        Err(e) => {
1683                            warn!(
1684                                "Failed to delete {} record {}.{} from endpoint {} (instance: {}): {}",
1685                                record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name, e
1686                            );
1687                            failures
1688                                .lock()
1689                                .expect("delete failures mutex should not be poisoned")
1690                                .push(format!(
1691                                    "endpoint {pod_endpoint} (instance: {instance_name}): {e}"
1692                                ));
1693                        }
1694                    }
1695
1696                    Ok(())
1697                }
1698            },
1699        )
1700        .await?;
1701
1702    let failures = failures
1703        .lock()
1704        .expect("delete failures mutex should not be poisoned");
1705
1706    if fail_on_error && !failures.is_empty() {
1707        return Err(anyhow::anyhow!(
1708            "Failed to delete {} record {}.{} from {} endpoint(s): {}",
1709            record_type_hickory,
1710            record_name,
1711            zone_name,
1712            failures.len(),
1713            failures.join("; ")
1714        ));
1715    }
1716
1717    if !failures.is_empty() {
1718        warn!(
1719            "Failed to delete {} record {}.{} from {} endpoint(s); continuing anyway (best-effort)",
1720            record_type_hickory,
1721            record_name,
1722            zone_name,
1723            failures.len()
1724        );
1725    }
1726
1727    Ok(())
1728}
1729
1730/// Builds the merge patch that updates `DNSZone.status.records[]`.
1731///
1732/// The `DNSZoneStatus` field is named `records` on the wire (camelCase of
1733/// `pub records`). Using any other key (e.g., the old `selectedRecords`) is
1734/// silently pruned by the CRD structural schema, so timestamps never persist.
1735#[must_use]
1736pub(crate) fn build_records_timestamp_patch(
1737    records: &[crate::crd::RecordReferenceWithTimestamp],
1738) -> serde_json::Value {
1739    json!({
1740        "status": {
1741            "records": records
1742        }
1743    })
1744}
1745
1746/// Update lastReconciledAt timestamp for a record in `DNSZone.status.records[]`.
1747///
1748/// This signals that the record has been successfully configured in BIND9.
1749/// Future reconciliations will skip this record until the timestamp is reset.
1750///
1751/// # Arguments
1752///
1753/// * `client` - Kubernetes API client
1754/// * `zone_namespace` - Namespace of the `DNSZone`
1755/// * `zone_name` - Name of the `DNSZone`
1756/// * `record_kind` - Kind of the record (e.g., "`ARecord`", "`CNAMERecord`")
1757/// * `record_name` - Name of the record resource
1758/// * `record_namespace` - Namespace of the record resource
1759///
1760/// # Errors
1761///
1762/// Returns an error if:
1763/// - `DNSZone` cannot be fetched from Kubernetes API
1764/// - Status patch operation fails
1765pub async fn update_record_reconciled_timestamp(
1766    client: &Client,
1767    zone_namespace: &str,
1768    zone_name: &str,
1769    record_kind: &str,
1770    record_name: &str,
1771    record_namespace: &str,
1772) -> Result<()> {
1773    let api: Api<DNSZone> = Api::namespaced(client.clone(), zone_namespace);
1774
1775    // Re-fetch zone to get latest status
1776    let mut zone = api.get(zone_name).await?;
1777
1778    // Find the record reference and update its timestamp
1779    let mut found = false;
1780    if let Some(status) = &mut zone.status {
1781        for record_ref in &mut status.records {
1782            if record_ref.kind == record_kind
1783                && record_ref.name == record_name
1784                && record_ref.namespace == record_namespace
1785            {
1786                record_ref.last_reconciled_at = Some(Time(k8s_openapi::jiff::Timestamp::now()));
1787                found = true;
1788                break;
1789            }
1790        }
1791    }
1792
1793    if !found {
1794        warn!(
1795            "Record {} {}/{} not found in DNSZone {}/{} status.records[] - cannot update timestamp",
1796            record_kind, record_namespace, record_name, zone_namespace, zone_name
1797        );
1798        return Ok(());
1799    }
1800
1801    // Patch the status with updated timestamp (key MUST be `records` - see
1802    // build_records_timestamp_patch)
1803    let status_patch = zone
1804        .status
1805        .as_ref()
1806        .map(|s| build_records_timestamp_patch(&s.records))
1807        .unwrap_or_else(|| build_records_timestamp_patch(&[]));
1808
1809    api.patch_status(
1810        zone_name,
1811        &PatchParams::default(),
1812        &Patch::Merge(status_patch),
1813    )
1814    .await?;
1815
1816    info!(
1817        "Updated lastReconciledAt for {} record {}/{} in zone {}/{}",
1818        record_kind, record_namespace, record_name, zone_namespace, zone_name
1819    );
1820
1821    Ok(())
1822}