bindy/reconcilers/dnszone/helpers.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Helper functions for DNS zone reconciliation.
5//!
6//! This module contains the validation and change detection helper functions
7//! extracted from the main reconcile_dnszone() function to improve maintainability.
8
9use crate::crd::{DNSZone, InstanceReference};
10use anyhow::{anyhow, Context as AnyhowContext, Result};
11use k8s_openapi::api::core::v1::{Endpoints, Pod, Secret};
12use kube::{Api, Client};
13use std::collections::{HashMap, HashSet};
14use tracing::{error, info, warn};
15
16use super::types::{DuplicateZoneInfo, EndpointAddress};
17use crate::bind9::RndcKeyData;
18
19/// HTTP status code for "Not Found" responses from the Kubernetes API.
20pub(crate) const HTTP_STATUS_NOT_FOUND: u16 = 404;
21
22/// How [`for_each_instance_endpoint_with_policy`] treats per-instance
23/// RNDC-key and endpoint lookup failures.
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum EndpointFailurePolicy {
26 /// Propagate RNDC-key and endpoint lookup failures immediately.
27 /// This is the correct behavior for normal reconciliation, where the
28 /// operation must be retried until the instance becomes reachable.
29 Strict,
30 /// Deletion-cleanup mode: an instance whose RNDC key Secret is gone or
31 /// that has no ready endpoints is skipped with a loud warning (the DNS
32 /// data on it is unreachable or already gone), while real API errors
33 /// (timeouts, 429, 5xx, ...) still fail the call so the next reconcile
34 /// retries. This prevents resources from being stuck Terminating behind
35 /// an instance that can never be cleaned up.
36 SkipUnavailable,
37}
38
39/// Classifies an error from `load_rndc_key`/`get_endpoint` during DELETION cleanup.
40///
41/// Returns `true` when the failure means the lookup target is gone or there is
42/// nothing to operate on (safe to skip during deletion):
43/// - a Kubernetes 404 (Secret or Endpoints object missing), or
44/// - a non-Kubernetes error (e.g. "no ready endpoints found", malformed
45/// Secret data) - conditions that will not be fixed by retrying the delete.
46///
47/// Returns `false` for any other Kubernetes API error (timeout, 429, 5xx, ...)
48/// which is potentially transient and must be retried instead of skipped.
49#[must_use]
50pub(crate) fn is_unavailable_for_deletion(err: &anyhow::Error) -> bool {
51 match err.downcast_ref::<kube::Error>() {
52 Some(kube::Error::Api(ae)) => ae.code == HTTP_STATUS_NOT_FOUND,
53 Some(_) => false,
54 None => true,
55 }
56}
57
58/// Re-fetch a DNSZone to get the latest status.
59///
60/// The `dnszone` parameter from the watch event might have stale status from the cache.
61/// We need the latest `status.bind9Instances` which may have been updated by the
62/// Bind9Instance reconciler.
63///
64/// # Arguments
65/// * `client` - Kubernetes client
66/// * `namespace` - Namespace of the DNSZone
67/// * `name` - Name of the DNSZone
68///
69/// # Returns
70/// The freshly fetched DNSZone with current status
71///
72/// # Errors
73/// Returns an error if the Kubernetes API call fails
74pub async fn refetch_zone(client: &Client, namespace: &str, name: &str) -> Result<DNSZone> {
75 let zones_api: kube::Api<DNSZone> = kube::Api::namespaced(client.clone(), namespace);
76 let zone = zones_api.get(name).await?;
77 Ok(zone)
78}
79
80/// Handle duplicate zone conflicts by setting Ready=False and stopping reconciliation.
81///
82/// When a duplicate zone is detected, this function:
83/// 1. Logs a warning with details about the conflict
84/// 2. Updates the status with Ready=False and DuplicateZone condition
85/// 3. Applies the status to the API server
86///
87/// # Arguments
88/// * `client` - Kubernetes client
89/// * `namespace` - Namespace of the conflicting DNSZone
90/// * `name` - Name of the conflicting DNSZone
91/// * `duplicate_info` - Information about the duplicate zone conflict
92/// * `status_updater` - Status updater to apply the condition
93///
94/// # Errors
95/// Returns an error if the status update fails
96pub async fn handle_duplicate_zone(
97 client: &Client,
98 namespace: &str,
99 name: &str,
100 duplicate_info: &DuplicateZoneInfo,
101 status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
102) -> Result<()> {
103 tracing::warn!(
104 "Duplicate zone detected: {}/{} cannot claim '{}' because it is already configured by: {:?}",
105 namespace, name, duplicate_info.zone_name, duplicate_info.conflicting_zones
106 );
107
108 // Build list of conflicting zones in namespace/name format
109 let conflicting_zone_refs: Vec<String> = duplicate_info
110 .conflicting_zones
111 .iter()
112 .map(|z| format!("{}/{}", z.namespace, z.name))
113 .collect();
114
115 // Set Ready=False with DuplicateZone reason
116 status_updater.set_duplicate_zone_condition(&duplicate_info.zone_name, &conflicting_zone_refs);
117
118 // Apply status and stop processing
119 status_updater.apply(client).await?;
120
121 Ok(())
122}
123
124/// Detect if the zone spec has changed since last reconciliation.
125///
126/// Compares current generation with observed generation to determine
127/// if this is first reconciliation or if spec changed.
128///
129/// # Arguments
130///
131/// * `zone` - The DNSZone resource
132///
133/// # Returns
134///
135/// Tuple of (first_reconciliation, spec_changed)
136#[must_use]
137pub fn detect_spec_changes(zone: &DNSZone) -> (bool, bool) {
138 let current_generation = zone.metadata.generation;
139 let observed_generation = zone.status.as_ref().and_then(|s| s.observed_generation);
140
141 let first_reconciliation = observed_generation.is_none();
142 let spec_changed =
143 crate::reconcilers::should_reconcile(current_generation, observed_generation);
144
145 (first_reconciliation, spec_changed)
146}
147
148/// Detect if the instance list changed between watch event and re-fetch.
149///
150/// This is critical for detecting when:
151/// 1. New instances are added to `status.bind9Instances` (via `bind9InstancesFrom` selectors)
152/// 2. Instance `lastReconciledAt` timestamps are cleared (e.g., instance deleted, needs reconfiguration)
153///
154/// NOTE: `InstanceReference` `PartialEq` ignores `lastReconciledAt`, so we must check timestamps separately!
155///
156/// # Arguments
157///
158/// * `namespace` - Namespace for logging
159/// * `name` - Zone name for logging
160/// * `watch_instances` - Instances from the watch event that triggered reconciliation
161/// * `current_instances` - Instances after re-fetching (current state)
162///
163/// # Returns
164///
165/// `true` if instances changed (list or timestamps), `false` otherwise
166pub fn detect_instance_changes(
167 namespace: &str,
168 name: &str,
169 watch_instances: Option<&Vec<InstanceReference>>,
170 current_instances: &[InstanceReference],
171) -> bool {
172 let Some(watch_instances) = watch_instances else {
173 // No instances in watch event, first reconciliation or error
174 return true;
175 };
176
177 // Get the instance names from the watch event (what triggered us)
178 let watch_instance_names: HashSet<_> = watch_instances.iter().map(|r| &r.name).collect();
179
180 // Get the instance names after re-fetching (current state)
181 let current_instance_names: HashSet<_> = current_instances.iter().map(|r| &r.name).collect();
182
183 // Check if instance list changed (added/removed instances)
184 let list_changed = watch_instance_names != current_instance_names;
185
186 if list_changed {
187 info!(
188 "Instance list changed during reconciliation for zone {}/{}: watch_event={:?}, current={:?}",
189 namespace, name, watch_instance_names, current_instance_names
190 );
191 return true;
192 }
193
194 // List is the same, but check if any lastReconciledAt timestamps changed
195 // Use InstanceReference as HashMap key (uses its Hash impl which hashes identity fields)
196 let watch_timestamps: HashMap<&InstanceReference, Option<&str>> = watch_instances
197 .iter()
198 .map(|inst| (inst, inst.last_reconciled_at.as_deref()))
199 .collect();
200
201 let current_timestamps: HashMap<&InstanceReference, Option<&str>> = current_instances
202 .iter()
203 .map(|inst| (inst, inst.last_reconciled_at.as_deref()))
204 .collect();
205
206 let timestamps_changed = watch_timestamps.iter().any(|(inst_ref, watch_ts)| {
207 current_timestamps
208 .get(inst_ref)
209 .is_some_and(|current_ts| current_ts != watch_ts)
210 });
211
212 if timestamps_changed {
213 info!(
214 "Instance lastReconciledAt timestamps changed for zone {}/{}",
215 namespace, name
216 );
217 }
218
219 timestamps_changed
220}
221
222//
223// ============================================================
224// Endpoint and Instance Utilities
225// ============================================================
226//
227
228/// Execute an operation on all endpoints for a list of instance references.
229///
230/// This is the event-driven instance-based approach that operates on instances
231/// discovered via spec.bind9InstancesFrom selectors.
232///
233/// # Arguments
234///
235/// * `client` - Kubernetes API client
236/// * `instance_refs` - List of instance references to process
237/// * `with_rndc_key` - Whether to load and pass RNDC keys for each instance
238/// * `port_name` - Port name to use for endpoints (e.g., "rndc-api", "dns-tcp")
239/// * `operation` - Async closure to execute for each endpoint
240///
241/// # Returns
242///
243/// * `Ok((first_endpoint, total_endpoints))` - First endpoint found and total count
244///
245/// # Errors
246///
247/// Returns an error if all operations fail or if critical API calls fail.
248pub async fn for_each_instance_endpoint<F, Fut>(
249 client: &Client,
250 instance_refs: &[crate::crd::InstanceReference],
251 with_rndc_key: bool,
252 port_name: &str,
253 operation: F,
254) -> Result<(Option<String>, usize)>
255where
256 F: Fn(String, String, Option<RndcKeyData>) -> Fut,
257 Fut: std::future::Future<Output = Result<()>>,
258{
259 for_each_instance_endpoint_with_policy(
260 client,
261 instance_refs,
262 with_rndc_key,
263 port_name,
264 EndpointFailurePolicy::Strict,
265 operation,
266 )
267 .await
268}
269
270/// Execute an operation on all endpoints for a list of instance references,
271/// with an explicit failure policy for per-instance lookups.
272///
273/// Same as [`for_each_instance_endpoint`], but the caller chooses how to treat
274/// RNDC-key and endpoint lookup failures (see [`EndpointFailurePolicy`]).
275/// Deletion cleanup paths should use [`EndpointFailurePolicy::SkipUnavailable`]
276/// so that a missing RNDC Secret or an instance with zero ready endpoints does
277/// not block finalizer removal forever.
278///
279/// # Arguments
280///
281/// * `client` - Kubernetes API client
282/// * `instance_refs` - List of instance references to process
283/// * `with_rndc_key` - Whether to load and pass RNDC keys for each instance
284/// * `port_name` - Port name to use for endpoints (e.g., "rndc-api", "dns-tcp")
285/// * `policy` - How to treat per-instance RNDC-key/endpoint lookup failures
286/// * `operation` - Async closure to execute for each endpoint
287///
288/// # Returns
289///
290/// * `Ok((first_endpoint, total_endpoints))` - First endpoint found and total count
291///
292/// # Errors
293///
294/// Returns an error if all operations fail, or if RNDC-key/endpoint lookups
295/// fail and the policy does not allow skipping them.
296pub async fn for_each_instance_endpoint_with_policy<F, Fut>(
297 client: &Client,
298 instance_refs: &[crate::crd::InstanceReference],
299 with_rndc_key: bool,
300 port_name: &str,
301 policy: EndpointFailurePolicy,
302 operation: F,
303) -> Result<(Option<String>, usize)>
304where
305 F: Fn(String, String, Option<RndcKeyData>) -> Fut,
306 Fut: std::future::Future<Output = Result<()>>,
307{
308 let mut first_endpoint: Option<String> = None;
309 let mut total_endpoints = 0;
310 let mut errors: Vec<String> = Vec::new();
311
312 for instance_ref in instance_refs {
313 info!(
314 "Processing endpoints for instance {}/{}",
315 instance_ref.namespace, instance_ref.name
316 );
317
318 // Load RNDC key for this specific instance if requested
319 let key_data = if with_rndc_key {
320 match load_rndc_key(client, &instance_ref.namespace, &instance_ref.name).await {
321 Ok(key) => Some(key),
322 Err(e)
323 if policy == EndpointFailurePolicy::SkipUnavailable
324 && is_unavailable_for_deletion(&e) =>
325 {
326 warn!(
327 "SKIPPING instance {}/{} during deletion cleanup: RNDC key unavailable ({e:#}). \
328 DNS data on this instance cannot be cleaned up and may be orphaned.",
329 instance_ref.namespace, instance_ref.name
330 );
331 continue;
332 }
333 Err(e) => return Err(e),
334 }
335 } else {
336 None
337 };
338
339 // Get all endpoints for this instance's service
340 let endpoints = match get_endpoint(
341 client,
342 &instance_ref.namespace,
343 &instance_ref.name,
344 port_name,
345 )
346 .await
347 {
348 Ok(eps) => eps,
349 Err(e)
350 if policy == EndpointFailurePolicy::SkipUnavailable
351 && is_unavailable_for_deletion(&e) =>
352 {
353 warn!(
354 "SKIPPING instance {}/{} during deletion cleanup: no reachable endpoints ({e:#}). \
355 DNS data on this instance cannot be cleaned up and may be orphaned.",
356 instance_ref.namespace, instance_ref.name
357 );
358 continue;
359 }
360 Err(e) => return Err(e),
361 };
362
363 info!(
364 "Found {} endpoint(s) for instance {}/{}",
365 endpoints.len(),
366 instance_ref.namespace,
367 instance_ref.name
368 );
369
370 for endpoint in &endpoints {
371 let pod_endpoint = format!("{}:{}", endpoint.ip, endpoint.port);
372
373 // Save the first endpoint
374 if first_endpoint.is_none() {
375 first_endpoint = Some(pod_endpoint.clone());
376 }
377
378 // Execute the operation on this endpoint
379 if let Err(e) = operation(
380 pod_endpoint.clone(),
381 instance_ref.name.clone(),
382 key_data.clone(),
383 )
384 .await
385 {
386 error!(
387 "Failed operation on endpoint {} (instance {}/{}): {}",
388 pod_endpoint, instance_ref.namespace, instance_ref.name, e
389 );
390 errors.push(format!(
391 "endpoint {pod_endpoint} (instance {}/{}): {e}",
392 instance_ref.namespace, instance_ref.name
393 ));
394 } else {
395 total_endpoints += 1;
396 }
397 }
398 }
399
400 // If ALL operations failed, return an error
401 if total_endpoints == 0 && !errors.is_empty() {
402 return Err(anyhow!(
403 "All operations failed. Errors: {}",
404 errors.join("; ")
405 ));
406 }
407
408 Ok((first_endpoint, total_endpoints))
409}
410
411/// Extract the name and IP of a pod that is Running and has an IP assigned.
412///
413/// Used when listing BIND9 pods for zone operations: pods that are not yet
414/// Running, or that are so freshly scheduled they have no IP, must be SKIPPED
415/// rather than failing the entire pod listing - a single Pending pod must not
416/// abort operations against the healthy pods.
417///
418/// # Arguments
419///
420/// * `pod` - The pod to inspect
421///
422/// # Returns
423///
424/// `Some((name, ip))` if the pod is Running with an IP, `None` otherwise.
425#[must_use]
426pub fn running_pod_name_and_ip(pod: &Pod) -> Option<(String, String)> {
427 let pod_name = pod.metadata.name.as_deref().unwrap_or("unknown");
428
429 let phase = pod
430 .status
431 .as_ref()
432 .and_then(|s| s.phase.as_deref())
433 .unwrap_or("Unknown");
434 if phase != "Running" {
435 tracing::debug!("Skipping pod {} (phase: {}, not running)", pod_name, phase);
436 return None;
437 }
438
439 let Some(pod_ip) = pod.status.as_ref().and_then(|s| s.pod_ip.as_ref()) else {
440 tracing::debug!(
441 "Skipping pod {} without an IP address (likely just scheduled)",
442 pod_name
443 );
444 return None;
445 };
446
447 Some((pod_name.to_string(), pod_ip.clone()))
448}
449
450/// Load RNDC key from the instance's secret.
451///
452/// # Arguments
453///
454/// * `client` - Kubernetes API client
455/// * `namespace` - Namespace of the instance
456/// * `instance_name` - Name of the instance
457///
458/// # Returns
459///
460/// Parsed RNDC key data
461///
462/// # Errors
463///
464/// Returns an error if the secret is not found or cannot be parsed
465pub async fn load_rndc_key(
466 client: &Client,
467 namespace: &str,
468 instance_name: &str,
469) -> Result<RndcKeyData> {
470 let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
471 let secret_name = format!("{instance_name}-rndc-key");
472
473 let secret = secret_api.get(&secret_name).await.context(format!(
474 "Failed to get RNDC secret {secret_name} in namespace {namespace}"
475 ))?;
476
477 let data = secret
478 .data
479 .as_ref()
480 .ok_or_else(|| anyhow!("Secret {secret_name} has no data"))?;
481
482 // Convert ByteString to Vec<u8>
483 let mut converted_data = std::collections::BTreeMap::new();
484 for (key, value) in data {
485 converted_data.insert(key.clone(), value.0.clone());
486 }
487
488 crate::bind9::Bind9Manager::parse_rndc_secret_data(&converted_data)
489}
490
491/// Get all ready endpoints for a service.
492///
493/// Queries the Kubernetes Endpoints API to find all ready pod IPs and ports
494/// for a given service. The port_name must match the name field in the
495/// service's port specification.
496///
497/// # Arguments
498///
499/// * `client` - Kubernetes API client
500/// * `namespace` - Namespace of the service
501/// * `service_name` - Name of the service (usually same as instance name)
502/// * `port_name` - Name of the port to query (e.g., "rndc-api", "dns-tcp")
503///
504/// # Returns
505///
506/// Vector of endpoint addresses with IP and port
507///
508/// # Errors
509///
510/// Returns an error if:
511/// - Failed to get endpoints from API
512/// - No ready addresses found
513pub async fn get_endpoint(
514 client: &Client,
515 namespace: &str,
516 service_name: &str,
517 port_name: &str,
518) -> Result<Vec<EndpointAddress>> {
519 let endpoints_api: Api<Endpoints> = Api::namespaced(client.clone(), namespace);
520 let endpoints = endpoints_api.get(service_name).await.context(format!(
521 "Failed to get endpoints for service {service_name}"
522 ))?;
523
524 let mut result = Vec::new();
525
526 // Endpoints are organized into subsets. Each subset has:
527 // - addresses: List of ready pod IPs
528 // - ports: List of container ports
529 if let Some(subsets) = endpoints.subsets {
530 for subset in subsets {
531 // Find the port in this subset
532 if let Some(ports) = subset.ports {
533 if let Some(endpoint_port) = ports
534 .iter()
535 .find(|p| p.name.as_ref().is_some_and(|name| name == port_name))
536 {
537 let port = endpoint_port.port;
538
539 // Get all ready addresses for this subset
540 if let Some(addresses) = subset.addresses {
541 for addr in addresses {
542 result.push(EndpointAddress {
543 ip: addr.ip.clone(),
544 port,
545 });
546 }
547 }
548 }
549 }
550 }
551 }
552
553 if result.is_empty() {
554 return Err(anyhow!(
555 "No ready endpoints found for service {service_name} with port '{port_name}'"
556 ));
557 }
558
559 Ok(result)
560}