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}