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;