bindy/reconcilers/
dnszone.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2#![allow(clippy::uninlined_format_args)]
3#![allow(clippy::doc_markdown)]
4// SPDX-License-Identifier: MIT
5
6//! DNS zone reconciliation logic.
7//!
8//! This module handles the creation and management of DNS zones on BIND9 servers.
9//! It supports both primary and secondary zone configurations.
10
11// Module imports
12pub mod bind9_config;
13pub mod cleanup;
14pub mod constants;
15pub mod discovery;
16pub mod helpers;
17pub mod primary;
18pub mod secondary;
19pub mod status_helpers;
20pub mod types;
21pub mod validation;
22
23#[cfg(test)]
24#[path = "dnszone/helpers_tests.rs"]
25mod helpers_tests;
26
27// Bind9Instance and InstanceReferenceWithStatus are used by dead_code marked functions (Phase 2 cleanup)
28use self::types::DuplicateZoneInfo;
29#[allow(unused_imports)]
30use crate::crd::{Condition, DNSZone, DNSZoneStatus};
31use anyhow::{anyhow, Result};
32use bindcar::{ZONE_TYPE_PRIMARY, ZONE_TYPE_SECONDARY};
33use futures::stream::{self, StreamExt};
34use k8s_openapi::api::core::v1::{Pod, Service};
35use kube::{api::ListParams, client::Client, Api, ResourceExt};
36use std::collections::HashMap;
37use std::sync::Arc;
38use tokio::sync::Mutex;
39use tracing::{debug, error, info, warn};
40
41/// Creates a map of nameserver hostnames to IP addresses by:
42/// 1. Checking for Service external IPs first (`LoadBalancer` or `NodePort`)
43/// 2. Falling back to pod IPs if no external IPs are available
44///
45/// Nameservers are named: `ns1.{zone_name}.`, `ns2.{zone_name}.`, etc.
46/// Order: Primary instances first, then secondary instances.
47///
48/// # Arguments
49///
50/// * `client` - Kubernetes API client
51/// * `zone_name` - DNS zone name (e.g., "example.com")
52/// * `instance_refs` - All instance references (primaries and secondaries)
53///
54/// # Returns
55///
56/// `HashMap` of nameserver hostnames to IP addresses, or None if no IPs found
57///
58/// # Errors
59///
60/// Returns an error if Kubernetes API calls fail.
61pub async fn generate_nameserver_ips(
62    client: &Client,
63    zone_name: &str,
64    instance_refs: &[crate::crd::InstanceReference],
65) -> Result<Option<HashMap<String, String>>> {
66    if instance_refs.is_empty() {
67        return Ok(None);
68    }
69
70    let mut nameserver_ips = HashMap::new();
71    let mut ns_index = 1;
72
73    // Process primaries first, then secondaries
74    for instance_ref in instance_refs {
75        // Try to get Service external IP first
76        let service_api: Api<Service> = Api::namespaced(client.clone(), &instance_ref.namespace);
77
78        let ip = match service_api.get(&instance_ref.name).await {
79            Ok(service) => {
80                // Check for LoadBalancer external IP
81                if let Some(status) = &service.status {
82                    if let Some(load_balancer) = &status.load_balancer {
83                        if let Some(ingress_list) = &load_balancer.ingress {
84                            if let Some(ingress) = ingress_list.first() {
85                                if let Some(lb_ip) = &ingress.ip {
86                                    debug!(
87                                        "Using LoadBalancer IP {} for instance {}/{}",
88                                        lb_ip, instance_ref.namespace, instance_ref.name
89                                    );
90                                    Some(lb_ip.clone())
91                                } else {
92                                    None
93                                }
94                            } else {
95                                None
96                            }
97                        } else {
98                            None
99                        }
100                    } else {
101                        None
102                    }
103                } else {
104                    None
105                }
106            }
107            Err(e) => {
108                debug!(
109                    "Failed to get service for instance {}/{}: {}. Will try pod IP.",
110                    instance_ref.namespace, instance_ref.name, e
111                );
112                None
113            }
114        };
115
116        // If no service external IP, fallback to pod IP
117        let ip = if ip.is_none() {
118            // Get pod IP
119            let pod_api: Api<Pod> = Api::namespaced(client.clone(), &instance_ref.namespace);
120            let label_selector = format!("app=bind9,instance={}", instance_ref.name);
121            let lp = ListParams::default().labels(&label_selector);
122
123            match pod_api.list(&lp).await {
124                Ok(pods) => {
125                    // Find first running pod
126                    pods.items
127                        .iter()
128                        .find(|pod| {
129                            let phase = pod
130                                .status
131                                .as_ref()
132                                .and_then(|s| s.phase.as_ref())
133                                .map_or("Unknown", std::string::String::as_str);
134                            phase == "Running"
135                        })
136                        .and_then(|pod| {
137                            pod.status
138                                .as_ref()
139                                .and_then(|s| s.pod_ip.as_ref())
140                                .map(|ip| {
141                                    debug!(
142                                        "Using pod IP {} for instance {}/{}",
143                                        ip, instance_ref.namespace, instance_ref.name
144                                    );
145                                    ip.clone()
146                                })
147                        })
148                }
149                Err(e) => {
150                    warn!(
151                        "Failed to list pods for instance {}/{}: {}. Skipping.",
152                        instance_ref.namespace, instance_ref.name, e
153                    );
154                    None
155                }
156            }
157        } else {
158            ip
159        };
160
161        // Add to nameserver map if we found an IP
162        if let Some(ip) = ip {
163            let ns_hostname = format!("ns{ns_index}.{zone_name}.");
164            nameserver_ips.insert(ns_hostname, ip);
165            ns_index += 1;
166        }
167    }
168
169    if nameserver_ips.is_empty() {
170        Ok(None)
171    } else {
172        Ok(Some(nameserver_ips))
173    }
174}
175
176/// Get the effective nameservers list for a DNSZone, handling both new and deprecated fields.
177///
178/// This function provides backward compatibility by:
179/// 1. Preferring the new `name_servers` field if present
180/// 2. Falling back to the deprecated `name_server_ips` field with automatic migration
181/// 3. Logging deprecation warnings when the old field is used
182///
183/// # Arguments
184/// * `spec` - The DNSZone spec containing nameserver configuration
185///
186/// # Returns
187/// `Option<Vec<NameServer>>` - The effective list of nameservers, or `None` if neither field is set
188///
189/// # Examples
190///
191/// ```
192/// # #[allow(deprecated)]
193/// # use bindy::crd::{DNSZoneSpec, NameServer, SOARecord};
194/// # use std::collections::HashMap;
195/// // New field takes precedence
196/// let spec = DNSZoneSpec {
197///     zone_name: "example.com".into(),
198///     soa_record: SOARecord {
199///         primary_ns: "ns1.example.com.".into(),
200///         admin_email: "admin.example.com.".into(),
201///         serial: 1,
202///         refresh: 3600,
203///         retry: 600,
204///         expire: 604800,
205///         negative_ttl: 86400,
206///     },
207///     ttl: None,
208///     cluster_ref: None,
209///     name_servers: Some(vec![NameServer {
210///         hostname: "ns2.example.com.".into(),
211///         ipv4_address: None,
212///         ipv6_address: None,
213///     }]),
214///     name_server_ips: Some(HashMap::from([("ns3.example.com.".into(), "192.0.2.3".into())])),
215///     records_from: None,
216///     bind9_instances_from: None,
217///     dnssec_policy: None,
218/// };
219/// // Returns name_servers (new field), ignoring name_server_ips
220/// ```
221fn get_effective_name_servers(
222    spec: &crate::crd::DNSZoneSpec,
223) -> Option<Vec<crate::crd::NameServer>> {
224    use crate::crd::NameServer;
225
226    // New field takes precedence
227    if let Some(ref new_servers) = spec.name_servers {
228        debug!(
229            "Using new `nameServers` field with {} server(s)",
230            new_servers.len()
231        );
232        return Some(new_servers.clone());
233    }
234
235    // Fallback to deprecated field with migration warning
236    #[allow(deprecated)]
237    if let Some(ref old_ips) = spec.name_server_ips {
238        warn!(
239            "DNSZone uses deprecated `nameServerIps` field. \
240             Migrate to `nameServers` for better functionality and IPv6 support. \
241             See migration guide at docs/src/operations/migration-guide.md"
242        );
243
244        // Convert HashMap<String, String> to Vec<NameServer>
245        // Old format: {"ns2.example.com.": "192.0.2.2"}
246        // New format: vec![NameServer { hostname: "ns2.example.com.", ipv4_address: Some("192.0.2.2"), .. }]
247        let servers: Vec<NameServer> = old_ips
248            .iter()
249            .map(|(hostname, ip)| NameServer {
250                hostname: hostname.clone(),
251                ipv4_address: Some(ip.clone()),
252                ipv6_address: None, // Old field doesn't support IPv6
253            })
254            .collect();
255
256        debug!(
257            "Migrated {} server(s) from deprecated `nameServerIps` to new format",
258            servers.len()
259        );
260
261        return Some(servers);
262    }
263
264    // Neither field set
265    None
266}
267
268/// Re-fetch a DNSZone to get the latest status.
269///
270/// The `dnszone` parameter from the watch event might have stale status from the cache.
271/// We need the latest `status.bind9Instances` which may have been updated by the
272/// Bind9Instance reconciler.
273///
274/// # Arguments
275/// * `client` - Kubernetes client
276/// * `namespace` - Namespace of the DNSZone
277/// * `name` - Name of the DNSZone
278///
279/// # Returns
280/// The freshly fetched DNSZone with current status
281///
282/// # Errors
283/// Returns an error if the Kubernetes API call fails
284async fn refetch_zone(client: &kube::Client, namespace: &str, name: &str) -> Result<DNSZone> {
285    let zones_api: Api<DNSZone> = Api::namespaced(client.clone(), namespace);
286    let zone = zones_api.get(name).await?;
287    Ok(zone)
288}
289
290/// Handle duplicate zone conflicts by setting Ready=False and stopping reconciliation.
291///
292/// When a duplicate zone is detected, this function:
293/// 1. Logs a warning with details about the conflict
294/// 2. Updates the status with Ready=False and DuplicateZone condition
295/// 3. Applies the status to the API server
296///
297/// # Arguments
298/// * `client` - Kubernetes client
299/// * `namespace` - Namespace of the conflicting DNSZone
300/// * `name` - Name of the conflicting DNSZone
301/// * `duplicate_info` - Information about the duplicate zone conflict
302/// * `status_updater` - Status updater to apply the condition
303///
304/// # Errors
305/// Returns an error if the status update fails
306async fn handle_duplicate_zone(
307    client: &kube::Client,
308    namespace: &str,
309    name: &str,
310    duplicate_info: &DuplicateZoneInfo,
311    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
312) -> Result<()> {
313    warn!(
314        "Duplicate zone detected: {}/{} cannot claim '{}' because it is already configured by: {:?}",
315        namespace, name, duplicate_info.zone_name, duplicate_info.conflicting_zones
316    );
317
318    // Build list of conflicting zones in namespace/name format
319    let conflicting_zone_refs: Vec<String> = duplicate_info
320        .conflicting_zones
321        .iter()
322        .map(|z| format!("{}/{}", z.namespace, z.name))
323        .collect();
324
325    // Set Ready=False with DuplicateZone reason
326    status_updater.set_duplicate_zone_condition(&duplicate_info.zone_name, &conflicting_zone_refs);
327
328    // Apply status and stop processing
329    status_updater.apply(client).await?;
330
331    Ok(())
332}
333
334/// Detect if the zone spec has changed since last reconciliation.
335///
336/// Compares current generation with observed generation to determine
337/// if this is first reconciliation or if spec changed.
338///
339/// # Arguments
340///
341/// * `zone` - The DNSZone resource
342///
343/// # Returns
344///
345/// Tuple of (first_reconciliation, spec_changed)
346fn detect_spec_changes(zone: &DNSZone) -> (bool, bool) {
347    let current_generation = zone.metadata.generation;
348    let observed_generation = zone.status.as_ref().and_then(|s| s.observed_generation);
349
350    let first_reconciliation = observed_generation.is_none();
351    let spec_changed =
352        crate::reconcilers::should_reconcile(current_generation, observed_generation);
353
354    (first_reconciliation, spec_changed)
355}
356
357/// Detect if the instance list changed between watch event and re-fetch.
358///
359/// This is critical for detecting when:
360/// 1. New instances are added to `status.bind9Instances` (via `bind9InstancesFrom` selectors)
361/// 2. Instance `lastReconciledAt` timestamps are cleared (e.g., instance deleted, needs reconfiguration)
362///
363/// NOTE: `InstanceReference` `PartialEq` ignores `lastReconciledAt`, so we must check timestamps separately!
364///
365/// # Arguments
366///
367/// * `namespace` - Namespace for logging
368/// * `name` - Zone name for logging
369/// * `watch_instances` - Instances from the watch event that triggered reconciliation
370/// * `current_instances` - Instances after re-fetching (current state)
371///
372/// # Returns
373///
374/// `true` if instances changed (list or timestamps), `false` otherwise
375fn detect_instance_changes(
376    namespace: &str,
377    name: &str,
378    watch_instances: Option<&Vec<crate::crd::InstanceReference>>,
379    current_instances: &[crate::crd::InstanceReference],
380) -> bool {
381    let Some(watch_instances) = watch_instances else {
382        // No instances in watch event, first reconciliation or error
383        return true;
384    };
385
386    // Get the instance names from the watch event (what triggered us)
387    let watch_instance_names: std::collections::HashSet<_> =
388        watch_instances.iter().map(|r| &r.name).collect();
389
390    // Get the instance names after re-fetching (current state)
391    let current_instance_names: std::collections::HashSet<_> =
392        current_instances.iter().map(|r| &r.name).collect();
393
394    // Check if instance list changed (added/removed instances)
395    let list_changed = watch_instance_names != current_instance_names;
396
397    if list_changed {
398        info!(
399            "Instance list changed during reconciliation for zone {}/{}: watch_event={:?}, current={:?}",
400            namespace, name, watch_instance_names, current_instance_names
401        );
402        return true;
403    }
404
405    // List is the same, but check if any lastReconciledAt timestamps changed
406    // Use InstanceReference as HashMap key (uses its Hash impl which hashes identity fields)
407    let watch_timestamps: std::collections::HashMap<&crate::crd::InstanceReference, Option<&str>> =
408        watch_instances
409            .iter()
410            .map(|inst| (inst, inst.last_reconciled_at.as_deref()))
411            .collect();
412
413    let current_timestamps: std::collections::HashMap<
414        &crate::crd::InstanceReference,
415        Option<&str>,
416    > = current_instances
417        .iter()
418        .map(|inst| (inst, inst.last_reconciled_at.as_deref()))
419        .collect();
420
421    let timestamps_changed = watch_timestamps.iter().any(|(inst_ref, watch_ts)| {
422        current_timestamps
423            .get(inst_ref)
424            .is_some_and(|current_ts| current_ts != watch_ts)
425    });
426
427    if timestamps_changed {
428        info!(
429            "Instance lastReconciledAt timestamps changed for zone {}/{}",
430            namespace, name
431        );
432    }
433
434    timestamps_changed
435}
436
437/// Reconciles a `DNSZone` resource.
438///
439/// Creates or updates DNS zone files on BIND9 instances that match the zone's
440/// instance selector. Supports both primary and secondary zone types.
441///
442/// # Zone Types
443///
444/// - **Primary**: Authoritative zone with SOA record and local zone file
445/// - **Secondary**: Replica zone that transfers from primary servers
446///
447/// # Arguments
448///
449/// * `client` - Kubernetes API client for finding matching `Bind9Instances`
450/// * `dnszone` - The `DNSZone` resource to reconcile
451/// * `zone_manager` - BIND9 manager for creating zone files
452///
453/// # Returns
454///
455/// * `Ok(())` - If zone was created/updated successfully
456/// * `Err(_)` - If zone creation failed or configuration is invalid
457///
458/// # Example
459///
460/// ```rust,no_run,ignore
461/// use bindy::reconcilers::reconcile_dnszone;
462/// use bindy::crd::DNSZone;
463/// use bindy::bind9::Bind9Manager;
464/// use bindy::context::Context;
465/// use std::sync::Arc;
466///
467/// async fn handle_zone(ctx: Arc<Context>, zone: DNSZone) -> anyhow::Result<()> {
468///     let manager = Bind9Manager::new();
469///     reconcile_dnszone(ctx, zone, &manager).await?;
470///     Ok(())
471/// }
472/// ```
473///
474/// # Errors
475///
476/// Returns an error if Kubernetes API operations fail or BIND9 zone operations fail.
477#[allow(clippy::too_many_lines)]
478pub async fn reconcile_dnszone(
479    ctx: Arc<crate::context::Context>,
480    dnszone: DNSZone,
481    zone_manager: &crate::bind9::Bind9Manager,
482) -> Result<()> {
483    let client = ctx.client.clone();
484    let bind9_instances_store = &ctx.stores.bind9_instances;
485
486    let namespace = dnszone.namespace().unwrap_or_default();
487    let name = dnszone.name_any();
488
489    info!("Reconciling DNSZone: {}/{}", namespace, name);
490    debug!(
491        namespace = %namespace,
492        name = %name,
493        generation = ?dnszone.metadata.generation,
494        "Starting DNSZone reconciliation"
495    );
496
497    // Save the instance list from the watch event (before re-fetching)
498    // This represents the instances that triggered this reconciliation
499    let watch_event_instances =
500        validation::get_instances_from_zone(&dnszone, bind9_instances_store).ok();
501
502    // CRITICAL: Re-fetch the zone to get the latest status
503    let dnszone = refetch_zone(&client, &namespace, &name).await?;
504
505    // Create centralized status updater to batch all status changes
506    let mut status_updater = crate::reconcilers::status::DNSZoneStatusUpdater::new(&dnszone);
507
508    // Extract spec
509    let spec = &dnszone.spec;
510
511    // Validate that zone has instances assigned (via spec.bind9Instances or status.bind9Instances)
512    // This will fail early if zone is not selected by any instance
513    let instance_refs = validation::get_instances_from_zone(&dnszone, bind9_instances_store)?;
514
515    info!(
516        "DNSZone {}/{} is assigned to {} instance(s): {:?}",
517        namespace,
518        name,
519        instance_refs.len(),
520        instance_refs.iter().map(|r| &r.name).collect::<Vec<_>>()
521    );
522
523    // CRITICAL: Check for duplicate zones BEFORE any configuration
524    // If another zone already claims this zone name, set Ready=False with DuplicateZone reason
525    // and stop processing to prevent conflicting DNS configurations
526    let zones_store = &ctx.stores.dnszones;
527    if let Some(duplicate_info) = validation::check_for_duplicate_zones(&dnszone, zones_store) {
528        handle_duplicate_zone(
529            &client,
530            &namespace,
531            &name,
532            &duplicate_info,
533            &mut status_updater,
534        )
535        .await?;
536        return Ok(());
537    }
538
539    // Determine if this is the first reconciliation or if spec has changed
540    let (first_reconciliation, spec_changed) = detect_spec_changes(&dnszone);
541
542    // Check if the instance list or lastReconciledAt timestamps changed between watch event and re-fetch
543    let instances_changed = detect_instance_changes(
544        &namespace,
545        &name,
546        watch_event_instances.as_ref(),
547        &instance_refs,
548    );
549
550    // Check if any instances need reconciliation (never reconciled or reconciliation failed)
551    let unreconciled_instances =
552        validation::filter_instances_needing_reconciliation(&instance_refs);
553    let has_unreconciled_instances = !unreconciled_instances.is_empty();
554
555    if has_unreconciled_instances {
556        info!(
557            "Found {} unreconciled instance(s) for zone {}/{}: {:?}",
558            unreconciled_instances.len(),
559            namespace,
560            name,
561            unreconciled_instances
562                .iter()
563                .map(|i| format!("{}/{}", i.namespace, i.name))
564                .collect::<Vec<_>>()
565        );
566    } else {
567        debug!(
568            "No unreconciled instances for zone {}/{} - all {} instance(s) already configured (lastReconciledAt set)",
569            namespace,
570            name,
571            instance_refs.len()
572        );
573    }
574
575    // CRITICAL: Cleanup deleted instances BEFORE early return check
576    // If we skip reconciliation due to no changes, we still need to remove deleted instances from status
577    match cleanup::cleanup_deleted_instances(&client, &dnszone, &mut status_updater).await {
578        Ok(deleted_count) if deleted_count > 0 => {
579            info!(
580                "Cleaned up {} deleted instance(s) from zone {}/{} status",
581                deleted_count, namespace, name
582            );
583        }
584        Ok(_) => {
585            debug!(
586                "No deleted instances found for zone {}/{} status",
587                namespace, name
588            );
589        }
590        Err(e) => {
591            warn!(
592                "Failed to cleanup deleted instances for zone {}/{}: {} (continuing with reconciliation)",
593                namespace, name, e
594            );
595            // Don't fail reconciliation for cleanup errors
596        }
597    }
598
599    // CRITICAL: We CANNOT skip reconciliation entirely, even if spec and instances haven't changed.
600    // Reconciliation may be triggered by ARecord/AAAA/TXT/etc changes via watches, and we MUST
601    // run record discovery to tag newly created records with status.zoneRef.
602    //
603    // However, we CAN skip BIND9 configuration if nothing changed (handled later in the flow).
604    // This ensures record discovery ALWAYS runs while still optimizing BIND9 API calls.
605
606    if instances_changed {
607        info!(
608            "Instances changed for zone {}/{} - reconciling to configure new instances",
609            namespace, name
610        );
611    }
612
613    info!(
614        "Reconciling zone {} (first_reconciliation={}, spec_changed={})",
615        spec.zone_name, first_reconciliation, spec_changed
616    );
617
618    // Cleanup stale records from status.records[] before main reconciliation
619    // This ensures status stays in sync with actual Kubernetes resources
620    match cleanup::cleanup_stale_records(
621        &client,
622        &dnszone,
623        &mut status_updater,
624        bind9_instances_store,
625    )
626    .await
627    {
628        Ok(stale_count) if stale_count > 0 => {
629            info!(
630                "Cleaned up {} stale record(s) from zone {}/{} status",
631                stale_count, namespace, name
632            );
633        }
634        Ok(_) => {
635            debug!(
636                "No stale records found in zone {}/{} status",
637                namespace, name
638            );
639        }
640        Err(e) => {
641            warn!(
642                "Failed to cleanup stale records for zone {}/{}: {} (continuing with reconciliation)",
643                namespace, name, e
644            );
645            // Don't fail reconciliation for cleanup errors
646        }
647    }
648
649    // BIND9 configuration: Always ensure zones exist on all instances
650    // This implements true declarative reconciliation - if a pod restarts without
651    // persistent storage, the reconciler will detect the missing zone and recreate it.
652    // The add_zones() function is idempotent, so this is safe to call every reconciliation.
653    //
654    // NOTE: We ALWAYS configure zones, not just when spec changes. This ensures:
655    // - Zones are recreated if pods restart without persistent volumes
656    // - New instances added to the cluster get zones automatically
657    // - Drift detection: if someone manually deletes a zone, it's recreated
658    // - True Kubernetes declarative reconciliation: actual state continuously matches desired state
659    let (primary_outcome, secondary_outcome) = bind9_config::configure_zone_on_instances(
660        ctx.clone(),
661        &dnszone,
662        zone_manager,
663        &mut status_updater,
664        &instance_refs,
665        &unreconciled_instances,
666    )
667    .await?;
668
669    // Discover DNS records and update status
670    let (record_refs, records_count) =
671        discovery::discover_and_update_records(&client, &dnszone, &mut status_updater, &ctx.stores)
672            .await?;
673
674    // Replay the zone's records whenever the zone had to be (re)created on any
675    // server, or a previous replay did not finish. Without this a wiped pod
676    // comes back authoritative for a zone containing only SOA and NS - see
677    // `records::replay_zone_records` for the full rationale.
678    replay_records_if_zone_was_recreated(
679        &ctx,
680        &dnszone,
681        &mut status_updater,
682        &instance_refs,
683        &record_refs,
684        primary_outcome.zones_created + secondary_outcome.zones_created,
685    )
686    .await?;
687
688    // Check if all discovered records are ready and trigger zone transfers if needed
689    if records_count > 0 {
690        let all_records_ready =
691            discovery::check_all_records_ready(&client, &namespace, &record_refs).await?;
692
693        if all_records_ready {
694            info!(
695                "All {} record(s) for zone {} are ready, triggering zone transfers to secondaries",
696                records_count, spec.zone_name
697            );
698
699            // Trigger zone transfers to all secondaries
700            // Zone transfers are triggered automatically by BIND9 via NOTIFY messages
701            // No manual trigger needed in the new architecture
702            info!(
703                "Zone {} configured on instances - BIND9 will handle zone transfers via NOTIFY",
704                spec.zone_name
705            );
706        } else {
707            info!("Not all records for zone {} are ready yet", spec.zone_name);
708        }
709    }
710    // Calculate expected counts and finalize status
711    let (expected_primary_count, expected_secondary_count) =
712        status_helpers::calculate_expected_instance_counts(&client, &instance_refs).await?;
713
714    status_helpers::finalize_zone_status(
715        &mut status_updater,
716        &client,
717        &spec.zone_name,
718        &namespace,
719        &name,
720        primary_outcome,
721        secondary_outcome,
722        expected_primary_count,
723        expected_secondary_count,
724        records_count,
725        dnszone.metadata.generation,
726    )
727    .await?;
728
729    Ok(())
730}
731
732/// Replay all of a zone's records into BIND9 when the zone was (re)created.
733///
734/// # Why
735///
736/// BIND9 operand pods keep zone data in ephemeral storage. Any event that
737/// replaces a pod - an operator upgrade, a `placement` change that rolls the
738/// Deployment, an eviction, a node reboot, a manual `kubectl delete pod` -
739/// brings the pod back with no zones. The zone reconciler then recreates the
740/// zone from `spec`, which yields SOA and NS records ONLY. The pod is now
741/// *authoritative* for a zone with no data: it answers authoritative NXDOMAIN,
742/// or, when `global.recursion` and `global.forwarders` are set, silently
743/// forwards the query and returns the PUBLIC answer for an internal name.
744///
745/// Nothing about the record CRs changed, so their own controllers have no
746/// reason to act. This function is the missing link: the moment the zone
747/// reconciler observes that it created a zone, it pushes every record CR the
748/// zone selects back into BIND9.
749///
750/// The intent is recorded in `status.recordsResyncPending` before the replay is
751/// attempted, so an operator crash mid-replay, or a partial failure, is retried
752/// on the next reconciliation instead of being forgotten. While the flag is set
753/// the zone reports `Ready=False` / `Degraded=True`, so a server authoritative
754/// for an empty zone is never advertised as healthy.
755///
756/// # Arguments
757///
758/// * `ctx` - Application context (Kubernetes client and reflector stores)
759/// * `dnszone` - The zone being reconciled
760/// * `status_updater` - Status updater collecting in-memory condition changes
761/// * `instance_refs` - All instances assigned to the zone
762/// * `record_refs` - The records just discovered for the zone
763/// * `zones_created` - Number of endpoints where the zone was newly created
764///
765/// # Errors
766///
767/// Returns an error if the PRIMARY instances cannot be determined. Individual
768/// record push failures are reported through the `Degraded` condition and
769/// retried on the next reconciliation rather than aborting the zone reconcile.
770async fn replay_records_if_zone_was_recreated(
771    ctx: &Arc<crate::context::Context>,
772    dnszone: &DNSZone,
773    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
774    instance_refs: &[crate::crd::InstanceReference],
775    record_refs: &[crate::crd::RecordReferenceWithTimestamp],
776    zones_created: usize,
777) -> Result<()> {
778    let client = ctx.client.clone();
779    let namespace = dnszone.namespace().unwrap_or_default();
780    let name = dnszone.name_any();
781    let zone_name = &dnszone.spec.zone_name;
782
783    // A replay left over from a previous reconciliation is just as binding as
784    // one triggered right now.
785    let resync_outstanding = dnszone
786        .status
787        .as_ref()
788        .is_some_and(|status| status.records_resync_pending);
789
790    if zones_created == 0 && !resync_outstanding {
791        return Ok(());
792    }
793
794    if zones_created > 0 {
795        warn!(
796            "Zone {} was created on {} endpoint(s) during this reconciliation of DNSZone {}/{} - \
797             those servers hold SOA and NS records only. Replaying {} record(s).",
798            zone_name,
799            zones_created,
800            namespace,
801            name,
802            record_refs.len()
803        );
804    } else {
805        info!(
806            "DNSZone {}/{} still has an outstanding record resync - retrying {} record(s) for zone {}",
807            namespace,
808            name,
809            record_refs.len(),
810            zone_name
811        );
812    }
813
814    // A zone with no records is fully described by its SOA and NS records, so
815    // recreating it already restored the declared state - nothing to replay and
816    // nothing to keep the zone out of Ready.
817    if record_refs.is_empty() {
818        debug!(
819            "Zone {} selects no records - nothing to replay for DNSZone {}/{}",
820            zone_name, namespace, name
821        );
822        status_updater.set_records_resync_pending(false);
823        return Ok(());
824    }
825
826    // Persist the intent BEFORE touching BIND9: if the operator dies mid-replay
827    // the flag survives and the next reconciliation retries.
828    status_updater.set_records_resync_pending(true);
829    status_updater.apply(&client).await?;
830
831    // Records are written to PRIMARY servers only; secondaries pull the zone
832    // via AXFR once the primary's serial advances.
833    let primary_refs = primary::filter_primary_instances(&client, instance_refs).await?;
834
835    if primary_refs.is_empty() {
836        let message = format!(
837            "Zone {zone_name} must replay {} record(s) but has no primary instances to write them to",
838            record_refs.len()
839        );
840        warn!("DNSZone {}/{}: {}", namespace, name, message);
841        status_updater.set_condition("Degraded", "True", "RecordsResyncPending", &message);
842        return Ok(());
843    }
844
845    let outcome = crate::reconcilers::records::replay_zone_records(
846        &client,
847        &ctx.stores,
848        zone_name,
849        record_refs,
850        &primary_refs,
851    )
852    .await;
853
854    if outcome.is_complete() {
855        info!(
856            "Record resync complete for DNSZone {}/{}: {}",
857            namespace,
858            name,
859            outcome.summary(zone_name)
860        );
861        status_updater.set_records_resync_pending(false);
862        return Ok(());
863    }
864
865    let message = outcome.summary(zone_name);
866    warn!(
867        "Record resync incomplete for DNSZone {}/{}: {} - zone stays Degraded and will be retried",
868        namespace, name, message
869    );
870    status_updater.set_condition("Degraded", "True", "RecordsResyncPending", &message);
871
872    Ok(())
873}
874
875/// Adds a DNS zone to all primary instances.
876///
877/// # Arguments
878///
879/// * `client` - Kubernetes API client
880/// * `dnszone` - The `DNSZone` resource
881/// * `zone_manager` - BIND9 manager for adding zone
882///
883/// # Returns
884///
885/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts.
886///   An instance counts as configured only if ALL of its ready endpoints accepted
887///   the zone, so the instance count is directly comparable with the expected
888///   PRIMARY instance count when computing readiness.
889/// * `Err(_)` - If zone addition failed on every endpoint
890///
891/// # Errors
892///
893/// Returns an error if BIND9 zone addition fails or if no instances are assigned.
894///
895/// # Panics
896///
897/// Panics if the RNDC key is not loaded by the helper function (should never happen in practice).
898#[allow(clippy::too_many_lines)]
899pub async fn add_dnszone(
900    ctx: Arc<crate::context::Context>,
901    dnszone: DNSZone,
902    zone_manager: &crate::bind9::Bind9Manager,
903    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
904    instance_refs: &[crate::crd::InstanceReference],
905) -> Result<types::ZoneConfigOutcome> {
906    let client = ctx.client.clone();
907    let namespace = dnszone.namespace().unwrap_or_default();
908    let name = dnszone.name_any();
909    let spec = &dnszone.spec;
910
911    info!("Adding DNSZone {}/{}", namespace, name);
912
913    // PHASE 2 OPTIMIZATION: Use the filtered instance list passed by the caller
914    // This ensures we only process instances that need reconciliation (lastReconciledAt == None)
915
916    info!(
917        "DNSZone {}/{} will be added to {} instance(s): {:?}",
918        namespace,
919        name,
920        instance_refs.len(),
921        instance_refs
922            .iter()
923            .map(|i| format!("{}/{}", i.namespace, i.name))
924            .collect::<Vec<_>>()
925    );
926
927    // Filter to only PRIMARY instances
928    let primary_instance_refs = primary::filter_primary_instances(&client, instance_refs).await?;
929
930    if primary_instance_refs.is_empty() {
931        return Err(anyhow!(
932            "DNSZone {}/{} has no PRIMARY instances assigned. Instances: {:?}",
933            namespace,
934            name,
935            instance_refs
936                .iter()
937                .map(|i| format!("{}/{}", i.namespace, i.name))
938                .collect::<Vec<_>>()
939        ));
940    }
941
942    info!(
943        "Found {} PRIMARY instance(s) for DNSZone {}/{}",
944        primary_instance_refs.len(),
945        namespace,
946        name
947    );
948
949    // Find all secondary instances for zone transfer configuration
950    let secondary_instance_refs =
951        secondary::filter_secondary_instances(&client, instance_refs).await?;
952    let secondary_ips =
953        secondary::find_secondary_pod_ips_from_instances(&client, &secondary_instance_refs).await?;
954
955    if secondary_ips.is_empty() {
956        warn!(
957            "No secondary servers found for DNSZone {}/{} - zone transfers will not be configured",
958            namespace, name
959        );
960    } else {
961        info!(
962            "Found {} secondary server(s) for DNSZone {}/{} - zone transfers will be configured: {:?}",
963            secondary_ips.len(),
964            namespace,
965            name,
966            secondary_ips
967        );
968    }
969
970    // Get effective nameservers (supports both new `nameServers` and deprecated `nameServerIps`)
971    let effective_name_servers = get_effective_name_servers(spec);
972
973    // Generate legacy nameserver IPs format for backward compatibility with bindcar API
974    // If user didn't provide either field, auto-generate from instance IPs
975    let name_server_ips = if effective_name_servers.is_none() {
976        info!(
977            "DNSZone {}/{} has no explicit nameServers - auto-generating from {} instance(s)",
978            namespace,
979            name,
980            instance_refs.len()
981        );
982
983        // Build ordered list: primaries first, then secondaries
984        let mut ordered_instances = primary_instance_refs.clone();
985        ordered_instances.extend(secondary_instance_refs.clone());
986
987        match generate_nameserver_ips(&client, &spec.zone_name, &ordered_instances).await {
988            Ok(Some(generated_ips)) => {
989                info!(
990                    "Auto-generated {} nameserver(s) for DNSZone {}/{}: {:?}",
991                    generated_ips.len(),
992                    namespace,
993                    name,
994                    generated_ips
995                );
996                Some(generated_ips)
997            }
998            Ok(None) => {
999                warn!(
1000                    "Failed to auto-generate nameserver IPs for DNSZone {}/{} - no IPs available",
1001                    namespace, name
1002                );
1003                None
1004            }
1005            Err(e) => {
1006                warn!(
1007                    "Error auto-generating nameserver IPs for DNSZone {}/{}: {}",
1008                    namespace, name, e
1009                );
1010                None
1011            }
1012        }
1013    } else {
1014        // Convert effective_name_servers to HashMap<String, String> for bindcar API compatibility
1015        // Only include IPv4 addresses (bindcar doesn't support IPv6 glue records in this field)
1016        // SAFETY: We know effective_name_servers is Some because we're in the else block
1017        let name_server_map: HashMap<String, String> =
1018            if let Some(ref ns_list) = effective_name_servers {
1019                ns_list
1020                    .iter()
1021                    .filter_map(|ns| {
1022                        ns.ipv4_address
1023                            .as_ref()
1024                            .map(|ip| (ns.hostname.clone(), ip.clone()))
1025                    })
1026                    .collect()
1027            } else {
1028                HashMap::new()
1029            };
1030
1031        info!(
1032            "Using explicit nameServers for DNSZone {}/{} ({} with IPv4 glue records)",
1033            namespace,
1034            name,
1035            name_server_map.len()
1036        );
1037
1038        if name_server_map.is_empty() {
1039            None
1040        } else {
1041            Some(name_server_map)
1042        }
1043    };
1044
1045    // Extract list of ALL nameserver hostnames (primary from SOA + all from nameServers field)
1046    // This is used by bindcar to generate NS records in the zone file
1047    let all_nameserver_hostnames: Vec<String> = {
1048        let mut hostnames = vec![spec.soa_record.primary_ns.clone()];
1049
1050        if let Some(ref ns_list) = effective_name_servers {
1051            for ns in ns_list {
1052                // Avoid duplicates - don't add primary NS again if it's in the list
1053                if ns.hostname != spec.soa_record.primary_ns {
1054                    hostnames.push(ns.hostname.clone());
1055                }
1056            }
1057        }
1058
1059        hostnames
1060    };
1061
1062    info!(
1063        "Zone {}/{} will be configured with {} nameserver(s): {:?}",
1064        namespace,
1065        name,
1066        all_nameserver_hostnames.len(),
1067        all_nameserver_hostnames
1068    );
1069
1070    // Extract DNSSEC policy if configured
1071    let dnssec_policy = spec.dnssec_policy.as_deref();
1072    if let Some(policy) = dnssec_policy {
1073        info!(
1074            "DNSSEC policy '{}' will be applied to zone {}/{}",
1075            policy, namespace, name
1076        );
1077    }
1078
1079    // Process all primary instances concurrently using async streams
1080    // Mark each instance as reconciled immediately after first successful endpoint configuration
1081    let first_endpoint = Arc::new(Mutex::new(None::<String>));
1082    let total_endpoints = Arc::new(Mutex::new(0_usize));
1083    // Endpoints where the zone did NOT exist and had to be created. A created
1084    // zone holds only SOA + NS, so every one of these endpoints is missing all
1085    // of the zone's record data and needs a replay.
1086    let zones_created = Arc::new(Mutex::new(0_usize));
1087    let errors = Arc::new(Mutex::new(Vec::<String>::new()));
1088    let status_updater_shared = Arc::new(Mutex::new(status_updater));
1089
1090    // Create a stream of futures for all instances.
1091    // Each instance future resolves to `true` only if EVERY endpoint of that
1092    // instance accepted the zone (added or already present) - this is the
1093    // per-INSTANCE success signal used for readiness computation.
1094    let instance_results = stream::iter(primary_instance_refs.iter())
1095        .then(|instance_ref| {
1096            let client = client.clone();
1097            let zone_manager = zone_manager.clone();
1098            let zone_name = spec.zone_name.clone();
1099            let soa_record = spec.soa_record.clone();
1100            let all_nameserver_hostnames = all_nameserver_hostnames.clone();
1101            let name_server_ips = name_server_ips.clone();
1102            let secondary_ips = secondary_ips.clone();
1103            let first_endpoint = Arc::clone(&first_endpoint);
1104            let total_endpoints = Arc::clone(&total_endpoints);
1105            let zones_created = Arc::clone(&zones_created);
1106            let errors = Arc::clone(&errors);
1107            let status_updater_shared = Arc::clone(&status_updater_shared);
1108            let instance_ref = instance_ref.clone();
1109            let _zone_namespace = namespace.clone();
1110            let _zone_name_ref = name.clone();
1111
1112            async move {
1113                info!(
1114                    "Processing endpoints for primary instance {}/{}",
1115                    instance_ref.namespace, instance_ref.name
1116                );
1117
1118                // Load RNDC key for this specific instance
1119                let key_data = match helpers::load_rndc_key(&client, &instance_ref.namespace, &instance_ref.name).await {
1120                    Ok(key) => key,
1121                    Err(e) => {
1122                        let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name);
1123                        errors.lock().await.push(err_msg);
1124                        return false;
1125                    }
1126                };
1127
1128                // Get all endpoints for this instance
1129                let endpoints = match helpers::get_endpoint(&client, &instance_ref.namespace, &instance_ref.name, "http").await {
1130                    Ok(eps) => eps,
1131                    Err(e) => {
1132                        let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name);
1133                        errors.lock().await.push(err_msg);
1134                        return false;
1135                    }
1136                };
1137
1138                info!(
1139                    "Found {} endpoint(s) for primary instance {}/{}",
1140                    endpoints.len(),
1141                    instance_ref.namespace,
1142                    instance_ref.name
1143                );
1144
1145                // Process endpoints concurrently for this instance
1146                let endpoint_results = stream::iter(endpoints.iter())
1147                    .then(|endpoint| {
1148                        let zone_manager = zone_manager.clone();
1149                        let zone_name = zone_name.clone();
1150                        let key_data = key_data.clone();
1151                        let soa_record = soa_record.clone();
1152                        let all_nameserver_hostnames = all_nameserver_hostnames.clone();
1153                        let name_server_ips = name_server_ips.clone();
1154                        let secondary_ips = secondary_ips.clone();
1155                        let first_endpoint = Arc::clone(&first_endpoint);
1156                        let total_endpoints = Arc::clone(&total_endpoints);
1157                        let zones_created = Arc::clone(&zones_created);
1158                        let errors = Arc::clone(&errors);
1159                        let instance_ref = instance_ref.clone();
1160                        let endpoint = endpoint.clone();
1161
1162                        async move {
1163                            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1164
1165                            // Save the first endpoint (globally)
1166                            {
1167                                let mut first = first_endpoint.lock().await;
1168                                if first.is_none() {
1169                                    *first = Some(pod_endpoint.clone());
1170                                }
1171                            }
1172
1173                            // Check if zone already exists before attempting creation
1174                            let zone_exists = match zone_manager.zone_exists(&zone_name, &pod_endpoint).await {
1175                                Ok(exists) => exists,
1176                                Err(e) => {
1177                                    error!(
1178                                        "Failed to check if zone {} exists on endpoint {} (instance {}/{}): {}",
1179                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1180                                    );
1181                                    // Treat errors as "zone might not exist" - proceed with add_zones
1182                                    false
1183                                }
1184                            };
1185
1186                            if zone_exists {
1187                                debug!(
1188                                    "Zone {} already exists on endpoint {} (instance {}/{}), skipping creation",
1189                                    zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1190                                );
1191                                *total_endpoints.lock().await += 1;
1192                                // Return false to indicate zone was not newly added
1193                                return Ok(false);
1194                            }
1195
1196                            // Pass secondary IPs for zone transfer configuration
1197                            let secondary_ips_ref = if secondary_ips.is_empty() {
1198                                None
1199                            } else {
1200                                Some(secondary_ips.as_slice())
1201                            };
1202
1203                            match zone_manager
1204                                .add_zones(
1205                                    &zone_name,
1206                                    ZONE_TYPE_PRIMARY,
1207                                    &pod_endpoint,
1208                                    &key_data,
1209                                    Some(&soa_record),
1210                                    Some(&all_nameserver_hostnames),
1211                                    name_server_ips.as_ref(),
1212                                    secondary_ips_ref,
1213                                    None, // primary_ips only for secondary zones
1214                                    dnssec_policy,
1215                                )
1216                                .await
1217                            {
1218                                Ok(was_added) => {
1219                                    if was_added {
1220                                        // The zone was absent from this pod and has just been
1221                                        // recreated from spec - it currently holds only SOA and
1222                                        // NS records, so the pod is authoritative for an empty
1223                                        // zone until the records are replayed.
1224                                        warn!(
1225                                            "Zone {} was MISSING on endpoint {} (instance: {}/{}) and has been recreated \
1226                                             with SOA and NS records only - all records for this zone will be replayed",
1227                                            zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1228                                        );
1229                                        *zones_created.lock().await += 1;
1230                                    }
1231                                    *total_endpoints.lock().await += 1;
1232                                    // Return was_added so we can check if zone was actually configured
1233                                    Ok(was_added)
1234                                }
1235                                Err(e) => {
1236                                    error!(
1237                                        "Failed to add zone {} to endpoint {} (instance {}/{}): {}",
1238                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1239                                    );
1240                                    errors.lock().await.push(format!(
1241                                        "endpoint {pod_endpoint} (instance {}/{}): {e}",
1242                                        instance_ref.namespace, instance_ref.name
1243                                    ));
1244                                    Err(())
1245                                }
1246                            }
1247                        }
1248                    })
1249                    .collect::<Vec<Result<bool, ()>>>()
1250                    .await;
1251
1252                // Mark this instance as configured ONLY if at least one endpoint actually added the zone
1253                // This prevents updating lastReconciledAt when zone already exists (avoids tight loop)
1254                let zone_was_configured = endpoint_results.iter().any(|r| r.is_ok() && *r.as_ref().unwrap());
1255                if zone_was_configured {
1256                    status_updater_shared
1257                        .lock()
1258                        .await
1259                        .update_instance_status(
1260                            &instance_ref.name,
1261                            &instance_ref.namespace,
1262                            crate::crd::InstanceStatus::Configured,
1263                            Some("Zone successfully configured on primary instance".to_string()),
1264                        );
1265                    info!(
1266                        "Marked primary instance {}/{} as configured for zone {}",
1267                        instance_ref.namespace, instance_ref.name, zone_name
1268                    );
1269                }
1270
1271                // The instance counts as fully configured only if every one of
1272                // its ready endpoints accepted the zone (added OR already
1273                // present). A single failed endpoint means the instance is NOT
1274                // fully serving the zone and must not count towards readiness.
1275                !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok)
1276            }
1277        })
1278        .collect::<Vec<bool>>()
1279        .await;
1280
1281    let instances_configured = instance_results.iter().filter(|ok| **ok).count();
1282
1283    let first_endpoint = Arc::try_unwrap(first_endpoint)
1284        .expect("Failed to unwrap first_endpoint Arc")
1285        .into_inner();
1286    let total_endpoints = Arc::try_unwrap(total_endpoints)
1287        .expect("Failed to unwrap total_endpoints Arc")
1288        .into_inner();
1289    let zones_created = Arc::try_unwrap(zones_created)
1290        .expect("Failed to unwrap zones_created Arc")
1291        .into_inner();
1292    let errors = Arc::try_unwrap(errors)
1293        .expect("Failed to unwrap errors Arc")
1294        .into_inner();
1295    let _status_updater = Arc::try_unwrap(status_updater_shared)
1296        .map_err(|_| anyhow!("Failed to unwrap status_updater - multiple references remain"))?
1297        .into_inner();
1298
1299    // If ALL operations failed, return an error
1300    if total_endpoints == 0 && !errors.is_empty() {
1301        return Err(anyhow!(
1302            "Failed to add zone {} to all primary instances. Errors: {}",
1303            spec.zone_name,
1304            errors.join("; ")
1305        ));
1306    }
1307
1308    info!(
1309        "Successfully added zone {} to {} endpoint(s) across {}/{} fully configured primary instance(s)",
1310        spec.zone_name,
1311        total_endpoints,
1312        instances_configured,
1313        primary_instance_refs.len()
1314    );
1315
1316    // Auto-generate NS records and glue records from nameServers field
1317    if let Some(ref name_servers) = effective_name_servers {
1318        if !name_servers.is_empty() {
1319            info!(
1320                "Auto-generating NS records for {} nameserver(s) in zone {}",
1321                name_servers.len(),
1322                spec.zone_name
1323            );
1324
1325            if let Err(e) = auto_generate_ns_records(
1326                &client,
1327                name_servers,
1328                &spec.zone_name,
1329                spec.ttl,
1330                &primary_instance_refs,
1331            )
1332            .await
1333            {
1334                warn!(
1335                    "Failed to auto-generate some NS records for zone {}: {}. \
1336                     Zone is functional but may have incomplete NS records.",
1337                    spec.zone_name, e
1338                );
1339                // Don't fail reconciliation - zone is functional even without all NS records
1340            }
1341        }
1342    }
1343
1344    // Note: We don't need to reload after addzone because:
1345    // 1. rndc addzone immediately adds the zone to BIND9's running config
1346    // 2. The zone file will be created automatically when records are added via dynamic updates
1347    // 3. Reloading would fail if the zone file doesn't exist yet
1348
1349    // Notify secondaries about the new zone via the first endpoint
1350    // This triggers zone transfer (AXFR) from primary to secondaries
1351    if let Some(first_pod_endpoint) = first_endpoint {
1352        info!("Notifying secondaries about new zone {}", spec.zone_name);
1353        if let Err(e) = zone_manager
1354            .notify_zone(&spec.zone_name, &first_pod_endpoint)
1355            .await
1356        {
1357            // Don't fail if NOTIFY fails - the zone was successfully created
1358            // Secondaries will sync via SOA refresh timer
1359            warn!(
1360                "Failed to notify secondaries for zone {}: {}. Secondaries will sync via SOA refresh timer.",
1361                spec.zone_name, e
1362            );
1363        }
1364    } else {
1365        warn!(
1366            "No endpoints found for zone {}, cannot notify secondaries",
1367            spec.zone_name
1368        );
1369    }
1370
1371    Ok(types::ZoneConfigOutcome {
1372        instances_configured,
1373        endpoints_configured: total_endpoints,
1374        zones_created,
1375    })
1376}
1377
1378/// Adds a DNS zone to all secondary instances in the cluster with primaries configured.
1379///
1380/// Creates secondary zones on all secondary instances, configuring them to transfer
1381/// from the provided primary server IPs. If a zone already exists on a secondary,
1382/// it checks if the primaries list matches and updates it if necessary.
1383///
1384/// # Arguments
1385///
1386/// * `client` - Kubernetes API client
1387/// * `dnszone` - The `DNSZone` resource
1388/// * `zone_manager` - BIND9 manager for adding zone
1389/// * `primary_ips` - List of primary server IPs to configure in the primaries field
1390///
1391/// # Returns
1392///
1393/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts.
1394///   An instance counts as configured only if ALL of its ready endpoints accepted
1395///   the zone, so the instance count is directly comparable with the expected
1396///   SECONDARY instance count when computing readiness.
1397/// * `Err(_)` - If zone addition failed on every endpoint
1398///
1399/// # Errors
1400///
1401/// Returns an error if BIND9 zone addition fails on any secondary instance.
1402///
1403/// # Panics
1404///
1405/// Panics if internal Arc unwrapping fails (should not happen in normal operation).
1406#[allow(clippy::too_many_lines)]
1407pub async fn add_dnszone_to_secondaries(
1408    ctx: Arc<crate::context::Context>,
1409    dnszone: DNSZone,
1410    zone_manager: &crate::bind9::Bind9Manager,
1411    primary_ips: &[String],
1412    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
1413    instance_refs: &[crate::crd::InstanceReference],
1414) -> Result<types::ZoneConfigOutcome> {
1415    let client = ctx.client.clone();
1416    let namespace = dnszone.namespace().unwrap_or_default();
1417    let name = dnszone.name_any();
1418    let spec = &dnszone.spec;
1419
1420    if primary_ips.is_empty() {
1421        warn!(
1422            "No primary IPs provided for secondary zone {}/{} - skipping secondary configuration",
1423            namespace, spec.zone_name
1424        );
1425        return Ok(types::ZoneConfigOutcome::default());
1426    }
1427
1428    info!(
1429        "Adding DNSZone {}/{} to secondary instances with primaries: {:?}",
1430        namespace, name, primary_ips
1431    );
1432
1433    // PHASE 2 OPTIMIZATION: Use the filtered instance list passed by the caller
1434    // This ensures we only process instances that need reconciliation (lastReconciledAt == None)
1435
1436    // Filter to only SECONDARY instances
1437    let secondary_instance_refs =
1438        secondary::filter_secondary_instances(&client, instance_refs).await?;
1439
1440    if secondary_instance_refs.is_empty() {
1441        info!(
1442            "No secondary instances found for DNSZone {}/{} - skipping secondary zone configuration",
1443            namespace, name
1444        );
1445        return Ok(types::ZoneConfigOutcome::default());
1446    }
1447
1448    info!(
1449        "Found {} secondary instance(s) for DNSZone {}/{}",
1450        secondary_instance_refs.len(),
1451        namespace,
1452        name
1453    );
1454
1455    // Process all secondary instances concurrently using async streams
1456    // Mark each instance as reconciled immediately after first successful endpoint configuration
1457    let total_endpoints = Arc::new(Mutex::new(0_usize));
1458    // Endpoints where the secondary zone had to be created (see the primary
1459    // path for why this matters).
1460    let zones_created = Arc::new(Mutex::new(0_usize));
1461    let errors = Arc::new(Mutex::new(Vec::<String>::new()));
1462    let status_updater_shared = Arc::new(Mutex::new(status_updater));
1463
1464    // Create a stream of futures for all secondary instances.
1465    // Each instance future resolves to `true` only if EVERY endpoint of that
1466    // instance accepted the zone (added or already present) - this is the
1467    // per-INSTANCE success signal used for readiness computation.
1468    let instance_results = stream::iter(secondary_instance_refs.iter())
1469        .then(|instance_ref| {
1470            let client = client.clone();
1471            let zone_manager = zone_manager.clone();
1472            let zone_name = spec.zone_name.clone();
1473            let primary_ips = primary_ips.to_vec();
1474            let total_endpoints = Arc::clone(&total_endpoints);
1475            let zones_created = Arc::clone(&zones_created);
1476            let errors = Arc::clone(&errors);
1477            let status_updater_shared = Arc::clone(&status_updater_shared);
1478            let instance_ref = instance_ref.clone();
1479            let _zone_namespace = namespace.clone();
1480            let _zone_name_ref = name.clone();
1481
1482            async move {
1483                info!(
1484                    "Processing secondary instance {}/{} for zone {}",
1485                    instance_ref.namespace, instance_ref.name, zone_name
1486                );
1487
1488                // Load RNDC key for this specific instance
1489                // Each instance has its own RNDC secret for security isolation
1490                let key_data = match helpers::load_rndc_key(&client, &instance_ref.namespace, &instance_ref.name).await {
1491                    Ok(key) => key,
1492                    Err(e) => {
1493                        let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name);
1494                        errors.lock().await.push(err_msg);
1495                        return false;
1496                    }
1497                };
1498
1499                // Get all endpoints for this secondary instance
1500                let endpoints = match helpers::get_endpoint(&client, &instance_ref.namespace, &instance_ref.name, "http").await {
1501                    Ok(eps) => eps,
1502                    Err(e) => {
1503                        let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name);
1504                        errors.lock().await.push(err_msg);
1505                        return false;
1506                    }
1507                };
1508
1509                info!(
1510                    "Found {} endpoint(s) for secondary instance {}/{}",
1511                    endpoints.len(),
1512                    instance_ref.namespace,
1513                    instance_ref.name
1514                );
1515
1516                // Process endpoints concurrently for this instance
1517                let endpoint_results = stream::iter(endpoints.iter())
1518                    .then(|endpoint| {
1519                        let zone_manager = zone_manager.clone();
1520                        let zone_name = zone_name.clone();
1521                        let key_data = key_data.clone();
1522                        let primary_ips = primary_ips.clone();
1523                        let total_endpoints = Arc::clone(&total_endpoints);
1524                        let zones_created = Arc::clone(&zones_created);
1525                        let errors = Arc::clone(&errors);
1526                        let instance_ref = instance_ref.clone();
1527                        let endpoint = endpoint.clone();
1528
1529                        async move {
1530                            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1531
1532                            // Check if zone already exists before attempting creation
1533                            let zone_exists = match zone_manager.zone_exists(&zone_name, &pod_endpoint).await {
1534                                Ok(exists) => exists,
1535                                Err(e) => {
1536                                    error!(
1537                                        "Failed to check if zone {} exists on endpoint {} (instance {}/{}): {}",
1538                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1539                                    );
1540                                    // Treat errors as "zone might not exist" - proceed with add_zones
1541                                    false
1542                                }
1543                            };
1544
1545                            // Variable to track if zone was added
1546                            let was_added = if zone_exists {
1547                                debug!(
1548                                    "Secondary zone {} already exists on endpoint {} (instance {}/{}), skipping creation",
1549                                    zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1550                                );
1551                                *total_endpoints.lock().await += 1;
1552                                false // Zone not newly added
1553                            } else {
1554                                info!(
1555                                    "Adding secondary zone {} to endpoint {} (instance: {}/{}) with primaries: {:?}",
1556                                    zone_name,
1557                                    pod_endpoint,
1558                                    instance_ref.namespace,
1559                                    instance_ref.name,
1560                                    primary_ips
1561                                );
1562
1563                                match zone_manager
1564                                    .add_zones(
1565                                        &zone_name,
1566                                        ZONE_TYPE_SECONDARY,
1567                                        &pod_endpoint,
1568                                        &key_data,
1569                                        None, // No SOA record for secondary zones
1570                                        None, // No name_servers for secondary zones
1571                                        None, // No name_server_ips for secondary zones
1572                                        None, // No secondary_ips for secondary zones
1573                                        Some(&primary_ips),
1574                                        None, // No DNSSEC policy for secondary zones
1575                                    )
1576                                    .await
1577                                {
1578                                    Ok(added) => {
1579                                        if added {
1580                                            info!(
1581                                                "Successfully added secondary zone {} to endpoint {} (instance: {}/{})",
1582                                                zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1583                                            );
1584                                            *zones_created.lock().await += 1;
1585                                        } else {
1586                                            info!(
1587                                                "Secondary zone {} already exists on endpoint {} (instance: {}/{})",
1588                                                zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1589                                            );
1590                                        }
1591                                        *total_endpoints.lock().await += 1;
1592                                        added
1593                                    }
1594                                    Err(e) => {
1595                                        error!(
1596                                            "Failed to add secondary zone {} to endpoint {} (instance {}/{}): {}",
1597                                            zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1598                                        );
1599                                        errors.lock().await.push(format!(
1600                                            "endpoint {pod_endpoint} (instance {}/{}): {e}",
1601                                            instance_ref.namespace, instance_ref.name
1602                                        ));
1603                                        return Err(());
1604                                    }
1605                                }
1606                            };
1607
1608                            // CRITICAL: Immediately trigger zone transfer to load the zone data
1609                            // This is necessary because:
1610                            // 1. `rndc addzone` only adds the zone to BIND9's config (in-memory)
1611                            // 2. The zone file doesn't exist yet on the secondary
1612                            // 3. Queries will return SERVFAIL until data is transferred from primary
1613                            // 4. `rndc retransfer` forces an immediate AXFR from primary to secondary
1614                            //
1615                            // This ensures the zone is LOADED and SERVING queries immediately after
1616                            // secondary pod restart or zone creation.
1617                            // NOTE: We trigger transfer even if zone already existed to ensure it's up to date
1618                            info!(
1619                                "Triggering immediate zone transfer for {} on secondary {} to load zone data",
1620                                zone_name, pod_endpoint
1621                            );
1622                            if let Err(e) = zone_manager
1623                                .retransfer_zone(&zone_name, &pod_endpoint)
1624                                .await
1625                            {
1626                                // Don't fail reconciliation if retransfer fails - zone will sync via SOA refresh
1627                                warn!(
1628                                    "Failed to trigger immediate zone transfer for {} on {}: {}. Zone will sync via SOA refresh timer.",
1629                                    zone_name, pod_endpoint, e
1630                                );
1631                            } else {
1632                                info!(
1633                                    "Successfully triggered zone transfer for {} on {}",
1634                                    zone_name, pod_endpoint
1635                                );
1636                            }
1637
1638                            // Return was_added so we can check if zone was actually configured
1639                            Ok(was_added)
1640                        }
1641                    })
1642                    .collect::<Vec<Result<bool, ()>>>()
1643                    .await;
1644
1645                // Mark this instance as configured ONLY if at least one endpoint actually added the zone
1646                // This prevents updating lastReconciledAt when zone already exists (avoids tight loop)
1647                let zone_was_configured = endpoint_results.iter().any(|r| r.is_ok() && *r.as_ref().unwrap());
1648                if zone_was_configured {
1649                    status_updater_shared
1650                        .lock()
1651                        .await
1652                        .update_instance_status(
1653                            &instance_ref.name,
1654                            &instance_ref.namespace,
1655                            crate::crd::InstanceStatus::Configured,
1656                            Some("Zone successfully configured on secondary instance".to_string()),
1657                        );
1658                    info!(
1659                        "Marked secondary instance {}/{} as configured for zone {}",
1660                        instance_ref.namespace, instance_ref.name, zone_name
1661                    );
1662                }
1663
1664                // The instance counts as fully configured only if every one of
1665                // its ready endpoints accepted the zone (added OR already
1666                // present). A single failed endpoint means the instance is NOT
1667                // fully serving the zone and must not count towards readiness.
1668                !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok)
1669            }
1670        })
1671        .collect::<Vec<bool>>()
1672        .await;
1673
1674    let instances_configured = instance_results.iter().filter(|ok| **ok).count();
1675
1676    let total_endpoints = Arc::try_unwrap(total_endpoints)
1677        .expect("Failed to unwrap total_endpoints Arc")
1678        .into_inner();
1679    let zones_created = Arc::try_unwrap(zones_created)
1680        .expect("Failed to unwrap zones_created Arc")
1681        .into_inner();
1682    let errors = Arc::try_unwrap(errors)
1683        .expect("Failed to unwrap errors Arc")
1684        .into_inner();
1685
1686    // If ALL operations failed, return an error
1687    if total_endpoints == 0 && !errors.is_empty() {
1688        return Err(anyhow!(
1689            "Failed to add zone {} to all secondary instances. Errors: {}",
1690            spec.zone_name,
1691            errors.join("; ")
1692        ));
1693    }
1694
1695    info!(
1696        "Successfully configured secondary zone {} on {} endpoint(s) across {}/{} fully configured secondary instance(s)",
1697        spec.zone_name,
1698        total_endpoints,
1699        instances_configured,
1700        secondary_instance_refs.len()
1701    );
1702
1703    Ok(types::ZoneConfigOutcome {
1704        instances_configured,
1705        endpoints_configured: total_endpoints,
1706        zones_created,
1707    })
1708}
1709
1710/// Deletes a DNS zone and its associated zone files.
1711///
1712/// # Arguments
1713///
1714/// * `_client` - Kubernetes API client (unused, for future extensions)
1715/// * `dnszone` - The `DNSZone` resource to delete
1716/// * `zone_manager` - BIND9 manager for removing zone files
1717///
1718/// # Returns
1719///
1720/// * `Ok(())` - If zone was deleted successfully
1721/// * `Err(_)` - If zone deletion failed
1722///
1723/// # Errors
1724///
1725/// Returns an error if BIND9 zone deletion fails.
1726pub async fn delete_dnszone(
1727    ctx: Arc<crate::context::Context>,
1728    dnszone: DNSZone,
1729    zone_manager: &crate::bind9::Bind9Manager,
1730) -> Result<()> {
1731    let client = ctx.client.clone();
1732    let bind9_instances_store = &ctx.stores.bind9_instances;
1733    let namespace = dnszone.namespace().unwrap_or_default();
1734    let name = dnszone.name_any();
1735    let spec = &dnszone.spec;
1736
1737    info!("Deleting DNSZone {}/{}", namespace, name);
1738
1739    // Get instances from new architecture (spec.bind9Instances or status.bind9Instances)
1740    // If zone has no instances assigned (e.g., orphaned zone), still allow deletion
1741    let instance_refs = match validation::get_instances_from_zone(&dnszone, bind9_instances_store) {
1742        Ok(refs) => refs,
1743        Err(e) => {
1744            warn!(
1745                "DNSZone {}/{} has no instances assigned: {}. Allowing deletion anyway.",
1746                namespace, name, e
1747            );
1748            return Ok(());
1749        }
1750    };
1751
1752    // Filter to primary and secondary instances
1753    let primary_instance_refs = primary::filter_primary_instances(&client, &instance_refs).await?;
1754    let secondary_instance_refs =
1755        secondary::filter_secondary_instances(&client, &instance_refs).await?;
1756
1757    // Delete from all primary instances.
1758    // Deletion cleanup uses SkipUnavailable: an instance with zero ready
1759    // endpoints must not block finalizer removal forever - its DNS data is
1760    // unreachable (and lost anyway with ephemeral storage). Real API errors
1761    // still propagate so the next reconcile retries.
1762    if !primary_instance_refs.is_empty() {
1763        let (_first_endpoint, total_endpoints) = helpers::for_each_instance_endpoint_with_policy(
1764            &client,
1765            &primary_instance_refs,
1766            false, // with_rndc_key = false for zone deletion
1767            "http", // Use HTTP API port for zone deletion via bindcar API
1768            helpers::EndpointFailurePolicy::SkipUnavailable,
1769            |pod_endpoint, instance_name, _rndc_key| {
1770                let zone_name = spec.zone_name.clone();
1771                let zone_manager = zone_manager.clone();
1772
1773                async move {
1774                    info!(
1775                        "Deleting zone {} from endpoint {} (instance: {})",
1776                        zone_name, pod_endpoint, instance_name
1777                    );
1778
1779                    // Attempt to delete zone - if it fails (zone not found, endpoint unreachable, etc.),
1780                    // log a warning but don't fail the deletion. This ensures DNSZones can be deleted
1781                    // even if BIND9 instances are unavailable or the zone was already removed.
1782                    // Pass freeze_before_delete=true for primary zones to prevent updates during deletion
1783                    if let Err(e) = zone_manager.delete_zone(&zone_name, &pod_endpoint, true).await {
1784                        warn!(
1785                            "Failed to delete zone {} from endpoint {} (instance: {}): {}. Continuing with deletion anyway.",
1786                            zone_name, pod_endpoint, instance_name, e
1787                        );
1788                    } else {
1789                        debug!(
1790                            "Successfully deleted zone {} from endpoint {} (instance: {})",
1791                            zone_name, pod_endpoint, instance_name
1792                        );
1793                    }
1794
1795                    Ok(())
1796                }
1797            },
1798        )
1799        .await?;
1800
1801        info!(
1802            "Successfully deleted zone {} from {} primary endpoint(s)",
1803            spec.zone_name, total_endpoints
1804        );
1805    }
1806
1807    // Delete from all secondary instances
1808    if !secondary_instance_refs.is_empty() {
1809        let mut secondary_endpoints_deleted = 0;
1810
1811        for instance_ref in &secondary_instance_refs {
1812            // Deletion cleanup: skip secondary instances with no reachable
1813            // endpoints instead of blocking finalizer removal forever. Real
1814            // (potentially transient) API errors still propagate for retry.
1815            let endpoints = match helpers::get_endpoint(
1816                &client,
1817                &instance_ref.namespace,
1818                &instance_ref.name,
1819                "http",
1820            )
1821            .await
1822            {
1823                Ok(eps) => eps,
1824                Err(e) if helpers::is_unavailable_for_deletion(&e) => {
1825                    warn!(
1826                        "SKIPPING secondary instance {}/{} during zone deletion: no reachable endpoints ({e:#}). \
1827                         Zone data on this instance cannot be cleaned up and may be orphaned.",
1828                        instance_ref.namespace, instance_ref.name
1829                    );
1830                    continue;
1831                }
1832                Err(e) => return Err(e),
1833            };
1834
1835            for endpoint in &endpoints {
1836                let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1837
1838                info!(
1839                    "Deleting zone {} from secondary endpoint {} (instance: {}/{})",
1840                    spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1841                );
1842
1843                // Attempt to delete zone - if it fails, log a warning but don't fail the deletion
1844                // Pass freeze_before_delete=false for secondary zones (they are read-only, no need to freeze)
1845                if let Err(e) = zone_manager
1846                    .delete_zone(&spec.zone_name, &pod_endpoint, false)
1847                    .await
1848                {
1849                    warn!(
1850                        "Failed to delete zone {} from secondary endpoint {} (instance: {}/{}): {}. Continuing with deletion anyway.",
1851                        spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1852                    );
1853                } else {
1854                    debug!(
1855                        "Successfully deleted zone {} from secondary endpoint {} (instance: {}/{})",
1856                        spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1857                    );
1858                    secondary_endpoints_deleted += 1;
1859                }
1860            }
1861        }
1862
1863        info!(
1864            "Successfully deleted zone {} from {} secondary endpoint(s)",
1865            spec.zone_name, secondary_endpoints_deleted
1866        );
1867    }
1868
1869    // Note: We don't need to reload after delzone because:
1870    // 1. rndc delzone immediately removes the zone from BIND9's running config
1871    // 2. BIND9 will clean up the zone file and journal files automatically
1872
1873    Ok(())
1874}
1875
1876/// Auto-generates NS records for all nameservers in the zone.
1877///
1878/// This function is called after zone creation to add NS records for secondary nameservers
1879/// specified in the `nameServers` field. The primary nameserver NS record is already created
1880/// by bindcar during zone initialization (from SOA).
1881///
1882/// # Arguments
1883/// * `client` - Kubernetes client for loading RNDC keys and getting endpoints
1884/// * `effective_name_servers` - List of nameservers from `nameServers` field
1885/// * `zone_name` - The DNS zone name
1886/// * `ttl` - TTL for the NS and glue records
1887/// * `primary_instance_refs` - List of primary instances to update
1888///
1889/// # Returns
1890/// Result indicating success or failure
1891///
1892/// # Errors
1893/// Returns error if NS record or glue record addition fails
1894#[allow(clippy::too_many_lines)]
1895async fn auto_generate_ns_records(
1896    client: &kube::Client,
1897    effective_name_servers: &[crate::crd::NameServer],
1898    zone_name: &str,
1899    ttl: Option<i32>,
1900    primary_instance_refs: &[crate::crd::InstanceReference],
1901) -> Result<()> {
1902    if effective_name_servers.is_empty() {
1903        return Ok(());
1904    }
1905
1906    info!(
1907        "Auto-generating {} NS record(s) for zone {}",
1908        effective_name_servers.len(),
1909        zone_name
1910    );
1911
1912    for nameserver in effective_name_servers {
1913        // Add NS record at zone apex (@)
1914        info!(
1915            "Adding NS record: {} IN NS {}",
1916            zone_name, nameserver.hostname
1917        );
1918
1919        for instance_ref in primary_instance_refs {
1920            // Load RNDC key for this instance
1921            let key_data = match helpers::load_rndc_key(
1922                client,
1923                &instance_ref.namespace,
1924                &instance_ref.name,
1925            )
1926            .await
1927            {
1928                Ok(key) => key,
1929                Err(e) => {
1930                    warn!(
1931                        "Failed to load RNDC key for instance {}/{}: {}. Skipping NS record addition.",
1932                        instance_ref.namespace, instance_ref.name, e
1933                    );
1934                    continue;
1935                }
1936            };
1937
1938            // Get endpoints for this instance
1939            let endpoints = match helpers::get_endpoint(
1940                client,
1941                &instance_ref.namespace,
1942                &instance_ref.name,
1943                "dns-tcp",
1944            )
1945            .await
1946            {
1947                Ok(eps) => eps,
1948                Err(e) => {
1949                    warn!(
1950                        "Failed to get endpoints for instance {}/{}: {}. Skipping NS record addition.",
1951                        instance_ref.namespace, instance_ref.name, e
1952                    );
1953                    continue;
1954                }
1955            };
1956
1957            // Add NS record to all endpoints of this instance
1958            for endpoint in &endpoints {
1959                let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1960
1961                if let Err(e) = crate::bind9::records::ns::add_ns_record(
1962                    zone_name,
1963                    "@", // Zone apex
1964                    &nameserver.hostname,
1965                    ttl,
1966                    &pod_endpoint,
1967                    &key_data,
1968                )
1969                .await
1970                {
1971                    warn!(
1972                        "Failed to add NS record for {} to endpoint {} (instance {}/{}): {}",
1973                        nameserver.hostname,
1974                        pod_endpoint,
1975                        instance_ref.namespace,
1976                        instance_ref.name,
1977                        e
1978                    );
1979                    // Continue with other endpoints - partial success is acceptable
1980                }
1981            }
1982        }
1983
1984        // Add glue records if IPs provided (for in-zone nameservers)
1985        if let Some(ref ipv4) = nameserver.ipv4_address {
1986            add_glue_record(
1987                client,
1988                zone_name,
1989                &nameserver.hostname,
1990                ipv4,
1991                hickory_proto::rr::RecordType::A,
1992                ttl,
1993                primary_instance_refs,
1994            )
1995            .await?;
1996        }
1997
1998        if let Some(ref ipv6) = nameserver.ipv6_address {
1999            add_glue_record(
2000                client,
2001                zone_name,
2002                &nameserver.hostname,
2003                ipv6,
2004                hickory_proto::rr::RecordType::AAAA,
2005                ttl,
2006                primary_instance_refs,
2007            )
2008            .await?;
2009        }
2010    }
2011
2012    info!(
2013        "Successfully auto-generated NS records and glue records for zone {}",
2014        zone_name
2015    );
2016
2017    Ok(())
2018}
2019
2020/// Adds a glue record (A or AAAA) for an in-zone nameserver.
2021///
2022/// Glue records provide IP addresses for nameservers within the zone's own domain.
2023/// This is necessary to avoid circular dependencies when resolving the nameserver itself.
2024///
2025/// # Arguments
2026/// * `client` - Kubernetes client for loading RNDC keys and getting endpoints
2027/// * `zone_name` - The DNS zone name
2028/// * `hostname` - Full nameserver hostname (e.g., "ns2.example.com.")
2029/// * `ip_address` - IP address (IPv4 or IPv6)
2030/// * `record_type` - Type of glue record (A or AAAA)
2031/// * `ttl` - TTL for the glue record
2032/// * `primary_instance_refs` - List of primary instances to update
2033///
2034/// # Returns
2035/// Result indicating success or failure
2036///
2037/// # Errors
2038/// Returns error if glue record addition fails on all instances
2039#[allow(clippy::too_many_lines)]
2040async fn add_glue_record(
2041    client: &kube::Client,
2042    zone_name: &str,
2043    hostname: &str,
2044    ip_address: &str,
2045    record_type: hickory_proto::rr::RecordType,
2046    ttl: Option<i32>,
2047    primary_instance_refs: &[crate::crd::InstanceReference],
2048) -> Result<()> {
2049    // Extract record name from hostname
2050    // Example: "ns2.example.com." in zone "example.com" → name = "ns2"
2051    let name = hostname
2052        .trim_end_matches('.')
2053        .strip_suffix(&format!(".{}", zone_name.trim_end_matches('.')))
2054        .unwrap_or_else(|| hostname.trim_end_matches('.'));
2055
2056    // Check if this is actually an in-zone nameserver
2057    if name == hostname.trim_end_matches('.') {
2058        // Hostname doesn't end with zone name - this is an out-of-zone nameserver
2059        // No glue record needed
2060        debug!(
2061            "Skipping glue record for out-of-zone nameserver {} (not in zone {})",
2062            hostname, zone_name
2063        );
2064        return Ok(());
2065    }
2066
2067    info!(
2068        "Adding {} glue record: {} IN {} {}",
2069        if record_type == hickory_proto::rr::RecordType::A {
2070            "A"
2071        } else {
2072            "AAAA"
2073        },
2074        name,
2075        if record_type == hickory_proto::rr::RecordType::A {
2076            "A"
2077        } else {
2078            "AAAA"
2079        },
2080        ip_address
2081    );
2082
2083    let mut success_count = 0;
2084    let mut errors = Vec::new();
2085
2086    for instance_ref in primary_instance_refs {
2087        // Load RNDC key for this instance
2088        let key_data = match helpers::load_rndc_key(
2089            client,
2090            &instance_ref.namespace,
2091            &instance_ref.name,
2092        )
2093        .await
2094        {
2095            Ok(key) => key,
2096            Err(e) => {
2097                warn!(
2098                    "Failed to load RNDC key for instance {}/{}: {}. Skipping glue record addition.",
2099                    instance_ref.namespace, instance_ref.name, e
2100                );
2101                continue;
2102            }
2103        };
2104
2105        // Get endpoints for this instance
2106        let endpoints = match helpers::get_endpoint(
2107            client,
2108            &instance_ref.namespace,
2109            &instance_ref.name,
2110            "dns-tcp",
2111        )
2112        .await
2113        {
2114            Ok(eps) => eps,
2115            Err(e) => {
2116                warn!(
2117                    "Failed to get endpoints for instance {}/{}: {}. Skipping glue record addition.",
2118                    instance_ref.namespace, instance_ref.name, e
2119                );
2120                continue;
2121            }
2122        };
2123
2124        // Add glue record to all endpoints of this instance
2125        for endpoint in &endpoints {
2126            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
2127
2128            let result = match record_type {
2129                hickory_proto::rr::RecordType::A => {
2130                    crate::bind9::records::a::add_a_record(
2131                        zone_name,
2132                        name,
2133                        &[ip_address.to_string()],
2134                        ttl,
2135                        &pod_endpoint,
2136                        &key_data,
2137                    )
2138                    .await
2139                }
2140                hickory_proto::rr::RecordType::AAAA => {
2141                    crate::bind9::records::a::add_aaaa_record(
2142                        zone_name,
2143                        name,
2144                        &[ip_address.to_string()],
2145                        ttl,
2146                        &pod_endpoint,
2147                        &key_data,
2148                    )
2149                    .await
2150                }
2151                _ => {
2152                    return Err(anyhow::anyhow!(
2153                        "Invalid record type for glue record: {:?}",
2154                        record_type
2155                    ))
2156                }
2157            };
2158
2159            match result {
2160                Ok(()) => {
2161                    success_count += 1;
2162                }
2163                Err(e) => {
2164                    warn!(
2165                        "Failed to add glue record {} to endpoint {} (instance {}/{}): {}",
2166                        name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
2167                    );
2168                    errors.push(format!(
2169                        "endpoint {} (instance {}/{}): {}",
2170                        pod_endpoint, instance_ref.namespace, instance_ref.name, e
2171                    ));
2172                }
2173            }
2174        }
2175    }
2176
2177    // Accept partial success - at least one endpoint updated
2178    if success_count > 0 {
2179        Ok(())
2180    } else {
2181        Err(anyhow::anyhow!(
2182            "Failed to add glue record {} to all instances. Errors: {}",
2183            name,
2184            errors.join("; ")
2185        ))
2186    }
2187}
2188
2189#[cfg(test)]
2190#[path = "dnszone_tests.rs"]
2191mod dnszone_tests;