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;