bindy/reconcilers/
records.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//! **IMPORTANT**: With the zone ownership model, DNS record reconcilers:
9//! 1. Read the `bindy.firestoned.io/zone` annotation set by the `DNSZone` controller
10//! 2. If annotation is present, create/update the record in BIND9 for that zone
11//! 3. If annotation is absent, the record is not selected by any zone (skip reconciliation)
12//! 4. Update the record's status to reflect success or failure
13
14use crate::constants::ANNOTATION_ZONE_OWNER;
15use crate::crd::{
16    AAAARecord, ARecord, CAARecord, CNAMERecord, Condition, DNSZone, MXRecord, NSRecord,
17    RecordStatus, SRVRecord, TXTRecord,
18};
19use anyhow::{anyhow, Context, Result};
20use k8s_openapi::api::core::v1::{Event, ObjectReference};
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
22use k8s_openapi::chrono::Utc;
23use kube::{
24    api::{ListParams, Patch, PatchParams, PostParams},
25    client::Client,
26    Api, Resource, ResourceExt,
27};
28use serde_json::json;
29use tracing::{debug, info, warn};
30
31/// Retrieves the zone FQDN from the record's `bindy.firestoned.io/zone` annotation.
32///
33/// This annotation is set by the `DNSZone` controller when a zone's label selector
34/// matches this record. The record reconciler uses this to determine which zone
35/// to update in BIND9.
36///
37/// # Arguments
38///
39/// * `record` - The DNS record resource
40///
41/// # Returns
42///
43/// * `Some(zone_fqdn)` - If the annotation is present and non-empty
44/// * `None` - If the annotation is missing or empty (record not selected by any zone)
45fn get_zone_from_annotation<T: ResourceExt>(record: &T) -> Option<String> {
46    record
47        .annotations()
48        .get(ANNOTATION_ZONE_OWNER)
49        .filter(|zone| !zone.is_empty())
50        .cloned()
51}
52
53/// Gets zone information from the annotation and looks up the `DNSZone` resource.
54///
55/// This function reads the `bindy.firestoned.io/zone` annotation set by the `DNSZone`
56/// controller, then queries the Kubernetes API to get the zone's cluster reference.
57///
58/// # Arguments
59///
60/// * `client` - Kubernetes API client
61/// * `namespace` - Namespace of the record
62/// * `zone_fqdn` - Zone FQDN from the annotation
63///
64/// # Returns
65///
66/// A tuple of (`zone_name`, `cluster_ref`, `is_cluster_provider`)
67///
68/// # Errors
69///
70/// Returns an error if the `DNSZone` resource cannot be found or queried.
71async fn get_zone_info(
72    client: &Client,
73    namespace: &str,
74    zone_fqdn: &str,
75) -> Result<(String, String, bool)> {
76    let dns_zones_api: Api<DNSZone> = Api::namespaced(client.clone(), namespace);
77
78    // List all DNSZones and find the one with matching zoneName
79    let zones = dns_zones_api.list(&ListParams::default()).await?;
80
81    for zone in zones {
82        if zone.spec.zone_name == zone_fqdn {
83            // Determine cluster reference
84            let (cluster_ref, is_cluster_provider) =
85                if let Some(ref cluster) = zone.spec.cluster_ref {
86                    (cluster.clone(), false)
87                } else if let Some(ref provider) = zone.spec.cluster_provider_ref {
88                    (provider.clone(), true)
89                } else {
90                    return Err(anyhow!(
91                        "DNSZone {}/{} has neither clusterRef nor clusterProviderRef",
92                        namespace,
93                        zone.name_any()
94                    ));
95                };
96
97            return Ok((zone_fqdn.to_string(), cluster_ref, is_cluster_provider));
98        }
99    }
100
101    Err(anyhow!(
102        "DNSZone with zoneName '{zone_fqdn}' not found in namespace '{namespace}'"
103    ))
104}
105
106/// Reconciles an `ARecord` (IPv4 address) resource.
107///
108/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
109/// the record in BIND9 primaries for those zones using dynamic DNS updates.
110///
111/// # Arguments
112///
113/// * `client` - Kubernetes API client
114/// * `record` - The `ARecord` resource to reconcile
115///
116/// # Example
117///
118/// ```rust,no_run
119/// use bindy::reconcilers::reconcile_a_record;
120/// use bindy::crd::ARecord;
121/// use kube::Client;
122///
123/// async fn handle_a_record(record: ARecord) -> anyhow::Result<()> {
124///     let client = Client::try_default().await?;
125///     reconcile_a_record(client, record).await?;
126///     Ok(())
127/// }
128/// ```
129///
130/// # Errors
131///
132/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
133#[allow(clippy::too_many_lines)]
134pub async fn reconcile_a_record(client: Client, record: ARecord) -> Result<()> {
135    let namespace = record.namespace().unwrap_or_default();
136    let name = record.name_any();
137
138    info!("Reconciling ARecord: {}/{}", namespace, name);
139
140    let spec = &record.spec;
141    let current_generation = record.metadata.generation;
142    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
143
144    // Get zone from annotation (set by DNSZone controller)
145    // Check this FIRST before generation check, because the annotation may have been
146    // added after the record was created (by DNSZone controller)
147    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
148        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
149        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
150            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
151            return Ok(());
152        }
153
154        info!(
155            "A record {}/{} not selected by any DNSZone (no zone annotation)",
156            namespace, name
157        );
158        update_record_status(
159            &client,
160            &record,
161            "Ready",
162            "False",
163            "NotSelected",
164            "Record not selected by any DNSZone label selector",
165            current_generation,
166        )
167        .await?;
168        return Ok(());
169    };
170
171    // Always reconcile to ensure declarative state - records are recreated if pods restart
172    // The underlying add_*_record() functions are idempotent and check for existence first
173    debug!(
174        "Ensuring A record exists in zone {} (declarative reconciliation)",
175        zone_fqdn
176    );
177
178    // Get zone information from Kubernetes
179    let (zone_name, cluster_ref, is_cluster_provider) =
180        match get_zone_info(&client, &namespace, &zone_fqdn).await {
181            Ok(info) => info,
182            Err(e) => {
183                warn!(
184                    "Failed to find DNSZone for {} in {}/{}: {}",
185                    zone_fqdn, namespace, name, e
186                );
187                update_record_status(
188                    &client,
189                    &record,
190                    "Ready",
191                    "False",
192                    "ZoneNotFound",
193                    &format!("DNSZone '{zone_fqdn}' not found: {e}"),
194                    current_generation,
195                )
196                .await?;
197                return Ok(());
198            }
199        };
200
201    // Create/update record in BIND9 for the zone
202    let zone_manager = crate::bind9::Bind9Manager::new();
203
204    match add_a_record_to_zone(
205        &client,
206        &namespace,
207        &zone_name,
208        &cluster_ref,
209        is_cluster_provider,
210        &spec.name,
211        &spec.ipv4_address,
212        spec.ttl,
213        &zone_manager,
214    )
215    .await
216    {
217        Ok(()) => {
218            info!(
219                "Successfully added A record {} to zone {} in cluster {}",
220                spec.name, zone_name, cluster_ref
221            );
222            update_record_status(
223                &client,
224                &record,
225                "Ready",
226                "True",
227                "ReconcileSucceeded",
228                &format!("A record added to zone {zone_name}"),
229                current_generation,
230            )
231            .await?;
232        }
233        Err(e) => {
234            warn!(
235                "Failed to add A record {} to zone {}: {}",
236                spec.name, zone_name, e
237            );
238            update_record_status(
239                &client,
240                &record,
241                "Ready",
242                "False",
243                "ReconcileFailed",
244                &format!("Failed to add record to zone {zone_name}: {e}"),
245                current_generation,
246            )
247            .await?;
248        }
249    }
250
251    Ok(())
252}
253
254/// Add an A record to a specific zone in BIND9 primaries.
255///
256/// Uses dynamic DNS updates (nsupdate protocol via DNS TCP port 53) to add the record
257/// to all primary endpoints for the specified zone.
258///
259/// # Arguments
260///
261/// * `client` - Kubernetes API client
262/// * `namespace` - Namespace of the zone
263/// * `zone_name` - DNS zone name
264/// * `cluster_ref` - Name of the `Bind9Cluster` or `ClusterBind9Provider`
265/// * `is_cluster_provider` - Whether the cluster is a `ClusterBind9Provider`
266/// * `record_name` - Name portion of the DNS record
267/// * `ipv4_address` - IPv4 address for the record
268/// * `ttl` - Optional TTL value
269/// * `zone_manager` - BIND9 manager instance
270///
271/// # Errors
272///
273/// Returns an error if the BIND9 record creation fails.
274#[allow(clippy::too_many_arguments)]
275async fn add_a_record_to_zone(
276    client: &Client,
277    namespace: &str,
278    zone_name: &str,
279    cluster_ref: &str,
280    is_cluster_provider: bool,
281    record_name: &str,
282    ipv4_address: &str,
283    ttl: Option<i32>,
284    zone_manager: &crate::bind9::Bind9Manager,
285) -> Result<()> {
286    use crate::reconcilers::dnszone::for_each_primary_endpoint;
287
288    let (_first, _total) = for_each_primary_endpoint(
289        client,
290        namespace,
291        cluster_ref,
292        is_cluster_provider,
293        true,      // with_rndc_key
294        "dns-tcp", // Use DNS TCP port for dynamic updates
295        |pod_endpoint, instance_name, rndc_key| {
296            let zone_name = zone_name.to_string();
297            let record_name = record_name.to_string();
298            let ipv4_address = ipv4_address.to_string();
299            let zone_manager = zone_manager.clone();
300
301            async move {
302                let key_data = rndc_key.expect("RNDC key should be loaded");
303
304                zone_manager
305                    .add_a_record(
306                        &zone_name,
307                        &record_name,
308                        &ipv4_address,
309                        ttl,
310                        &pod_endpoint,
311                        &key_data,
312                    )
313                    .await
314                    .context(format!(
315                        "Failed to add A record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
316                    ))?;
317
318                Ok(())
319            }
320        },
321    )
322    .await?;
323
324    Ok(())
325}
326
327/// Reconciles a `TXTRecord` (text) resource.
328///
329/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
330/// the record in BIND9 primaries for those zones using dynamic DNS updates.
331/// Commonly used for SPF, DKIM, DMARC, and domain verification.
332///
333/// # Errors
334///
335/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
336#[allow(clippy::too_many_lines)]
337pub async fn reconcile_txt_record(client: Client, record: TXTRecord) -> Result<()> {
338    let namespace = record.namespace().unwrap_or_default();
339    let name = record.name_any();
340
341    info!("Reconciling TXTRecord: {}/{}", namespace, name);
342
343    let spec = &record.spec;
344    let current_generation = record.metadata.generation;
345    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
346
347    // Get zone from annotation (set by DNSZone controller)
348    // Check this FIRST before generation check, because the annotation may have been
349    // added after the record was created (by DNSZone controller)
350    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
351        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
352        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
353            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
354            return Ok(());
355        }
356
357        info!(
358            "TXT record {}/{} not selected by any DNSZone",
359            namespace, name
360        );
361        update_record_status(
362            &client,
363            &record,
364            "Ready",
365            "False",
366            "NotSelected",
367            "Record not selected by any DNSZone label selector",
368            current_generation,
369        )
370        .await?;
371        return Ok(());
372    };
373
374    // Always reconcile to ensure declarative state - records are recreated if pods restart
375    // The underlying add_*_record() functions are idempotent and check for existence first
376    debug!(
377        "Ensuring record exists in zone {} (declarative reconciliation)",
378        zone_fqdn
379    );
380
381    // Get zone info from DNSZone resource
382    let (zone_name, cluster_ref, is_cluster_provider) =
383        match get_zone_info(&client, &namespace, &zone_fqdn).await {
384            Ok(info) => info,
385            Err(e) => {
386                warn!(
387                    "Failed to find DNSZone {} for TXT record {}/{}: {}",
388                    zone_fqdn, namespace, name, e
389                );
390                update_record_status(
391                    &client,
392                    &record,
393                    "Ready",
394                    "False",
395                    "ZoneNotFound",
396                    &format!("DNSZone {zone_fqdn} not found: {e}"),
397                    current_generation,
398                )
399                .await?;
400                return Ok(());
401            }
402        };
403
404    // Create/update record in BIND9
405    let zone_manager = crate::bind9::Bind9Manager::new();
406
407    match add_txt_record_to_zone(
408        &client,
409        &namespace,
410        &zone_name,
411        &cluster_ref,
412        is_cluster_provider,
413        &spec.name,
414        &spec.text,
415        spec.ttl,
416        &zone_manager,
417    )
418    .await
419    {
420        Ok(()) => {
421            info!(
422                "Successfully added TXT record {} to zone {} in cluster {}",
423                spec.name, zone_name, cluster_ref
424            );
425            update_record_status(
426                &client,
427                &record,
428                "Ready",
429                "True",
430                "RecordAvailable",
431                &format!(
432                    "TXT record {} successfully added to zone {zone_name}",
433                    spec.name
434                ),
435                current_generation,
436            )
437            .await?;
438        }
439        Err(e) => {
440            warn!(
441                "Failed to add TXT record {} to zone {}: {}",
442                spec.name, zone_name, e
443            );
444            update_record_status(
445                &client,
446                &record,
447                "Ready",
448                "False",
449                "ReconcileFailed",
450                &format!("Failed to add TXT record to zone {zone_name}: {e}"),
451                current_generation,
452            )
453            .await?;
454        }
455    }
456
457    Ok(())
458}
459
460/// Add a TXT record to a specific zone in BIND9 primaries.
461#[allow(clippy::too_many_arguments)]
462async fn add_txt_record_to_zone(
463    client: &Client,
464    namespace: &str,
465    zone_name: &str,
466    cluster_ref: &str,
467    is_cluster_provider: bool,
468    record_name: &str,
469    text: &[String],
470    ttl: Option<i32>,
471    zone_manager: &crate::bind9::Bind9Manager,
472) -> Result<()> {
473    use crate::reconcilers::dnszone::for_each_primary_endpoint;
474
475    let (_first, _total) = for_each_primary_endpoint(
476        client,
477        namespace,
478        cluster_ref,
479        is_cluster_provider,
480        true,
481        "dns-tcp",
482        |pod_endpoint, instance_name, rndc_key| {
483            let zone_name = zone_name.to_string();
484            let record_name = record_name.to_string();
485            let text = text.to_vec();
486            let zone_manager = zone_manager.clone();
487
488            async move {
489                let key_data = rndc_key.expect("RNDC key should be loaded");
490
491                zone_manager
492                    .add_txt_record(
493                        &zone_name,
494                        &record_name,
495                        &text,
496                        ttl,
497                        &pod_endpoint,
498                        &key_data,
499                    )
500                    .await
501                    .context(format!(
502                        "Failed to add TXT record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
503                    ))?;
504
505                Ok(())
506            }
507        },
508    )
509    .await?;
510
511    Ok(())
512}
513
514/// Reconciles an `AAAARecord` (IPv6 address) resource.
515///
516/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
517/// the record in BIND9 primaries for those zones using dynamic DNS updates.
518///
519/// # Errors
520///
521/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
522#[allow(clippy::too_many_lines)]
523pub async fn reconcile_aaaa_record(client: Client, record: AAAARecord) -> Result<()> {
524    let namespace = record.namespace().unwrap_or_default();
525    let name = record.name_any();
526
527    info!("Reconciling AAAARecord: {}/{}", namespace, name);
528
529    let spec = &record.spec;
530    let current_generation = record.metadata.generation;
531    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
532
533    // Get zone from annotation (set by DNSZone controller)
534    // Check this FIRST before generation check, because the annotation may have been
535    // added after the record was created (by DNSZone controller)
536    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
537        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
538        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
539            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
540            return Ok(());
541        }
542
543        info!(
544            "AAAA record {}/{} not selected by any DNSZone (no zone annotation)",
545            namespace, name
546        );
547        update_record_status(
548            &client,
549            &record,
550            "Ready",
551            "False",
552            "NotSelected",
553            "Record not selected by any DNSZone label selector",
554            current_generation,
555        )
556        .await?;
557        return Ok(());
558    };
559
560    // Always reconcile to ensure declarative state - records are recreated if pods restart
561    // The underlying add_*_record() functions are idempotent and check for existence first
562    debug!(
563        "Ensuring record exists in zone {} (declarative reconciliation)",
564        zone_fqdn
565    );
566
567    // Get zone information from Kubernetes
568    let (zone_name, cluster_ref, is_cluster_provider) =
569        match get_zone_info(&client, &namespace, &zone_fqdn).await {
570            Ok(info) => info,
571            Err(e) => {
572                warn!(
573                    "Failed to find DNSZone for {} in {}/{}: {}",
574                    zone_fqdn, namespace, name, e
575                );
576                update_record_status(
577                    &client,
578                    &record,
579                    "Ready",
580                    "False",
581                    "ZoneNotFound",
582                    &format!("DNSZone '{zone_fqdn}' not found: {e}"),
583                    current_generation,
584                )
585                .await?;
586                return Ok(());
587            }
588        };
589
590    // Create/update record in BIND9 for the zone
591    let zone_manager = crate::bind9::Bind9Manager::new();
592
593    match add_aaaa_record_to_zone(
594        &client,
595        &namespace,
596        &zone_name,
597        &cluster_ref,
598        is_cluster_provider,
599        &spec.name,
600        &spec.ipv6_address,
601        spec.ttl,
602        &zone_manager,
603    )
604    .await
605    {
606        Ok(()) => {
607            info!(
608                "Successfully added AAAA record {} to zone {} in cluster {}",
609                spec.name, zone_name, cluster_ref
610            );
611            update_record_status(
612                &client,
613                &record,
614                "Ready",
615                "True",
616                "ReconcileSucceeded",
617                &format!("AAAA record added to zone {zone_name}"),
618                current_generation,
619            )
620            .await?;
621        }
622        Err(e) => {
623            warn!(
624                "Failed to add AAAA record {} to zone {}: {}",
625                spec.name, zone_name, e
626            );
627            update_record_status(
628                &client,
629                &record,
630                "Ready",
631                "False",
632                "ReconcileFailed",
633                &format!("Failed to add record to zone {zone_name}: {e}"),
634                current_generation,
635            )
636            .await?;
637        }
638    }
639
640    Ok(())
641}
642
643/// Add an AAAA record to a specific zone in BIND9 primaries.
644#[allow(clippy::too_many_arguments)]
645async fn add_aaaa_record_to_zone(
646    client: &Client,
647    namespace: &str,
648    zone_name: &str,
649    cluster_ref: &str,
650    is_cluster_provider: bool,
651    record_name: &str,
652    ipv6_address: &str,
653    ttl: Option<i32>,
654    zone_manager: &crate::bind9::Bind9Manager,
655) -> Result<()> {
656    use crate::reconcilers::dnszone::for_each_primary_endpoint;
657
658    let (_first, _total) = for_each_primary_endpoint(
659        client,
660        namespace,
661        cluster_ref,
662        is_cluster_provider,
663        true,
664        "dns-tcp",
665        |pod_endpoint, instance_name, rndc_key| {
666            let zone_name = zone_name.to_string();
667            let record_name = record_name.to_string();
668            let ipv6_address = ipv6_address.to_string();
669            let zone_manager = zone_manager.clone();
670
671            async move {
672                let key_data = rndc_key.expect("RNDC key should be loaded");
673
674                zone_manager
675                    .add_aaaa_record(
676                        &zone_name,
677                        &record_name,
678                        &ipv6_address,
679                        ttl,
680                        &pod_endpoint,
681                        &key_data,
682                    )
683                    .await
684                    .context(format!(
685                        "Failed to add AAAA record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
686                    ))?;
687
688                Ok(())
689            }
690        },
691    )
692    .await?;
693
694    Ok(())
695}
696
697/// Reconciles a `CNAMERecord` (canonical name alias) resource.
698///
699/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
700/// the record in BIND9 primaries for those zones using dynamic DNS updates.
701///
702/// # Errors
703///
704/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
705#[allow(clippy::too_many_lines)]
706pub async fn reconcile_cname_record(client: Client, record: CNAMERecord) -> Result<()> {
707    let namespace = record.namespace().unwrap_or_default();
708    let name = record.name_any();
709
710    info!("Reconciling CNAMERecord: {}/{}", namespace, name);
711
712    let spec = &record.spec;
713    let current_generation = record.metadata.generation;
714    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
715
716    // Get zone from annotation (set by DNSZone controller)
717    // Check this FIRST before generation check, because the annotation may have been
718    // added after the record was created (by DNSZone controller)
719    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
720        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
721        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
722            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
723            return Ok(());
724        }
725
726        info!(
727            "CNAME record {}/{} not selected by any DNSZone",
728            namespace, name
729        );
730        update_record_status(
731            &client,
732            &record,
733            "Ready",
734            "False",
735            "NotSelected",
736            "Record not selected by any DNSZone label selector",
737            current_generation,
738        )
739        .await?;
740        return Ok(());
741    };
742
743    // Always reconcile to ensure declarative state - records are recreated if pods restart
744    // The underlying add_*_record() functions are idempotent and check for existence first
745    debug!(
746        "Ensuring record exists in zone {} (declarative reconciliation)",
747        zone_fqdn
748    );
749
750    // Get zone info from DNSZone resource
751    let (zone_name, cluster_ref, is_cluster_provider) =
752        match get_zone_info(&client, &namespace, &zone_fqdn).await {
753            Ok(info) => info,
754            Err(e) => {
755                warn!(
756                    "Failed to find DNSZone {} for CNAME record {}/{}: {}",
757                    zone_fqdn, namespace, name, e
758                );
759                update_record_status(
760                    &client,
761                    &record,
762                    "Ready",
763                    "False",
764                    "ZoneNotFound",
765                    &format!("DNSZone {zone_fqdn} not found: {e}"),
766                    current_generation,
767                )
768                .await?;
769                return Ok(());
770            }
771        };
772
773    // Create/update record in BIND9
774    let zone_manager = crate::bind9::Bind9Manager::new();
775
776    match add_cname_record_to_zone(
777        &client,
778        &namespace,
779        &zone_name,
780        &cluster_ref,
781        is_cluster_provider,
782        &spec.name,
783        &spec.target,
784        spec.ttl,
785        &zone_manager,
786    )
787    .await
788    {
789        Ok(()) => {
790            info!(
791                "Successfully added CNAME record {} to zone {} in cluster {}",
792                spec.name, zone_name, cluster_ref
793            );
794            update_record_status(
795                &client,
796                &record,
797                "Ready",
798                "True",
799                "RecordAvailable",
800                &format!(
801                    "CNAME record {} successfully added to zone {zone_name}",
802                    spec.name
803                ),
804                current_generation,
805            )
806            .await?;
807        }
808        Err(e) => {
809            warn!(
810                "Failed to add CNAME record {} to zone {}: {}",
811                spec.name, zone_name, e
812            );
813            update_record_status(
814                &client,
815                &record,
816                "Ready",
817                "False",
818                "ReconcileFailed",
819                &format!("Failed to add CNAME record to zone {zone_name}: {e}"),
820                current_generation,
821            )
822            .await?;
823        }
824    }
825
826    Ok(())
827}
828
829/// Add a CNAME record to a specific zone in BIND9 primaries.
830#[allow(clippy::too_many_arguments)]
831async fn add_cname_record_to_zone(
832    client: &Client,
833    namespace: &str,
834    zone_name: &str,
835    cluster_ref: &str,
836    is_cluster_provider: bool,
837    record_name: &str,
838    target: &str,
839    ttl: Option<i32>,
840    zone_manager: &crate::bind9::Bind9Manager,
841) -> Result<()> {
842    use crate::reconcilers::dnszone::for_each_primary_endpoint;
843
844    let (_first, _total) = for_each_primary_endpoint(
845        client,
846        namespace,
847        cluster_ref,
848        is_cluster_provider,
849        true,
850        "dns-tcp",
851        |pod_endpoint, instance_name, rndc_key| {
852            let zone_name = zone_name.to_string();
853            let record_name = record_name.to_string();
854            let target = target.to_string();
855            let zone_manager = zone_manager.clone();
856
857            async move {
858                let key_data = rndc_key.expect("RNDC key should be loaded");
859
860                zone_manager
861                    .add_cname_record(
862                        &zone_name,
863                        &record_name,
864                        &target,
865                        ttl,
866                        &pod_endpoint,
867                        &key_data,
868                    )
869                    .await
870                    .context(format!(
871                        "Failed to add CNAME record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
872                    ))?;
873
874                Ok(())
875            }
876        },
877    )
878    .await?;
879
880    Ok(())
881}
882
883/// Reconciles an `MXRecord` (mail exchange) resource.
884///
885/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
886/// the record in BIND9 primaries for those zones using dynamic DNS updates.
887/// MX records specify mail servers for email delivery.
888///
889/// # Errors
890///
891/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
892#[allow(clippy::too_many_lines)]
893pub async fn reconcile_mx_record(client: Client, record: MXRecord) -> Result<()> {
894    let namespace = record.namespace().unwrap_or_default();
895    let name = record.name_any();
896
897    info!("Reconciling MXRecord: {}/{}", namespace, name);
898
899    let spec = &record.spec;
900    let current_generation = record.metadata.generation;
901    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
902
903    // Get zone from annotation (set by DNSZone controller)
904    // Check this FIRST before generation check, because the annotation may have been
905    // added after the record was created (by DNSZone controller)
906    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
907        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
908        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
909            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
910            return Ok(());
911        }
912
913        info!(
914            "MX record {}/{} not selected by any DNSZone",
915            namespace, name
916        );
917        update_record_status(
918            &client,
919            &record,
920            "Ready",
921            "False",
922            "NotSelected",
923            "Record not selected by any DNSZone label selector",
924            current_generation,
925        )
926        .await?;
927        return Ok(());
928    };
929
930    // Always reconcile to ensure declarative state - records are recreated if pods restart
931    // The underlying add_*_record() functions are idempotent and check for existence first
932    debug!(
933        "Ensuring record exists in zone {} (declarative reconciliation)",
934        zone_fqdn
935    );
936
937    // Get zone info from DNSZone resource
938    let (zone_name, cluster_ref, is_cluster_provider) =
939        match get_zone_info(&client, &namespace, &zone_fqdn).await {
940            Ok(info) => info,
941            Err(e) => {
942                warn!(
943                    "Failed to find DNSZone {} for MX record {}/{}: {}",
944                    zone_fqdn, namespace, name, e
945                );
946                update_record_status(
947                    &client,
948                    &record,
949                    "Ready",
950                    "False",
951                    "ZoneNotFound",
952                    &format!("DNSZone {zone_fqdn} not found: {e}"),
953                    current_generation,
954                )
955                .await?;
956                return Ok(());
957            }
958        };
959
960    // Create/update record in BIND9
961    let zone_manager = crate::bind9::Bind9Manager::new();
962
963    match add_mx_record_to_zone(
964        &client,
965        &namespace,
966        &zone_name,
967        &cluster_ref,
968        is_cluster_provider,
969        &spec.name,
970        spec.priority,
971        &spec.mail_server,
972        spec.ttl,
973        &zone_manager,
974    )
975    .await
976    {
977        Ok(()) => {
978            info!(
979                "Successfully added MX record {} to zone {} in cluster {}",
980                spec.name, zone_name, cluster_ref
981            );
982            update_record_status(
983                &client,
984                &record,
985                "Ready",
986                "True",
987                "RecordAvailable",
988                &format!(
989                    "MX record {} successfully added to zone {zone_name}",
990                    spec.name
991                ),
992                current_generation,
993            )
994            .await?;
995        }
996        Err(e) => {
997            warn!(
998                "Failed to add MX record {} to zone {}: {}",
999                spec.name, zone_name, e
1000            );
1001            update_record_status(
1002                &client,
1003                &record,
1004                "Ready",
1005                "False",
1006                "ReconcileFailed",
1007                &format!("Failed to add MX record to zone {zone_name}: {e}"),
1008                current_generation,
1009            )
1010            .await?;
1011        }
1012    }
1013
1014    Ok(())
1015}
1016
1017/// Add an MX record to a specific zone in BIND9 primaries.
1018#[allow(clippy::too_many_arguments)]
1019async fn add_mx_record_to_zone(
1020    client: &Client,
1021    namespace: &str,
1022    zone_name: &str,
1023    cluster_ref: &str,
1024    is_cluster_provider: bool,
1025    record_name: &str,
1026    priority: i32,
1027    mail_server: &str,
1028    ttl: Option<i32>,
1029    zone_manager: &crate::bind9::Bind9Manager,
1030) -> Result<()> {
1031    use crate::reconcilers::dnszone::for_each_primary_endpoint;
1032
1033    let (_first, _total) = for_each_primary_endpoint(
1034        client,
1035        namespace,
1036        cluster_ref,
1037        is_cluster_provider,
1038        true,
1039        "dns-tcp",
1040        |pod_endpoint, instance_name, rndc_key| {
1041            let zone_name = zone_name.to_string();
1042            let record_name = record_name.to_string();
1043            let mail_server = mail_server.to_string();
1044            let zone_manager = zone_manager.clone();
1045
1046            async move {
1047                let key_data = rndc_key.expect("RNDC key should be loaded");
1048
1049                zone_manager
1050                    .add_mx_record(
1051                        &zone_name,
1052                        &record_name,
1053                        priority,
1054                        &mail_server,
1055                        ttl,
1056                        &pod_endpoint,
1057                        &key_data,
1058                    )
1059                    .await
1060                    .context(format!(
1061                        "Failed to add MX record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1062                    ))?;
1063
1064                Ok(())
1065            }
1066        },
1067    )
1068    .await?;
1069
1070    Ok(())
1071}
1072
1073/// Reconciles an `NSRecord` (nameserver delegation) resource.
1074///
1075/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1076/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1077/// NS records delegate a subdomain to different nameservers.
1078///
1079/// # Errors
1080///
1081/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1082#[allow(clippy::too_many_lines)]
1083pub async fn reconcile_ns_record(client: Client, record: NSRecord) -> Result<()> {
1084    let namespace = record.namespace().unwrap_or_default();
1085    let name = record.name_any();
1086
1087    info!("Reconciling NSRecord: {}/{}", namespace, name);
1088
1089    let spec = &record.spec;
1090    let current_generation = record.metadata.generation;
1091    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1092
1093    // Get zone from annotation (set by DNSZone controller)
1094    // Check this FIRST before generation check, because the annotation may have been
1095    // added after the record was created (by DNSZone controller)
1096    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1097        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
1098        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1099            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1100            return Ok(());
1101        }
1102
1103        info!(
1104            "NS record {}/{} not selected by any DNSZone",
1105            namespace, name
1106        );
1107        update_record_status(
1108            &client,
1109            &record,
1110            "Ready",
1111            "False",
1112            "NotSelected",
1113            "Record not selected by any DNSZone label selector",
1114            current_generation,
1115        )
1116        .await?;
1117        return Ok(());
1118    };
1119
1120    // Always reconcile to ensure declarative state - records are recreated if pods restart
1121    // The underlying add_*_record() functions are idempotent and check for existence first
1122    debug!(
1123        "Ensuring record exists in zone {} (declarative reconciliation)",
1124        zone_fqdn
1125    );
1126
1127    // Get zone info from DNSZone resource
1128    let (zone_name, cluster_ref, is_cluster_provider) =
1129        match get_zone_info(&client, &namespace, &zone_fqdn).await {
1130            Ok(info) => info,
1131            Err(e) => {
1132                warn!(
1133                    "Failed to find DNSZone {} for NS record {}/{}: {}",
1134                    zone_fqdn, namespace, name, e
1135                );
1136                update_record_status(
1137                    &client,
1138                    &record,
1139                    "Ready",
1140                    "False",
1141                    "ZoneNotFound",
1142                    &format!("DNSZone {zone_fqdn} not found: {e}"),
1143                    current_generation,
1144                )
1145                .await?;
1146                return Ok(());
1147            }
1148        };
1149
1150    // Create/update record in BIND9
1151    let zone_manager = crate::bind9::Bind9Manager::new();
1152
1153    match add_ns_record_to_zone(
1154        &client,
1155        &namespace,
1156        &zone_name,
1157        &cluster_ref,
1158        is_cluster_provider,
1159        &spec.name,
1160        &spec.nameserver,
1161        spec.ttl,
1162        &zone_manager,
1163    )
1164    .await
1165    {
1166        Ok(()) => {
1167            info!(
1168                "Successfully added NS record {} to zone {} in cluster {}",
1169                spec.name, zone_name, cluster_ref
1170            );
1171            update_record_status(
1172                &client,
1173                &record,
1174                "Ready",
1175                "True",
1176                "RecordAvailable",
1177                &format!(
1178                    "NS record {} successfully added to zone {zone_name}",
1179                    spec.name
1180                ),
1181                current_generation,
1182            )
1183            .await?;
1184        }
1185        Err(e) => {
1186            warn!(
1187                "Failed to add NS record {} to zone {}: {}",
1188                spec.name, zone_name, e
1189            );
1190            update_record_status(
1191                &client,
1192                &record,
1193                "Ready",
1194                "False",
1195                "ReconcileFailed",
1196                &format!("Failed to add NS record to zone {zone_name}: {e}"),
1197                current_generation,
1198            )
1199            .await?;
1200        }
1201    }
1202
1203    Ok(())
1204}
1205
1206/// Add an NS record to a specific zone in BIND9 primaries.
1207#[allow(clippy::too_many_arguments)]
1208async fn add_ns_record_to_zone(
1209    client: &Client,
1210    namespace: &str,
1211    zone_name: &str,
1212    cluster_ref: &str,
1213    is_cluster_provider: bool,
1214    record_name: &str,
1215    nameserver: &str,
1216    ttl: Option<i32>,
1217    zone_manager: &crate::bind9::Bind9Manager,
1218) -> Result<()> {
1219    use crate::reconcilers::dnszone::for_each_primary_endpoint;
1220
1221    let (_first, _total) = for_each_primary_endpoint(
1222        client,
1223        namespace,
1224        cluster_ref,
1225        is_cluster_provider,
1226        true,
1227        "dns-tcp",
1228        |pod_endpoint, instance_name, rndc_key| {
1229            let zone_name = zone_name.to_string();
1230            let record_name = record_name.to_string();
1231            let nameserver = nameserver.to_string();
1232            let zone_manager = zone_manager.clone();
1233
1234            async move {
1235                let key_data = rndc_key.expect("RNDC key should be loaded");
1236
1237                zone_manager
1238                    .add_ns_record(
1239                        &zone_name,
1240                        &record_name,
1241                        &nameserver,
1242                        ttl,
1243                        &pod_endpoint,
1244                        &key_data,
1245                    )
1246                    .await
1247                    .context(format!(
1248                        "Failed to add NS record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1249                    ))?;
1250
1251                Ok(())
1252            }
1253        },
1254    )
1255    .await?;
1256
1257    Ok(())
1258}
1259
1260/// Reconciles an `SRVRecord` (service location) resource.
1261///
1262/// Finds `DNSZones` that have selected this record via label selectors and creates/updates
1263/// the record in BIND9 primaries for those zones using dynamic DNS updates.
1264/// SRV records specify the location of services (e.g., _ldap._tcp).
1265///
1266/// # Errors
1267///
1268/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1269#[allow(clippy::too_many_lines)]
1270pub async fn reconcile_srv_record(client: Client, record: SRVRecord) -> Result<()> {
1271    let namespace = record.namespace().unwrap_or_default();
1272    let name = record.name_any();
1273
1274    info!("Reconciling SRVRecord: {}/{}", namespace, name);
1275
1276    let spec = &record.spec;
1277    let current_generation = record.metadata.generation;
1278    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1279
1280    // Get zone from annotation (set by DNSZone controller)
1281    // Check this FIRST before generation check, because the annotation may have been
1282    // added after the record was created (by DNSZone controller)
1283    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1284        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
1285        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1286            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1287            return Ok(());
1288        }
1289
1290        info!(
1291            "SRV record {}/{} not selected by any DNSZone",
1292            namespace, name
1293        );
1294        update_record_status(
1295            &client,
1296            &record,
1297            "Ready",
1298            "False",
1299            "NotSelected",
1300            "Record not selected by any DNSZone label selector",
1301            current_generation,
1302        )
1303        .await?;
1304        return Ok(());
1305    };
1306
1307    // Always reconcile to ensure declarative state - records are recreated if pods restart
1308    // The underlying add_*_record() functions are idempotent and check for existence first
1309    debug!(
1310        "Ensuring record exists in zone {} (declarative reconciliation)",
1311        zone_fqdn
1312    );
1313
1314    // Get zone info from DNSZone resource
1315    let (zone_name, cluster_ref, is_cluster_provider) =
1316        match get_zone_info(&client, &namespace, &zone_fqdn).await {
1317            Ok(info) => info,
1318            Err(e) => {
1319                warn!(
1320                    "Failed to find DNSZone {} for SRV record {}/{}: {}",
1321                    zone_fqdn, namespace, name, e
1322                );
1323                update_record_status(
1324                    &client,
1325                    &record,
1326                    "Ready",
1327                    "False",
1328                    "ZoneNotFound",
1329                    &format!("DNSZone {zone_fqdn} not found: {e}"),
1330                    current_generation,
1331                )
1332                .await?;
1333                return Ok(());
1334            }
1335        };
1336
1337    // Create/update record in BIND9
1338    let zone_manager = crate::bind9::Bind9Manager::new();
1339
1340    match add_srv_record_to_zone(
1341        &client,
1342        &namespace,
1343        &zone_name,
1344        &cluster_ref,
1345        is_cluster_provider,
1346        &spec.name,
1347        spec.priority,
1348        spec.weight,
1349        spec.port,
1350        &spec.target,
1351        spec.ttl,
1352        &zone_manager,
1353    )
1354    .await
1355    {
1356        Ok(()) => {
1357            info!(
1358                "Successfully added SRV record {} to zone {} in cluster {}",
1359                spec.name, zone_name, cluster_ref
1360            );
1361            update_record_status(
1362                &client,
1363                &record,
1364                "Ready",
1365                "True",
1366                "RecordAvailable",
1367                &format!(
1368                    "SRV record {} successfully added to zone {zone_name}",
1369                    spec.name
1370                ),
1371                current_generation,
1372            )
1373            .await?;
1374        }
1375        Err(e) => {
1376            warn!(
1377                "Failed to add SRV record {} to zone {}: {}",
1378                spec.name, zone_name, e
1379            );
1380            update_record_status(
1381                &client,
1382                &record,
1383                "Ready",
1384                "False",
1385                "ReconcileFailed",
1386                &format!("Failed to add SRV record to zone {zone_name}: {e}"),
1387                current_generation,
1388            )
1389            .await?;
1390        }
1391    }
1392
1393    Ok(())
1394}
1395
1396/// Add an SRV record to a specific zone in BIND9 primaries.
1397#[allow(clippy::too_many_arguments)]
1398async fn add_srv_record_to_zone(
1399    client: &Client,
1400    namespace: &str,
1401    zone_name: &str,
1402    cluster_ref: &str,
1403    is_cluster_provider: bool,
1404    record_name: &str,
1405    priority: i32,
1406    weight: i32,
1407    port: i32,
1408    target: &str,
1409    ttl: Option<i32>,
1410    zone_manager: &crate::bind9::Bind9Manager,
1411) -> Result<()> {
1412    use crate::bind9::types::SRVRecordData;
1413    use crate::reconcilers::dnszone::for_each_primary_endpoint;
1414
1415    let (_first, _total) = for_each_primary_endpoint(
1416        client,
1417        namespace,
1418        cluster_ref,
1419        is_cluster_provider,
1420        true,
1421        "dns-tcp",
1422        |pod_endpoint, instance_name, rndc_key| {
1423            let zone_name = zone_name.to_string();
1424            let record_name = record_name.to_string();
1425            let srv_data = SRVRecordData {
1426                priority,
1427                weight,
1428                port,
1429                target: target.to_string(),
1430                ttl,
1431            };
1432            let zone_manager = zone_manager.clone();
1433
1434            async move {
1435                let key_data = rndc_key.expect("RNDC key should be loaded");
1436
1437                zone_manager
1438                    .add_srv_record(
1439                        &zone_name,
1440                        &record_name,
1441                        &srv_data,
1442                        &pod_endpoint,
1443                        &key_data,
1444                    )
1445                    .await
1446                    .context(format!(
1447                        "Failed to add SRV record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1448                    ))?;
1449
1450                Ok(())
1451            }
1452        },
1453    )
1454    .await?;
1455
1456    Ok(())
1457}
1458
1459/// Reconciles a `CAARecord` (certificate authority authorization) 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/// CAA records specify which certificate authorities can issue certificates.
1464///
1465/// # Errors
1466///
1467/// Returns an error if Kubernetes API operations fail or BIND9 record creation fails.
1468#[allow(clippy::too_many_lines)]
1469pub async fn reconcile_caa_record(client: Client, record: CAARecord) -> Result<()> {
1470    let namespace = record.namespace().unwrap_or_default();
1471    let name = record.name_any();
1472
1473    info!("Reconciling CAARecord: {}/{}", namespace, name);
1474
1475    let spec = &record.spec;
1476    let current_generation = record.metadata.generation;
1477    let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1478
1479    // Get zone from annotation (set by DNSZone controller)
1480    // Check this FIRST before generation check, because the annotation may have been
1481    // added after the record was created (by DNSZone controller)
1482    let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1483        // Only skip reconciliation if generation hasn't changed AND already marked as NotSelected
1484        if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1485            debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1486            return Ok(());
1487        }
1488
1489        info!(
1490            "CAA record {}/{} not selected by any DNSZone",
1491            namespace, name
1492        );
1493        update_record_status(
1494            &client,
1495            &record,
1496            "Ready",
1497            "False",
1498            "NotSelected",
1499            "Record not selected by any DNSZone label selector",
1500            current_generation,
1501        )
1502        .await?;
1503        return Ok(());
1504    };
1505
1506    // Always reconcile to ensure declarative state - records are recreated if pods restart
1507    // The underlying add_*_record() functions are idempotent and check for existence first
1508    debug!(
1509        "Ensuring record exists in zone {} (declarative reconciliation)",
1510        zone_fqdn
1511    );
1512
1513    // Get zone info from DNSZone resource
1514    let (zone_name, cluster_ref, is_cluster_provider) =
1515        match get_zone_info(&client, &namespace, &zone_fqdn).await {
1516            Ok(info) => info,
1517            Err(e) => {
1518                warn!(
1519                    "Failed to find DNSZone {} for CAA record {}/{}: {}",
1520                    zone_fqdn, namespace, name, e
1521                );
1522                update_record_status(
1523                    &client,
1524                    &record,
1525                    "Ready",
1526                    "False",
1527                    "ZoneNotFound",
1528                    &format!("DNSZone {zone_fqdn} not found: {e}"),
1529                    current_generation,
1530                )
1531                .await?;
1532                return Ok(());
1533            }
1534        };
1535
1536    // Create/update record in BIND9
1537    let zone_manager = crate::bind9::Bind9Manager::new();
1538
1539    match add_caa_record_to_zone(
1540        &client,
1541        &namespace,
1542        &zone_name,
1543        &cluster_ref,
1544        is_cluster_provider,
1545        &spec.name,
1546        spec.flags,
1547        &spec.tag,
1548        &spec.value,
1549        spec.ttl,
1550        &zone_manager,
1551    )
1552    .await
1553    {
1554        Ok(()) => {
1555            info!(
1556                "Successfully added CAA record {} to zone {} in cluster {}",
1557                spec.name, zone_name, cluster_ref
1558            );
1559            update_record_status(
1560                &client,
1561                &record,
1562                "Ready",
1563                "True",
1564                "RecordAvailable",
1565                &format!(
1566                    "CAA record {} successfully added to zone {zone_name}",
1567                    spec.name
1568                ),
1569                current_generation,
1570            )
1571            .await?;
1572        }
1573        Err(e) => {
1574            warn!(
1575                "Failed to add CAA record {} to zone {}: {}",
1576                spec.name, zone_name, e
1577            );
1578            update_record_status(
1579                &client,
1580                &record,
1581                "Ready",
1582                "False",
1583                "ReconcileFailed",
1584                &format!("Failed to add CAA record to zone {zone_name}: {e}"),
1585                current_generation,
1586            )
1587            .await?;
1588        }
1589    }
1590
1591    Ok(())
1592}
1593
1594/// Add a CAA record to a specific zone in BIND9 primaries.
1595#[allow(clippy::too_many_arguments)]
1596async fn add_caa_record_to_zone(
1597    client: &Client,
1598    namespace: &str,
1599    zone_name: &str,
1600    cluster_ref: &str,
1601    is_cluster_provider: bool,
1602    record_name: &str,
1603    flags: i32,
1604    tag: &str,
1605    value: &str,
1606    ttl: Option<i32>,
1607    zone_manager: &crate::bind9::Bind9Manager,
1608) -> Result<()> {
1609    use crate::reconcilers::dnszone::for_each_primary_endpoint;
1610
1611    let (_first, _total) = for_each_primary_endpoint(
1612        client,
1613        namespace,
1614        cluster_ref,
1615        is_cluster_provider,
1616        true,
1617        "dns-tcp",
1618        |pod_endpoint, instance_name, rndc_key| {
1619            let zone_name = zone_name.to_string();
1620            let record_name = record_name.to_string();
1621            let tag = tag.to_string();
1622            let value = value.to_string();
1623            let zone_manager = zone_manager.clone();
1624
1625            async move {
1626                let key_data = rndc_key.expect("RNDC key should be loaded");
1627
1628                zone_manager
1629                    .add_caa_record(
1630                        &zone_name,
1631                        &record_name,
1632                        flags,
1633                        &tag,
1634                        &value,
1635                        ttl,
1636                        &pod_endpoint,
1637                        &key_data,
1638                    )
1639                    .await
1640                    .context(format!(
1641                        "Failed to add CAA record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1642                    ))?;
1643
1644                Ok(())
1645            }
1646        },
1647    )
1648    .await?;
1649
1650    Ok(())
1651}
1652
1653/// Create a Kubernetes Event for a DNS record.
1654///
1655/// # Arguments
1656///
1657/// * `client` - Kubernetes API client
1658/// * `record` - The DNS record resource
1659/// * `event_type` - Type of event ("Normal" or "Warning")
1660/// * `reason` - Short reason for the event
1661/// * `message` - Human-readable message describing the event
1662async fn create_event<T>(
1663    client: &Client,
1664    record: &T,
1665    event_type: &str,
1666    reason: &str,
1667    message: &str,
1668) -> Result<()>
1669where
1670    T: Resource<DynamicType = ()> + ResourceExt,
1671{
1672    let namespace = record.namespace().unwrap_or_default();
1673    let name = record.name_any();
1674    let event_api: Api<Event> = Api::namespaced(client.clone(), &namespace);
1675
1676    let now = Time(Utc::now());
1677    let event = Event {
1678        metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
1679            generate_name: Some(format!("{name}-")),
1680            namespace: Some(namespace.clone()),
1681            ..Default::default()
1682        },
1683        involved_object: ObjectReference {
1684            api_version: Some(T::api_version(&()).to_string()),
1685            kind: Some(T::kind(&()).to_string()),
1686            name: Some(name.clone()),
1687            namespace: Some(namespace),
1688            uid: record.meta().uid.clone(),
1689            ..Default::default()
1690        },
1691        reason: Some(reason.to_string()),
1692        message: Some(message.to_string()),
1693        type_: Some(event_type.to_string()),
1694        first_timestamp: Some(now.clone()),
1695        last_timestamp: Some(now),
1696        count: Some(1),
1697        ..Default::default()
1698    };
1699
1700    match event_api.create(&PostParams::default(), &event).await {
1701        Ok(_) => Ok(()),
1702        Err(e) => {
1703            warn!("Failed to create event for {}: {}", name, e);
1704            Ok(()) // Don't fail reconciliation if event creation fails
1705        }
1706    }
1707}
1708
1709/// Updates the status of a DNS record resource.
1710///
1711/// Updates the status subresource with appropriate conditions following
1712/// Kubernetes conventions. Also creates a Kubernetes Event for visibility.
1713///
1714/// # Arguments
1715///
1716/// * `client` - Kubernetes API client
1717/// * `record` - The DNS record resource to update
1718/// * `condition_type` - Type of condition (e.g., "Ready", "Failed")
1719/// * `status` - Status value (e.g., "True", "False", "Unknown")
1720/// * `reason` - Short reason code (e.g., "`ReconcileSucceeded`", "`ZoneNotFound`")
1721/// * `message` - Human-readable message describing the status
1722/// * `observed_generation` - Optional generation to set in status (defaults to record's current generation)
1723///
1724/// # Errors
1725///
1726/// Returns an error if the status update fails.
1727#[allow(clippy::too_many_lines)]
1728async fn update_record_status<T>(
1729    client: &Client,
1730    record: &T,
1731    condition_type: &str,
1732    status: &str,
1733    reason: &str,
1734    message: &str,
1735    observed_generation: Option<i64>,
1736) -> Result<()>
1737where
1738    T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
1739        + ResourceExt
1740        + Clone
1741        + std::fmt::Debug
1742        + serde::Serialize
1743        + for<'de> serde::Deserialize<'de>,
1744{
1745    let namespace = record.namespace().unwrap_or_default();
1746    let name = record.name_any();
1747    let api: Api<T> = Api::namespaced(client.clone(), &namespace);
1748
1749    // Fetch current resource to check existing status
1750    let current = api
1751        .get(&name)
1752        .await
1753        .context("Failed to fetch current resource")?;
1754
1755    // Check if we need to update
1756    // Extract status from the current resource using json
1757    let current_json = serde_json::to_value(&current)?;
1758    let needs_update = if let Some(current_status) = current_json.get("status") {
1759        if let Some(observed_gen) = current_status.get("observedGeneration") {
1760            // If observed generation matches current generation and condition hasn't changed, skip update
1761            if observed_gen == &json!(record.meta().generation) {
1762                if let Some(conditions) =
1763                    current_status.get("conditions").and_then(|c| c.as_array())
1764                {
1765                    // Find the condition with matching type (not just first condition)
1766                    let matching_condition = conditions.iter().find(|cond| {
1767                        cond.get("type").and_then(|t| t.as_str()) == Some(condition_type)
1768                    });
1769
1770                    if let Some(cond) = matching_condition {
1771                        let status_matches =
1772                            cond.get("status").and_then(|s| s.as_str()) == Some(status);
1773                        let reason_matches =
1774                            cond.get("reason").and_then(|r| r.as_str()) == Some(reason);
1775                        let message_matches =
1776                            cond.get("message").and_then(|m| m.as_str()) == Some(message);
1777                        // Only update if any field has changed
1778                        !(status_matches && reason_matches && message_matches)
1779                    } else {
1780                        true // Condition type not found, need to add it
1781                    }
1782                } else {
1783                    true // No conditions array, need to update
1784                }
1785            } else {
1786                true // Generation changed, need to update
1787            }
1788        } else {
1789            true // No observed generation, need to update
1790        }
1791    } else {
1792        true // No status, need to update
1793    };
1794
1795    if !needs_update {
1796        // Status is already correct, skip update to avoid reconciliation loop
1797        return Ok(());
1798    }
1799
1800    // Determine last_transition_time
1801    let last_transition_time = if let Some(current_status) = current_json.get("status") {
1802        if let Some(conditions) = current_status.get("conditions").and_then(|c| c.as_array()) {
1803            // Find the condition with matching type (same as above)
1804            let matching_condition = conditions
1805                .iter()
1806                .find(|cond| cond.get("type").and_then(|t| t.as_str()) == Some(condition_type));
1807
1808            if let Some(cond) = matching_condition {
1809                let status_changed = cond.get("status").and_then(|s| s.as_str()) != Some(status);
1810                if status_changed {
1811                    // Status changed, use current time
1812                    Utc::now().to_rfc3339()
1813                } else {
1814                    // Status unchanged, preserve existing timestamp
1815                    cond.get("lastTransitionTime")
1816                        .and_then(|t| t.as_str())
1817                        .unwrap_or(&Utc::now().to_rfc3339())
1818                        .to_string()
1819                }
1820            } else {
1821                // Condition type not found, use current time
1822                Utc::now().to_rfc3339()
1823            }
1824        } else {
1825            Utc::now().to_rfc3339()
1826        }
1827    } else {
1828        Utc::now().to_rfc3339()
1829    };
1830
1831    let condition = Condition {
1832        r#type: condition_type.to_string(),
1833        status: status.to_string(),
1834        reason: Some(reason.to_string()),
1835        message: Some(message.to_string()),
1836        last_transition_time: Some(last_transition_time),
1837    };
1838
1839    // Preserve existing zone field if it exists (set by DNSZone controller)
1840    let zone = current_json
1841        .get("status")
1842        .and_then(|s| s.get("zone"))
1843        .and_then(|z| z.as_str())
1844        .map(ToString::to_string);
1845
1846    let record_status = RecordStatus {
1847        conditions: vec![condition],
1848        observed_generation: observed_generation.or(record.meta().generation),
1849        zone,
1850    };
1851
1852    let status_patch = json!({
1853        "status": record_status
1854    });
1855
1856    api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch))
1857        .await
1858        .context("Failed to update record status")?;
1859
1860    info!(
1861        "Updated status for {}/{}: {} = {}",
1862        namespace, name, condition_type, status
1863    );
1864
1865    // Create event for visibility
1866    let event_type = if status == "True" {
1867        "Normal"
1868    } else {
1869        "Warning"
1870    };
1871    create_event(client, record, event_type, reason, message).await?;
1872
1873    Ok(())
1874}