bindy/reconcilers/dnszone/
validation.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Validation logic for DNS zones.
5//!
6//! This module contains functions for validating zone configurations,
7//! checking for duplicate zones, and filtering instances.
8
9use anyhow::{anyhow, Result};
10use kube::ResourceExt;
11use tracing::{debug, warn};
12
13use super::types::{ConflictingZone, DuplicateZoneInfo};
14use crate::crd::DNSZone;
15
16/// Get instances from a DNSZone based on `bind9_instances_from` selectors.
17///
18/// This function:
19/// - Uses the reflector store for O(1) lookups without API calls
20/// - Single source of truth: `DNSZone` owns the zone-instance relationship
21///
22/// # Arguments
23///
24/// * `dnszone` - The `DNSZone` resource to get instances for
25/// * `bind9_instances_store` - The reflector store for querying `Bind9Instance` resources
26///
27/// # Returns
28///
29/// * `Ok(Vec<InstanceReference>)` - List of instances serving this zone
30/// * `Err(_)` - If no instances match the `bind9_instances_from` selectors
31///
32/// # Errors
33///
34/// Returns an error if no instances are found matching the label selectors.
35pub fn get_instances_from_zone(
36    dnszone: &DNSZone,
37    bind9_instances_store: &kube::runtime::reflector::Store<crate::crd::Bind9Instance>,
38) -> Result<Vec<crate::crd::InstanceReference>> {
39    let namespace = dnszone.namespace().unwrap_or_default();
40    let name = dnszone.name_any();
41
42    // Get bind9_instances_from selectors from zone spec
43    let bind9_instances_from = match &dnszone.spec.bind9_instances_from {
44        Some(sources) if !sources.is_empty() => sources,
45        _ => {
46            return Err(anyhow!(
47                "DNSZone {namespace}/{name} has no bind9_instances_from selectors configured. \
48                Add spec.bind9_instances_from[] with label selectors to target Bind9Instance resources."
49            ));
50        }
51    };
52
53    // Query all instances from the reflector store and filter by label selectors
54    let instances_with_zone: Vec<crate::crd::InstanceReference> = bind9_instances_store
55        .state()
56        .iter()
57        .filter_map(|instance| {
58            let instance_labels = instance.metadata.labels.as_ref()?;
59            let instance_namespace = instance.namespace()?;
60            let instance_name = instance.name_any();
61
62            // Check if instance matches ANY of the bind9_instances_from selectors (OR logic)
63            let matches = bind9_instances_from
64                .iter()
65                .any(|source| source.selector.matches(instance_labels));
66
67            if matches {
68                Some(crate::crd::InstanceReference {
69                    api_version: "bindy.firestoned.io/v1beta1".to_string(),
70                    kind: "Bind9Instance".to_string(),
71                    name: instance_name,
72                    namespace: instance_namespace,
73                    last_reconciled_at: None,
74                })
75            } else {
76                None
77            }
78        })
79        .collect();
80
81    if !instances_with_zone.is_empty() {
82        debug!(
83            "DNSZone {}/{} matched {} instances via spec.bind9_instances_from selectors",
84            namespace,
85            name,
86            instances_with_zone.len()
87        );
88        return Ok(instances_with_zone);
89    }
90
91    // No instances found
92    Err(anyhow!(
93        "DNSZone {namespace}/{name} has no instances matching spec.bind9_instances_from selectors. \
94        Verify that Bind9Instance resources exist with matching labels."
95    ))
96}
97
98/// Checks if another zone has already claimed the same zone name across any BIND9 instances.
99///
100/// This function prevents multiple teams from creating conflicting zones with the same
101/// fully qualified domain name (FQDN). A conflict exists if:
102/// 1. Another DNSZone CR has the same `spec.zoneName`
103/// 2. That zone is NOT the same resource (different namespace/name)
104/// 3. That zone has at least one instance configured (status.bind9Instances is non-empty)
105/// 4. Those instances have status != "Failed"
106///
107/// # Arguments
108///
109/// * `dnszone` - The DNSZone resource to check for duplicates
110/// * `zones_store` - The reflector store containing all DNSZone resources
111///
112/// # Returns
113///
114/// * `Some(DuplicateZoneInfo)` - If a duplicate zone is detected, with details about conflicts
115/// * `None` - If no duplicate exists (safe to proceed)
116///
117/// # Examples
118///
119/// ```rust,ignore
120/// use tracing::warn;
121/// use bindy::reconcilers::dnszone::check_for_duplicate_zones;
122///
123/// if let Some(duplicate_info) = check_for_duplicate_zones(&dnszone, &zones_store) {
124///     warn!("Zone {} conflicts with existing zones: {:?}",
125///           duplicate_info.zone_name, duplicate_info.conflicting_zones);
126///     // Set status condition to DuplicateZone and stop processing
127/// }
128/// ```
129pub fn check_for_duplicate_zones(
130    dnszone: &DNSZone,
131    zones_store: &kube::runtime::reflector::Store<DNSZone>,
132) -> Option<DuplicateZoneInfo> {
133    let current_namespace = dnszone.namespace().unwrap_or_default();
134    let current_name = dnszone.name_any();
135    let zone_name = &dnszone.spec.zone_name;
136
137    debug!(
138        "Checking for duplicate zones: current zone {}/{} claims {}",
139        current_namespace, current_name, zone_name
140    );
141
142    let mut conflicting_zones = Vec::new();
143
144    // Query all zones from the reflector store
145    for other_zone in &zones_store.state() {
146        let other_namespace = other_zone.namespace().unwrap_or_default();
147        let other_name = other_zone.name_any();
148
149        // Skip if this is the same zone (updating itself)
150        if other_namespace == current_namespace && other_name == current_name {
151            continue;
152        }
153
154        // Skip if zone name doesn't match
155        if other_zone.spec.zone_name != *zone_name {
156            continue;
157        }
158
159        // Check if other zone has instances configured
160        let has_configured_instances = other_zone.status.as_ref().is_some_and(|status| {
161            !status.bind9_instances.is_empty()
162                && status.bind9_instances.iter().any(|inst| {
163                    inst.status != crate::crd::InstanceStatus::Failed
164                        && inst.status != crate::crd::InstanceStatus::Unclaimed
165                })
166        });
167
168        if !has_configured_instances {
169            debug!(
170                "Zone {}/{} also uses {} but has no configured instances - not a conflict",
171                other_namespace, other_name, zone_name
172            );
173            continue;
174        }
175
176        // This is a conflict - collect instance names
177        let instance_names = other_zone
178            .status
179            .as_ref()
180            .map(|status| {
181                status
182                    .bind9_instances
183                    .iter()
184                    .filter(|inst| {
185                        inst.status != crate::crd::InstanceStatus::Failed
186                            && inst.status != crate::crd::InstanceStatus::Unclaimed
187                    })
188                    .map(|inst| format!("{}/{}", inst.namespace, inst.name))
189                    .collect()
190            })
191            .unwrap_or_default();
192
193        warn!(
194            "Duplicate zone detected: {}/{} already claims {} on instances: {:?}",
195            other_namespace, other_name, zone_name, instance_names
196        );
197
198        conflicting_zones.push(ConflictingZone {
199            name: other_name,
200            namespace: other_namespace,
201            instance_names,
202        });
203    }
204
205    if conflicting_zones.is_empty() {
206        None
207    } else {
208        Some(DuplicateZoneInfo {
209            zone_name: zone_name.clone(),
210            conflicting_zones,
211        })
212    }
213}
214
215/// Filters instances that need reconciliation based on their `last_reconciled_at` timestamp.
216///
217/// Returns instances where:
218/// - `last_reconciled_at` is `None` (never reconciled)
219/// - `last_reconciled_at` exists but we need to verify pod IPs haven't changed
220///
221/// # Arguments
222///
223/// * `instances` - All instances assigned to the zone
224///
225/// # Returns
226///
227/// List of instances that need reconciliation (zone configuration)
228#[must_use]
229pub fn filter_instances_needing_reconciliation(
230    instances: &[crate::crd::InstanceReference],
231) -> Vec<crate::crd::InstanceReference> {
232    instances
233        .iter()
234        .filter(|instance| {
235            // If never reconciled, needs reconciliation
236            instance.last_reconciled_at.is_none()
237        })
238        .cloned()
239        .collect()
240}
241
242#[cfg(test)]
243#[path = "validation_tests.rs"]
244mod validation_tests;