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    // Check if all discovered records are ready and trigger zone transfers if needed
675    if records_count > 0 {
676        let all_records_ready =
677            discovery::check_all_records_ready(&client, &namespace, &record_refs).await?;
678
679        if all_records_ready {
680            info!(
681                "All {} record(s) for zone {} are ready, triggering zone transfers to secondaries",
682                records_count, spec.zone_name
683            );
684
685            // Trigger zone transfers to all secondaries
686            // Zone transfers are triggered automatically by BIND9 via NOTIFY messages
687            // No manual trigger needed in the new architecture
688            info!(
689                "Zone {} configured on instances - BIND9 will handle zone transfers via NOTIFY",
690                spec.zone_name
691            );
692        } else {
693            info!("Not all records for zone {} are ready yet", spec.zone_name);
694        }
695    }
696    // Calculate expected counts and finalize status
697    let (expected_primary_count, expected_secondary_count) =
698        status_helpers::calculate_expected_instance_counts(&client, &instance_refs).await?;
699
700    status_helpers::finalize_zone_status(
701        &mut status_updater,
702        &client,
703        &spec.zone_name,
704        &namespace,
705        &name,
706        primary_outcome,
707        secondary_outcome,
708        expected_primary_count,
709        expected_secondary_count,
710        records_count,
711        dnszone.metadata.generation,
712    )
713    .await?;
714
715    // Trigger record reconciliation: Update all matching records with a "zone-reconciled" annotation
716    // This ensures records are re-added to BIND9 after pod restarts or zone recreation
717    if !status_updater.has_degraded_condition() {
718        if let Err(e) =
719            discovery::trigger_record_reconciliation(&client, &namespace, &spec.zone_name).await
720        {
721            warn!(
722                "Failed to trigger record reconciliation for zone {}: {}",
723                spec.zone_name, e
724            );
725            // Don't fail the entire reconciliation for this - records will eventually reconcile
726        }
727    }
728
729    Ok(())
730}
731
732/// Adds a DNS zone to all primary instances.
733///
734/// # Arguments
735///
736/// * `client` - Kubernetes API client
737/// * `dnszone` - The `DNSZone` resource
738/// * `zone_manager` - BIND9 manager for adding zone
739///
740/// # Returns
741///
742/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts.
743///   An instance counts as configured only if ALL of its ready endpoints accepted
744///   the zone, so the instance count is directly comparable with the expected
745///   PRIMARY instance count when computing readiness.
746/// * `Err(_)` - If zone addition failed on every endpoint
747///
748/// # Errors
749///
750/// Returns an error if BIND9 zone addition fails or if no instances are assigned.
751///
752/// # Panics
753///
754/// Panics if the RNDC key is not loaded by the helper function (should never happen in practice).
755#[allow(clippy::too_many_lines)]
756pub async fn add_dnszone(
757    ctx: Arc<crate::context::Context>,
758    dnszone: DNSZone,
759    zone_manager: &crate::bind9::Bind9Manager,
760    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
761    instance_refs: &[crate::crd::InstanceReference],
762) -> Result<types::ZoneConfigOutcome> {
763    let client = ctx.client.clone();
764    let namespace = dnszone.namespace().unwrap_or_default();
765    let name = dnszone.name_any();
766    let spec = &dnszone.spec;
767
768    info!("Adding DNSZone {}/{}", namespace, name);
769
770    // PHASE 2 OPTIMIZATION: Use the filtered instance list passed by the caller
771    // This ensures we only process instances that need reconciliation (lastReconciledAt == None)
772
773    info!(
774        "DNSZone {}/{} will be added to {} instance(s): {:?}",
775        namespace,
776        name,
777        instance_refs.len(),
778        instance_refs
779            .iter()
780            .map(|i| format!("{}/{}", i.namespace, i.name))
781            .collect::<Vec<_>>()
782    );
783
784    // Filter to only PRIMARY instances
785    let primary_instance_refs = primary::filter_primary_instances(&client, instance_refs).await?;
786
787    if primary_instance_refs.is_empty() {
788        return Err(anyhow!(
789            "DNSZone {}/{} has no PRIMARY instances assigned. Instances: {:?}",
790            namespace,
791            name,
792            instance_refs
793                .iter()
794                .map(|i| format!("{}/{}", i.namespace, i.name))
795                .collect::<Vec<_>>()
796        ));
797    }
798
799    info!(
800        "Found {} PRIMARY instance(s) for DNSZone {}/{}",
801        primary_instance_refs.len(),
802        namespace,
803        name
804    );
805
806    // Find all secondary instances for zone transfer configuration
807    let secondary_instance_refs =
808        secondary::filter_secondary_instances(&client, instance_refs).await?;
809    let secondary_ips =
810        secondary::find_secondary_pod_ips_from_instances(&client, &secondary_instance_refs).await?;
811
812    if secondary_ips.is_empty() {
813        warn!(
814            "No secondary servers found for DNSZone {}/{} - zone transfers will not be configured",
815            namespace, name
816        );
817    } else {
818        info!(
819            "Found {} secondary server(s) for DNSZone {}/{} - zone transfers will be configured: {:?}",
820            secondary_ips.len(),
821            namespace,
822            name,
823            secondary_ips
824        );
825    }
826
827    // Get effective nameservers (supports both new `nameServers` and deprecated `nameServerIps`)
828    let effective_name_servers = get_effective_name_servers(spec);
829
830    // Generate legacy nameserver IPs format for backward compatibility with bindcar API
831    // If user didn't provide either field, auto-generate from instance IPs
832    let name_server_ips = if effective_name_servers.is_none() {
833        info!(
834            "DNSZone {}/{} has no explicit nameServers - auto-generating from {} instance(s)",
835            namespace,
836            name,
837            instance_refs.len()
838        );
839
840        // Build ordered list: primaries first, then secondaries
841        let mut ordered_instances = primary_instance_refs.clone();
842        ordered_instances.extend(secondary_instance_refs.clone());
843
844        match generate_nameserver_ips(&client, &spec.zone_name, &ordered_instances).await {
845            Ok(Some(generated_ips)) => {
846                info!(
847                    "Auto-generated {} nameserver(s) for DNSZone {}/{}: {:?}",
848                    generated_ips.len(),
849                    namespace,
850                    name,
851                    generated_ips
852                );
853                Some(generated_ips)
854            }
855            Ok(None) => {
856                warn!(
857                    "Failed to auto-generate nameserver IPs for DNSZone {}/{} - no IPs available",
858                    namespace, name
859                );
860                None
861            }
862            Err(e) => {
863                warn!(
864                    "Error auto-generating nameserver IPs for DNSZone {}/{}: {}",
865                    namespace, name, e
866                );
867                None
868            }
869        }
870    } else {
871        // Convert effective_name_servers to HashMap<String, String> for bindcar API compatibility
872        // Only include IPv4 addresses (bindcar doesn't support IPv6 glue records in this field)
873        // SAFETY: We know effective_name_servers is Some because we're in the else block
874        let name_server_map: HashMap<String, String> =
875            if let Some(ref ns_list) = effective_name_servers {
876                ns_list
877                    .iter()
878                    .filter_map(|ns| {
879                        ns.ipv4_address
880                            .as_ref()
881                            .map(|ip| (ns.hostname.clone(), ip.clone()))
882                    })
883                    .collect()
884            } else {
885                HashMap::new()
886            };
887
888        info!(
889            "Using explicit nameServers for DNSZone {}/{} ({} with IPv4 glue records)",
890            namespace,
891            name,
892            name_server_map.len()
893        );
894
895        if name_server_map.is_empty() {
896            None
897        } else {
898            Some(name_server_map)
899        }
900    };
901
902    // Extract list of ALL nameserver hostnames (primary from SOA + all from nameServers field)
903    // This is used by bindcar to generate NS records in the zone file
904    let all_nameserver_hostnames: Vec<String> = {
905        let mut hostnames = vec![spec.soa_record.primary_ns.clone()];
906
907        if let Some(ref ns_list) = effective_name_servers {
908            for ns in ns_list {
909                // Avoid duplicates - don't add primary NS again if it's in the list
910                if ns.hostname != spec.soa_record.primary_ns {
911                    hostnames.push(ns.hostname.clone());
912                }
913            }
914        }
915
916        hostnames
917    };
918
919    info!(
920        "Zone {}/{} will be configured with {} nameserver(s): {:?}",
921        namespace,
922        name,
923        all_nameserver_hostnames.len(),
924        all_nameserver_hostnames
925    );
926
927    // Extract DNSSEC policy if configured
928    let dnssec_policy = spec.dnssec_policy.as_deref();
929    if let Some(policy) = dnssec_policy {
930        info!(
931            "DNSSEC policy '{}' will be applied to zone {}/{}",
932            policy, namespace, name
933        );
934    }
935
936    // Process all primary instances concurrently using async streams
937    // Mark each instance as reconciled immediately after first successful endpoint configuration
938    let first_endpoint = Arc::new(Mutex::new(None::<String>));
939    let total_endpoints = Arc::new(Mutex::new(0_usize));
940    let errors = Arc::new(Mutex::new(Vec::<String>::new()));
941    let status_updater_shared = Arc::new(Mutex::new(status_updater));
942
943    // Create a stream of futures for all instances.
944    // Each instance future resolves to `true` only if EVERY endpoint of that
945    // instance accepted the zone (added or already present) - this is the
946    // per-INSTANCE success signal used for readiness computation.
947    let instance_results = stream::iter(primary_instance_refs.iter())
948        .then(|instance_ref| {
949            let client = client.clone();
950            let zone_manager = zone_manager.clone();
951            let zone_name = spec.zone_name.clone();
952            let soa_record = spec.soa_record.clone();
953            let all_nameserver_hostnames = all_nameserver_hostnames.clone();
954            let name_server_ips = name_server_ips.clone();
955            let secondary_ips = secondary_ips.clone();
956            let first_endpoint = Arc::clone(&first_endpoint);
957            let total_endpoints = Arc::clone(&total_endpoints);
958            let errors = Arc::clone(&errors);
959            let status_updater_shared = Arc::clone(&status_updater_shared);
960            let instance_ref = instance_ref.clone();
961            let _zone_namespace = namespace.clone();
962            let _zone_name_ref = name.clone();
963
964            async move {
965                info!(
966                    "Processing endpoints for primary instance {}/{}",
967                    instance_ref.namespace, instance_ref.name
968                );
969
970                // Load RNDC key for this specific instance
971                let key_data = match helpers::load_rndc_key(&client, &instance_ref.namespace, &instance_ref.name).await {
972                    Ok(key) => key,
973                    Err(e) => {
974                        let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name);
975                        errors.lock().await.push(err_msg);
976                        return false;
977                    }
978                };
979
980                // Get all endpoints for this instance
981                let endpoints = match helpers::get_endpoint(&client, &instance_ref.namespace, &instance_ref.name, "http").await {
982                    Ok(eps) => eps,
983                    Err(e) => {
984                        let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name);
985                        errors.lock().await.push(err_msg);
986                        return false;
987                    }
988                };
989
990                info!(
991                    "Found {} endpoint(s) for primary instance {}/{}",
992                    endpoints.len(),
993                    instance_ref.namespace,
994                    instance_ref.name
995                );
996
997                // Process endpoints concurrently for this instance
998                let endpoint_results = stream::iter(endpoints.iter())
999                    .then(|endpoint| {
1000                        let zone_manager = zone_manager.clone();
1001                        let zone_name = zone_name.clone();
1002                        let key_data = key_data.clone();
1003                        let soa_record = soa_record.clone();
1004                        let all_nameserver_hostnames = all_nameserver_hostnames.clone();
1005                        let name_server_ips = name_server_ips.clone();
1006                        let secondary_ips = secondary_ips.clone();
1007                        let first_endpoint = Arc::clone(&first_endpoint);
1008                        let total_endpoints = Arc::clone(&total_endpoints);
1009                        let errors = Arc::clone(&errors);
1010                        let instance_ref = instance_ref.clone();
1011                        let endpoint = endpoint.clone();
1012
1013                        async move {
1014                            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1015
1016                            // Save the first endpoint (globally)
1017                            {
1018                                let mut first = first_endpoint.lock().await;
1019                                if first.is_none() {
1020                                    *first = Some(pod_endpoint.clone());
1021                                }
1022                            }
1023
1024                            // Check if zone already exists before attempting creation
1025                            let zone_exists = match zone_manager.zone_exists(&zone_name, &pod_endpoint).await {
1026                                Ok(exists) => exists,
1027                                Err(e) => {
1028                                    error!(
1029                                        "Failed to check if zone {} exists on endpoint {} (instance {}/{}): {}",
1030                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1031                                    );
1032                                    // Treat errors as "zone might not exist" - proceed with add_zones
1033                                    false
1034                                }
1035                            };
1036
1037                            if zone_exists {
1038                                debug!(
1039                                    "Zone {} already exists on endpoint {} (instance {}/{}), skipping creation",
1040                                    zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1041                                );
1042                                *total_endpoints.lock().await += 1;
1043                                // Return false to indicate zone was not newly added
1044                                return Ok(false);
1045                            }
1046
1047                            // Pass secondary IPs for zone transfer configuration
1048                            let secondary_ips_ref = if secondary_ips.is_empty() {
1049                                None
1050                            } else {
1051                                Some(secondary_ips.as_slice())
1052                            };
1053
1054                            match zone_manager
1055                                .add_zones(
1056                                    &zone_name,
1057                                    ZONE_TYPE_PRIMARY,
1058                                    &pod_endpoint,
1059                                    &key_data,
1060                                    Some(&soa_record),
1061                                    Some(&all_nameserver_hostnames),
1062                                    name_server_ips.as_ref(),
1063                                    secondary_ips_ref,
1064                                    None, // primary_ips only for secondary zones
1065                                    dnssec_policy,
1066                                )
1067                                .await
1068                            {
1069                                Ok(was_added) => {
1070                                    if was_added {
1071                                        info!(
1072                                            "Successfully added zone {} to endpoint {} (instance: {}/{})",
1073                                            zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1074                                        );
1075                                    }
1076                                    *total_endpoints.lock().await += 1;
1077                                    // Return was_added so we can check if zone was actually configured
1078                                    Ok(was_added)
1079                                }
1080                                Err(e) => {
1081                                    error!(
1082                                        "Failed to add zone {} to endpoint {} (instance {}/{}): {}",
1083                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1084                                    );
1085                                    errors.lock().await.push(format!(
1086                                        "endpoint {pod_endpoint} (instance {}/{}): {e}",
1087                                        instance_ref.namespace, instance_ref.name
1088                                    ));
1089                                    Err(())
1090                                }
1091                            }
1092                        }
1093                    })
1094                    .collect::<Vec<Result<bool, ()>>>()
1095                    .await;
1096
1097                // Mark this instance as configured ONLY if at least one endpoint actually added the zone
1098                // This prevents updating lastReconciledAt when zone already exists (avoids tight loop)
1099                let zone_was_configured = endpoint_results.iter().any(|r| r.is_ok() && *r.as_ref().unwrap());
1100                if zone_was_configured {
1101                    status_updater_shared
1102                        .lock()
1103                        .await
1104                        .update_instance_status(
1105                            &instance_ref.name,
1106                            &instance_ref.namespace,
1107                            crate::crd::InstanceStatus::Configured,
1108                            Some("Zone successfully configured on primary instance".to_string()),
1109                        );
1110                    info!(
1111                        "Marked primary instance {}/{} as configured for zone {}",
1112                        instance_ref.namespace, instance_ref.name, zone_name
1113                    );
1114                }
1115
1116                // The instance counts as fully configured only if every one of
1117                // its ready endpoints accepted the zone (added OR already
1118                // present). A single failed endpoint means the instance is NOT
1119                // fully serving the zone and must not count towards readiness.
1120                !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok)
1121            }
1122        })
1123        .collect::<Vec<bool>>()
1124        .await;
1125
1126    let instances_configured = instance_results.iter().filter(|ok| **ok).count();
1127
1128    let first_endpoint = Arc::try_unwrap(first_endpoint)
1129        .expect("Failed to unwrap first_endpoint Arc")
1130        .into_inner();
1131    let total_endpoints = Arc::try_unwrap(total_endpoints)
1132        .expect("Failed to unwrap total_endpoints Arc")
1133        .into_inner();
1134    let errors = Arc::try_unwrap(errors)
1135        .expect("Failed to unwrap errors Arc")
1136        .into_inner();
1137    let _status_updater = Arc::try_unwrap(status_updater_shared)
1138        .map_err(|_| anyhow!("Failed to unwrap status_updater - multiple references remain"))?
1139        .into_inner();
1140
1141    // If ALL operations failed, return an error
1142    if total_endpoints == 0 && !errors.is_empty() {
1143        return Err(anyhow!(
1144            "Failed to add zone {} to all primary instances. Errors: {}",
1145            spec.zone_name,
1146            errors.join("; ")
1147        ));
1148    }
1149
1150    info!(
1151        "Successfully added zone {} to {} endpoint(s) across {}/{} fully configured primary instance(s)",
1152        spec.zone_name,
1153        total_endpoints,
1154        instances_configured,
1155        primary_instance_refs.len()
1156    );
1157
1158    // Auto-generate NS records and glue records from nameServers field
1159    if let Some(ref name_servers) = effective_name_servers {
1160        if !name_servers.is_empty() {
1161            info!(
1162                "Auto-generating NS records for {} nameserver(s) in zone {}",
1163                name_servers.len(),
1164                spec.zone_name
1165            );
1166
1167            if let Err(e) = auto_generate_ns_records(
1168                &client,
1169                name_servers,
1170                &spec.zone_name,
1171                spec.ttl,
1172                &primary_instance_refs,
1173            )
1174            .await
1175            {
1176                warn!(
1177                    "Failed to auto-generate some NS records for zone {}: {}. \
1178                     Zone is functional but may have incomplete NS records.",
1179                    spec.zone_name, e
1180                );
1181                // Don't fail reconciliation - zone is functional even without all NS records
1182            }
1183        }
1184    }
1185
1186    // Note: We don't need to reload after addzone because:
1187    // 1. rndc addzone immediately adds the zone to BIND9's running config
1188    // 2. The zone file will be created automatically when records are added via dynamic updates
1189    // 3. Reloading would fail if the zone file doesn't exist yet
1190
1191    // Notify secondaries about the new zone via the first endpoint
1192    // This triggers zone transfer (AXFR) from primary to secondaries
1193    if let Some(first_pod_endpoint) = first_endpoint {
1194        info!("Notifying secondaries about new zone {}", spec.zone_name);
1195        if let Err(e) = zone_manager
1196            .notify_zone(&spec.zone_name, &first_pod_endpoint)
1197            .await
1198        {
1199            // Don't fail if NOTIFY fails - the zone was successfully created
1200            // Secondaries will sync via SOA refresh timer
1201            warn!(
1202                "Failed to notify secondaries for zone {}: {}. Secondaries will sync via SOA refresh timer.",
1203                spec.zone_name, e
1204            );
1205        }
1206    } else {
1207        warn!(
1208            "No endpoints found for zone {}, cannot notify secondaries",
1209            spec.zone_name
1210        );
1211    }
1212
1213    Ok(types::ZoneConfigOutcome {
1214        instances_configured,
1215        endpoints_configured: total_endpoints,
1216    })
1217}
1218
1219/// Adds a DNS zone to all secondary instances in the cluster with primaries configured.
1220///
1221/// Creates secondary zones on all secondary instances, configuring them to transfer
1222/// from the provided primary server IPs. If a zone already exists on a secondary,
1223/// it checks if the primaries list matches and updates it if necessary.
1224///
1225/// # Arguments
1226///
1227/// * `client` - Kubernetes API client
1228/// * `dnszone` - The `DNSZone` resource
1229/// * `zone_manager` - BIND9 manager for adding zone
1230/// * `primary_ips` - List of primary server IPs to configure in the primaries field
1231///
1232/// # Returns
1233///
1234/// * `Ok(ZoneConfigOutcome)` - Per-instance and per-endpoint configuration counts.
1235///   An instance counts as configured only if ALL of its ready endpoints accepted
1236///   the zone, so the instance count is directly comparable with the expected
1237///   SECONDARY instance count when computing readiness.
1238/// * `Err(_)` - If zone addition failed on every endpoint
1239///
1240/// # Errors
1241///
1242/// Returns an error if BIND9 zone addition fails on any secondary instance.
1243///
1244/// # Panics
1245///
1246/// Panics if internal Arc unwrapping fails (should not happen in normal operation).
1247#[allow(clippy::too_many_lines)]
1248pub async fn add_dnszone_to_secondaries(
1249    ctx: Arc<crate::context::Context>,
1250    dnszone: DNSZone,
1251    zone_manager: &crate::bind9::Bind9Manager,
1252    primary_ips: &[String],
1253    status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
1254    instance_refs: &[crate::crd::InstanceReference],
1255) -> Result<types::ZoneConfigOutcome> {
1256    let client = ctx.client.clone();
1257    let namespace = dnszone.namespace().unwrap_or_default();
1258    let name = dnszone.name_any();
1259    let spec = &dnszone.spec;
1260
1261    if primary_ips.is_empty() {
1262        warn!(
1263            "No primary IPs provided for secondary zone {}/{} - skipping secondary configuration",
1264            namespace, spec.zone_name
1265        );
1266        return Ok(types::ZoneConfigOutcome::default());
1267    }
1268
1269    info!(
1270        "Adding DNSZone {}/{} to secondary instances with primaries: {:?}",
1271        namespace, name, primary_ips
1272    );
1273
1274    // PHASE 2 OPTIMIZATION: Use the filtered instance list passed by the caller
1275    // This ensures we only process instances that need reconciliation (lastReconciledAt == None)
1276
1277    // Filter to only SECONDARY instances
1278    let secondary_instance_refs =
1279        secondary::filter_secondary_instances(&client, instance_refs).await?;
1280
1281    if secondary_instance_refs.is_empty() {
1282        info!(
1283            "No secondary instances found for DNSZone {}/{} - skipping secondary zone configuration",
1284            namespace, name
1285        );
1286        return Ok(types::ZoneConfigOutcome::default());
1287    }
1288
1289    info!(
1290        "Found {} secondary instance(s) for DNSZone {}/{}",
1291        secondary_instance_refs.len(),
1292        namespace,
1293        name
1294    );
1295
1296    // Process all secondary instances concurrently using async streams
1297    // Mark each instance as reconciled immediately after first successful endpoint configuration
1298    let total_endpoints = Arc::new(Mutex::new(0_usize));
1299    let errors = Arc::new(Mutex::new(Vec::<String>::new()));
1300    let status_updater_shared = Arc::new(Mutex::new(status_updater));
1301
1302    // Create a stream of futures for all secondary instances.
1303    // Each instance future resolves to `true` only if EVERY endpoint of that
1304    // instance accepted the zone (added or already present) - this is the
1305    // per-INSTANCE success signal used for readiness computation.
1306    let instance_results = stream::iter(secondary_instance_refs.iter())
1307        .then(|instance_ref| {
1308            let client = client.clone();
1309            let zone_manager = zone_manager.clone();
1310            let zone_name = spec.zone_name.clone();
1311            let primary_ips = primary_ips.to_vec();
1312            let total_endpoints = Arc::clone(&total_endpoints);
1313            let errors = Arc::clone(&errors);
1314            let status_updater_shared = Arc::clone(&status_updater_shared);
1315            let instance_ref = instance_ref.clone();
1316            let _zone_namespace = namespace.clone();
1317            let _zone_name_ref = name.clone();
1318
1319            async move {
1320                info!(
1321                    "Processing secondary instance {}/{} for zone {}",
1322                    instance_ref.namespace, instance_ref.name, zone_name
1323                );
1324
1325                // Load RNDC key for this specific instance
1326                // Each instance has its own RNDC secret for security isolation
1327                let key_data = match helpers::load_rndc_key(&client, &instance_ref.namespace, &instance_ref.name).await {
1328                    Ok(key) => key,
1329                    Err(e) => {
1330                        let err_msg = format!("instance {}/{}: failed to load RNDC key: {e}", instance_ref.namespace, instance_ref.name);
1331                        errors.lock().await.push(err_msg);
1332                        return false;
1333                    }
1334                };
1335
1336                // Get all endpoints for this secondary instance
1337                let endpoints = match helpers::get_endpoint(&client, &instance_ref.namespace, &instance_ref.name, "http").await {
1338                    Ok(eps) => eps,
1339                    Err(e) => {
1340                        let err_msg = format!("instance {}/{}: failed to get endpoints: {e}", instance_ref.namespace, instance_ref.name);
1341                        errors.lock().await.push(err_msg);
1342                        return false;
1343                    }
1344                };
1345
1346                info!(
1347                    "Found {} endpoint(s) for secondary instance {}/{}",
1348                    endpoints.len(),
1349                    instance_ref.namespace,
1350                    instance_ref.name
1351                );
1352
1353                // Process endpoints concurrently for this instance
1354                let endpoint_results = stream::iter(endpoints.iter())
1355                    .then(|endpoint| {
1356                        let zone_manager = zone_manager.clone();
1357                        let zone_name = zone_name.clone();
1358                        let key_data = key_data.clone();
1359                        let primary_ips = primary_ips.clone();
1360                        let total_endpoints = Arc::clone(&total_endpoints);
1361                        let errors = Arc::clone(&errors);
1362                        let instance_ref = instance_ref.clone();
1363                        let endpoint = endpoint.clone();
1364
1365                        async move {
1366                            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1367
1368                            // Check if zone already exists before attempting creation
1369                            let zone_exists = match zone_manager.zone_exists(&zone_name, &pod_endpoint).await {
1370                                Ok(exists) => exists,
1371                                Err(e) => {
1372                                    error!(
1373                                        "Failed to check if zone {} exists on endpoint {} (instance {}/{}): {}",
1374                                        zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1375                                    );
1376                                    // Treat errors as "zone might not exist" - proceed with add_zones
1377                                    false
1378                                }
1379                            };
1380
1381                            // Variable to track if zone was added
1382                            let was_added = if zone_exists {
1383                                debug!(
1384                                    "Secondary zone {} already exists on endpoint {} (instance {}/{}), skipping creation",
1385                                    zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1386                                );
1387                                *total_endpoints.lock().await += 1;
1388                                false // Zone not newly added
1389                            } else {
1390                                info!(
1391                                    "Adding secondary zone {} to endpoint {} (instance: {}/{}) with primaries: {:?}",
1392                                    zone_name,
1393                                    pod_endpoint,
1394                                    instance_ref.namespace,
1395                                    instance_ref.name,
1396                                    primary_ips
1397                                );
1398
1399                                match zone_manager
1400                                    .add_zones(
1401                                        &zone_name,
1402                                        ZONE_TYPE_SECONDARY,
1403                                        &pod_endpoint,
1404                                        &key_data,
1405                                        None, // No SOA record for secondary zones
1406                                        None, // No name_servers for secondary zones
1407                                        None, // No name_server_ips for secondary zones
1408                                        None, // No secondary_ips for secondary zones
1409                                        Some(&primary_ips),
1410                                        None, // No DNSSEC policy for secondary zones
1411                                    )
1412                                    .await
1413                                {
1414                                    Ok(added) => {
1415                                        if added {
1416                                            info!(
1417                                                "Successfully added secondary zone {} to endpoint {} (instance: {}/{})",
1418                                                zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1419                                            );
1420                                        } else {
1421                                            info!(
1422                                                "Secondary zone {} already exists on endpoint {} (instance: {}/{})",
1423                                                zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1424                                            );
1425                                        }
1426                                        *total_endpoints.lock().await += 1;
1427                                        added
1428                                    }
1429                                    Err(e) => {
1430                                        error!(
1431                                            "Failed to add secondary zone {} to endpoint {} (instance {}/{}): {}",
1432                                            zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1433                                        );
1434                                        errors.lock().await.push(format!(
1435                                            "endpoint {pod_endpoint} (instance {}/{}): {e}",
1436                                            instance_ref.namespace, instance_ref.name
1437                                        ));
1438                                        return Err(());
1439                                    }
1440                                }
1441                            };
1442
1443                            // CRITICAL: Immediately trigger zone transfer to load the zone data
1444                            // This is necessary because:
1445                            // 1. `rndc addzone` only adds the zone to BIND9's config (in-memory)
1446                            // 2. The zone file doesn't exist yet on the secondary
1447                            // 3. Queries will return SERVFAIL until data is transferred from primary
1448                            // 4. `rndc retransfer` forces an immediate AXFR from primary to secondary
1449                            //
1450                            // This ensures the zone is LOADED and SERVING queries immediately after
1451                            // secondary pod restart or zone creation.
1452                            // NOTE: We trigger transfer even if zone already existed to ensure it's up to date
1453                            info!(
1454                                "Triggering immediate zone transfer for {} on secondary {} to load zone data",
1455                                zone_name, pod_endpoint
1456                            );
1457                            if let Err(e) = zone_manager
1458                                .retransfer_zone(&zone_name, &pod_endpoint)
1459                                .await
1460                            {
1461                                // Don't fail reconciliation if retransfer fails - zone will sync via SOA refresh
1462                                warn!(
1463                                    "Failed to trigger immediate zone transfer for {} on {}: {}. Zone will sync via SOA refresh timer.",
1464                                    zone_name, pod_endpoint, e
1465                                );
1466                            } else {
1467                                info!(
1468                                    "Successfully triggered zone transfer for {} on {}",
1469                                    zone_name, pod_endpoint
1470                                );
1471                            }
1472
1473                            // Return was_added so we can check if zone was actually configured
1474                            Ok(was_added)
1475                        }
1476                    })
1477                    .collect::<Vec<Result<bool, ()>>>()
1478                    .await;
1479
1480                // Mark this instance as configured ONLY if at least one endpoint actually added the zone
1481                // This prevents updating lastReconciledAt when zone already exists (avoids tight loop)
1482                let zone_was_configured = endpoint_results.iter().any(|r| r.is_ok() && *r.as_ref().unwrap());
1483                if zone_was_configured {
1484                    status_updater_shared
1485                        .lock()
1486                        .await
1487                        .update_instance_status(
1488                            &instance_ref.name,
1489                            &instance_ref.namespace,
1490                            crate::crd::InstanceStatus::Configured,
1491                            Some("Zone successfully configured on secondary instance".to_string()),
1492                        );
1493                    info!(
1494                        "Marked secondary instance {}/{} as configured for zone {}",
1495                        instance_ref.namespace, instance_ref.name, zone_name
1496                    );
1497                }
1498
1499                // The instance counts as fully configured only if every one of
1500                // its ready endpoints accepted the zone (added OR already
1501                // present). A single failed endpoint means the instance is NOT
1502                // fully serving the zone and must not count towards readiness.
1503                !endpoint_results.is_empty() && endpoint_results.iter().all(Result::is_ok)
1504            }
1505        })
1506        .collect::<Vec<bool>>()
1507        .await;
1508
1509    let instances_configured = instance_results.iter().filter(|ok| **ok).count();
1510
1511    let total_endpoints = Arc::try_unwrap(total_endpoints)
1512        .expect("Failed to unwrap total_endpoints Arc")
1513        .into_inner();
1514    let errors = Arc::try_unwrap(errors)
1515        .expect("Failed to unwrap errors Arc")
1516        .into_inner();
1517
1518    // If ALL operations failed, return an error
1519    if total_endpoints == 0 && !errors.is_empty() {
1520        return Err(anyhow!(
1521            "Failed to add zone {} to all secondary instances. Errors: {}",
1522            spec.zone_name,
1523            errors.join("; ")
1524        ));
1525    }
1526
1527    info!(
1528        "Successfully configured secondary zone {} on {} endpoint(s) across {}/{} fully configured secondary instance(s)",
1529        spec.zone_name,
1530        total_endpoints,
1531        instances_configured,
1532        secondary_instance_refs.len()
1533    );
1534
1535    Ok(types::ZoneConfigOutcome {
1536        instances_configured,
1537        endpoints_configured: total_endpoints,
1538    })
1539}
1540
1541/// Deletes a DNS zone and its associated zone files.
1542///
1543/// # Arguments
1544///
1545/// * `_client` - Kubernetes API client (unused, for future extensions)
1546/// * `dnszone` - The `DNSZone` resource to delete
1547/// * `zone_manager` - BIND9 manager for removing zone files
1548///
1549/// # Returns
1550///
1551/// * `Ok(())` - If zone was deleted successfully
1552/// * `Err(_)` - If zone deletion failed
1553///
1554/// # Errors
1555///
1556/// Returns an error if BIND9 zone deletion fails.
1557pub async fn delete_dnszone(
1558    ctx: Arc<crate::context::Context>,
1559    dnszone: DNSZone,
1560    zone_manager: &crate::bind9::Bind9Manager,
1561) -> Result<()> {
1562    let client = ctx.client.clone();
1563    let bind9_instances_store = &ctx.stores.bind9_instances;
1564    let namespace = dnszone.namespace().unwrap_or_default();
1565    let name = dnszone.name_any();
1566    let spec = &dnszone.spec;
1567
1568    info!("Deleting DNSZone {}/{}", namespace, name);
1569
1570    // Get instances from new architecture (spec.bind9Instances or status.bind9Instances)
1571    // If zone has no instances assigned (e.g., orphaned zone), still allow deletion
1572    let instance_refs = match validation::get_instances_from_zone(&dnszone, bind9_instances_store) {
1573        Ok(refs) => refs,
1574        Err(e) => {
1575            warn!(
1576                "DNSZone {}/{} has no instances assigned: {}. Allowing deletion anyway.",
1577                namespace, name, e
1578            );
1579            return Ok(());
1580        }
1581    };
1582
1583    // Filter to primary and secondary instances
1584    let primary_instance_refs = primary::filter_primary_instances(&client, &instance_refs).await?;
1585    let secondary_instance_refs =
1586        secondary::filter_secondary_instances(&client, &instance_refs).await?;
1587
1588    // Delete from all primary instances.
1589    // Deletion cleanup uses SkipUnavailable: an instance with zero ready
1590    // endpoints must not block finalizer removal forever - its DNS data is
1591    // unreachable (and lost anyway with ephemeral storage). Real API errors
1592    // still propagate so the next reconcile retries.
1593    if !primary_instance_refs.is_empty() {
1594        let (_first_endpoint, total_endpoints) = helpers::for_each_instance_endpoint_with_policy(
1595            &client,
1596            &primary_instance_refs,
1597            false, // with_rndc_key = false for zone deletion
1598            "http", // Use HTTP API port for zone deletion via bindcar API
1599            helpers::EndpointFailurePolicy::SkipUnavailable,
1600            |pod_endpoint, instance_name, _rndc_key| {
1601                let zone_name = spec.zone_name.clone();
1602                let zone_manager = zone_manager.clone();
1603
1604                async move {
1605                    info!(
1606                        "Deleting zone {} from endpoint {} (instance: {})",
1607                        zone_name, pod_endpoint, instance_name
1608                    );
1609
1610                    // Attempt to delete zone - if it fails (zone not found, endpoint unreachable, etc.),
1611                    // log a warning but don't fail the deletion. This ensures DNSZones can be deleted
1612                    // even if BIND9 instances are unavailable or the zone was already removed.
1613                    // Pass freeze_before_delete=true for primary zones to prevent updates during deletion
1614                    if let Err(e) = zone_manager.delete_zone(&zone_name, &pod_endpoint, true).await {
1615                        warn!(
1616                            "Failed to delete zone {} from endpoint {} (instance: {}): {}. Continuing with deletion anyway.",
1617                            zone_name, pod_endpoint, instance_name, e
1618                        );
1619                    } else {
1620                        debug!(
1621                            "Successfully deleted zone {} from endpoint {} (instance: {})",
1622                            zone_name, pod_endpoint, instance_name
1623                        );
1624                    }
1625
1626                    Ok(())
1627                }
1628            },
1629        )
1630        .await?;
1631
1632        info!(
1633            "Successfully deleted zone {} from {} primary endpoint(s)",
1634            spec.zone_name, total_endpoints
1635        );
1636    }
1637
1638    // Delete from all secondary instances
1639    if !secondary_instance_refs.is_empty() {
1640        let mut secondary_endpoints_deleted = 0;
1641
1642        for instance_ref in &secondary_instance_refs {
1643            // Deletion cleanup: skip secondary instances with no reachable
1644            // endpoints instead of blocking finalizer removal forever. Real
1645            // (potentially transient) API errors still propagate for retry.
1646            let endpoints = match helpers::get_endpoint(
1647                &client,
1648                &instance_ref.namespace,
1649                &instance_ref.name,
1650                "http",
1651            )
1652            .await
1653            {
1654                Ok(eps) => eps,
1655                Err(e) if helpers::is_unavailable_for_deletion(&e) => {
1656                    warn!(
1657                        "SKIPPING secondary instance {}/{} during zone deletion: no reachable endpoints ({e:#}). \
1658                         Zone data on this instance cannot be cleaned up and may be orphaned.",
1659                        instance_ref.namespace, instance_ref.name
1660                    );
1661                    continue;
1662                }
1663                Err(e) => return Err(e),
1664            };
1665
1666            for endpoint in &endpoints {
1667                let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1668
1669                info!(
1670                    "Deleting zone {} from secondary endpoint {} (instance: {}/{})",
1671                    spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1672                );
1673
1674                // Attempt to delete zone - if it fails, log a warning but don't fail the deletion
1675                // Pass freeze_before_delete=false for secondary zones (they are read-only, no need to freeze)
1676                if let Err(e) = zone_manager
1677                    .delete_zone(&spec.zone_name, &pod_endpoint, false)
1678                    .await
1679                {
1680                    warn!(
1681                        "Failed to delete zone {} from secondary endpoint {} (instance: {}/{}): {}. Continuing with deletion anyway.",
1682                        spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1683                    );
1684                } else {
1685                    debug!(
1686                        "Successfully deleted zone {} from secondary endpoint {} (instance: {}/{})",
1687                        spec.zone_name, pod_endpoint, instance_ref.namespace, instance_ref.name
1688                    );
1689                    secondary_endpoints_deleted += 1;
1690                }
1691            }
1692        }
1693
1694        info!(
1695            "Successfully deleted zone {} from {} secondary endpoint(s)",
1696            spec.zone_name, secondary_endpoints_deleted
1697        );
1698    }
1699
1700    // Note: We don't need to reload after delzone because:
1701    // 1. rndc delzone immediately removes the zone from BIND9's running config
1702    // 2. BIND9 will clean up the zone file and journal files automatically
1703
1704    Ok(())
1705}
1706
1707/// Auto-generates NS records for all nameservers in the zone.
1708///
1709/// This function is called after zone creation to add NS records for secondary nameservers
1710/// specified in the `nameServers` field. The primary nameserver NS record is already created
1711/// by bindcar during zone initialization (from SOA).
1712///
1713/// # Arguments
1714/// * `client` - Kubernetes client for loading RNDC keys and getting endpoints
1715/// * `effective_name_servers` - List of nameservers from `nameServers` field
1716/// * `zone_name` - The DNS zone name
1717/// * `ttl` - TTL for the NS and glue records
1718/// * `primary_instance_refs` - List of primary instances to update
1719///
1720/// # Returns
1721/// Result indicating success or failure
1722///
1723/// # Errors
1724/// Returns error if NS record or glue record addition fails
1725#[allow(clippy::too_many_lines)]
1726async fn auto_generate_ns_records(
1727    client: &kube::Client,
1728    effective_name_servers: &[crate::crd::NameServer],
1729    zone_name: &str,
1730    ttl: Option<i32>,
1731    primary_instance_refs: &[crate::crd::InstanceReference],
1732) -> Result<()> {
1733    if effective_name_servers.is_empty() {
1734        return Ok(());
1735    }
1736
1737    info!(
1738        "Auto-generating {} NS record(s) for zone {}",
1739        effective_name_servers.len(),
1740        zone_name
1741    );
1742
1743    for nameserver in effective_name_servers {
1744        // Add NS record at zone apex (@)
1745        info!(
1746            "Adding NS record: {} IN NS {}",
1747            zone_name, nameserver.hostname
1748        );
1749
1750        for instance_ref in primary_instance_refs {
1751            // Load RNDC key for this instance
1752            let key_data = match helpers::load_rndc_key(
1753                client,
1754                &instance_ref.namespace,
1755                &instance_ref.name,
1756            )
1757            .await
1758            {
1759                Ok(key) => key,
1760                Err(e) => {
1761                    warn!(
1762                        "Failed to load RNDC key for instance {}/{}: {}. Skipping NS record addition.",
1763                        instance_ref.namespace, instance_ref.name, e
1764                    );
1765                    continue;
1766                }
1767            };
1768
1769            // Get endpoints for this instance
1770            let endpoints = match helpers::get_endpoint(
1771                client,
1772                &instance_ref.namespace,
1773                &instance_ref.name,
1774                "dns-tcp",
1775            )
1776            .await
1777            {
1778                Ok(eps) => eps,
1779                Err(e) => {
1780                    warn!(
1781                        "Failed to get endpoints for instance {}/{}: {}. Skipping NS record addition.",
1782                        instance_ref.namespace, instance_ref.name, e
1783                    );
1784                    continue;
1785                }
1786            };
1787
1788            // Add NS record to all endpoints of this instance
1789            for endpoint in &endpoints {
1790                let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1791
1792                if let Err(e) = crate::bind9::records::ns::add_ns_record(
1793                    zone_name,
1794                    "@", // Zone apex
1795                    &nameserver.hostname,
1796                    ttl,
1797                    &pod_endpoint,
1798                    &key_data,
1799                )
1800                .await
1801                {
1802                    warn!(
1803                        "Failed to add NS record for {} to endpoint {} (instance {}/{}): {}",
1804                        nameserver.hostname,
1805                        pod_endpoint,
1806                        instance_ref.namespace,
1807                        instance_ref.name,
1808                        e
1809                    );
1810                    // Continue with other endpoints - partial success is acceptable
1811                }
1812            }
1813        }
1814
1815        // Add glue records if IPs provided (for in-zone nameservers)
1816        if let Some(ref ipv4) = nameserver.ipv4_address {
1817            add_glue_record(
1818                client,
1819                zone_name,
1820                &nameserver.hostname,
1821                ipv4,
1822                hickory_proto::rr::RecordType::A,
1823                ttl,
1824                primary_instance_refs,
1825            )
1826            .await?;
1827        }
1828
1829        if let Some(ref ipv6) = nameserver.ipv6_address {
1830            add_glue_record(
1831                client,
1832                zone_name,
1833                &nameserver.hostname,
1834                ipv6,
1835                hickory_proto::rr::RecordType::AAAA,
1836                ttl,
1837                primary_instance_refs,
1838            )
1839            .await?;
1840        }
1841    }
1842
1843    info!(
1844        "Successfully auto-generated NS records and glue records for zone {}",
1845        zone_name
1846    );
1847
1848    Ok(())
1849}
1850
1851/// Adds a glue record (A or AAAA) for an in-zone nameserver.
1852///
1853/// Glue records provide IP addresses for nameservers within the zone's own domain.
1854/// This is necessary to avoid circular dependencies when resolving the nameserver itself.
1855///
1856/// # Arguments
1857/// * `client` - Kubernetes client for loading RNDC keys and getting endpoints
1858/// * `zone_name` - The DNS zone name
1859/// * `hostname` - Full nameserver hostname (e.g., "ns2.example.com.")
1860/// * `ip_address` - IP address (IPv4 or IPv6)
1861/// * `record_type` - Type of glue record (A or AAAA)
1862/// * `ttl` - TTL for the glue record
1863/// * `primary_instance_refs` - List of primary instances to update
1864///
1865/// # Returns
1866/// Result indicating success or failure
1867///
1868/// # Errors
1869/// Returns error if glue record addition fails on all instances
1870#[allow(clippy::too_many_lines)]
1871async fn add_glue_record(
1872    client: &kube::Client,
1873    zone_name: &str,
1874    hostname: &str,
1875    ip_address: &str,
1876    record_type: hickory_proto::rr::RecordType,
1877    ttl: Option<i32>,
1878    primary_instance_refs: &[crate::crd::InstanceReference],
1879) -> Result<()> {
1880    // Extract record name from hostname
1881    // Example: "ns2.example.com." in zone "example.com" → name = "ns2"
1882    let name = hostname
1883        .trim_end_matches('.')
1884        .strip_suffix(&format!(".{}", zone_name.trim_end_matches('.')))
1885        .unwrap_or_else(|| hostname.trim_end_matches('.'));
1886
1887    // Check if this is actually an in-zone nameserver
1888    if name == hostname.trim_end_matches('.') {
1889        // Hostname doesn't end with zone name - this is an out-of-zone nameserver
1890        // No glue record needed
1891        debug!(
1892            "Skipping glue record for out-of-zone nameserver {} (not in zone {})",
1893            hostname, zone_name
1894        );
1895        return Ok(());
1896    }
1897
1898    info!(
1899        "Adding {} glue record: {} IN {} {}",
1900        if record_type == hickory_proto::rr::RecordType::A {
1901            "A"
1902        } else {
1903            "AAAA"
1904        },
1905        name,
1906        if record_type == hickory_proto::rr::RecordType::A {
1907            "A"
1908        } else {
1909            "AAAA"
1910        },
1911        ip_address
1912    );
1913
1914    let mut success_count = 0;
1915    let mut errors = Vec::new();
1916
1917    for instance_ref in primary_instance_refs {
1918        // Load RNDC key for this instance
1919        let key_data = match helpers::load_rndc_key(
1920            client,
1921            &instance_ref.namespace,
1922            &instance_ref.name,
1923        )
1924        .await
1925        {
1926            Ok(key) => key,
1927            Err(e) => {
1928                warn!(
1929                    "Failed to load RNDC key for instance {}/{}: {}. Skipping glue record addition.",
1930                    instance_ref.namespace, instance_ref.name, e
1931                );
1932                continue;
1933            }
1934        };
1935
1936        // Get endpoints for this instance
1937        let endpoints = match helpers::get_endpoint(
1938            client,
1939            &instance_ref.namespace,
1940            &instance_ref.name,
1941            "dns-tcp",
1942        )
1943        .await
1944        {
1945            Ok(eps) => eps,
1946            Err(e) => {
1947                warn!(
1948                    "Failed to get endpoints for instance {}/{}: {}. Skipping glue record addition.",
1949                    instance_ref.namespace, instance_ref.name, e
1950                );
1951                continue;
1952            }
1953        };
1954
1955        // Add glue record to all endpoints of this instance
1956        for endpoint in &endpoints {
1957            let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
1958
1959            let result = match record_type {
1960                hickory_proto::rr::RecordType::A => {
1961                    crate::bind9::records::a::add_a_record(
1962                        zone_name,
1963                        name,
1964                        &[ip_address.to_string()],
1965                        ttl,
1966                        &pod_endpoint,
1967                        &key_data,
1968                    )
1969                    .await
1970                }
1971                hickory_proto::rr::RecordType::AAAA => {
1972                    crate::bind9::records::a::add_aaaa_record(
1973                        zone_name,
1974                        name,
1975                        &[ip_address.to_string()],
1976                        ttl,
1977                        &pod_endpoint,
1978                        &key_data,
1979                    )
1980                    .await
1981                }
1982                _ => {
1983                    return Err(anyhow::anyhow!(
1984                        "Invalid record type for glue record: {:?}",
1985                        record_type
1986                    ))
1987                }
1988            };
1989
1990            match result {
1991                Ok(()) => {
1992                    success_count += 1;
1993                }
1994                Err(e) => {
1995                    warn!(
1996                        "Failed to add glue record {} to endpoint {} (instance {}/{}): {}",
1997                        name, pod_endpoint, instance_ref.namespace, instance_ref.name, e
1998                    );
1999                    errors.push(format!(
2000                        "endpoint {} (instance {}/{}): {}",
2001                        pod_endpoint, instance_ref.namespace, instance_ref.name, e
2002                    ));
2003                }
2004            }
2005        }
2006    }
2007
2008    // Accept partial success - at least one endpoint updated
2009    if success_count > 0 {
2010        Ok(())
2011    } else {
2012        Err(anyhow::anyhow!(
2013            "Failed to add glue record {} to all instances. Errors: {}",
2014            name,
2015            errors.join("; ")
2016        ))
2017    }
2018}
2019
2020#[cfg(test)]
2021#[path = "dnszone_tests.rs"]
2022mod dnszone_tests;