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