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