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