bindy/reconcilers/bind9instance/mod.rs
1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! BIND9 instance reconciliation logic.
5//!
6//! This module handles the lifecycle of BIND9 DNS server deployments in Kubernetes.
7//! It creates and manages Deployments, `ConfigMaps`, and Services for each `Bind9Instance`.
8//!
9//! ## Module Structure
10//!
11//! - [`cluster_helpers`] - Cluster integration and reference management
12//! - [`config`] - RNDC configuration precedence resolution
13//! - [`resources`] - Resource lifecycle (`ConfigMap`, Deployment, Service)
14//! - [`status_helpers`] - Status calculation and updates
15//! - [`types`] - Shared types and imports
16//! - [`zones`] - Zone reconciliation logic
17
18// Submodules
19pub mod cluster_helpers;
20pub mod config;
21pub mod resources;
22pub mod status_helpers;
23pub mod types;
24pub mod zones;
25
26// Re-export public APIs for external use
27pub use zones::reconcile_instance_zones;
28
29// Internal imports
30use cluster_helpers::{build_cluster_reference, fetch_cluster_info};
31use resources::{create_or_update_resources, delete_resources};
32use status_helpers::{update_status, update_status_from_deployment};
33#[allow(clippy::wildcard_imports)]
34use types::*;
35use zones::reconcile_instance_zones as reconcile_zones_internal;
36
37use crate::reconcilers::finalizers::{ensure_finalizer, handle_deletion, FinalizerCleanup};
38
39/// Calculate the requeue duration for the next reconciliation based on RNDC rotation schedule.
40///
41/// If auto-rotation is enabled and a rotation time is scheduled, this function calculates
42/// the duration until that rotation time. If the rotation is overdue, it returns a minimal
43/// duration to trigger immediate reconciliation.
44///
45/// # Arguments
46///
47/// * `config` - RNDC configuration with rotation settings
48/// * `secret` - The RNDC Secret with rotation annotations
49///
50/// # Returns
51///
52/// Duration until next reconciliation. Returns `None` if rotation is disabled or Secret
53/// has no rotation annotations.
54///
55/// # Examples
56///
57/// ```rust,ignore
58/// use bindy::crd::RndcKeyConfig;
59/// use k8s_openapi::api::core::v1::Secret;
60/// use bindy::reconcilers::bind9instance::calculate_requeue_duration;
61///
62/// let config = RndcKeyConfig {
63/// auto_rotate: true,
64/// rotate_after: "720h".to_string(),
65/// ..Default::default()
66/// };
67///
68/// // Create a secret with rotation annotations
69/// let secret = Secret {
70/// metadata: ObjectMeta {
71/// annotations: Some(BTreeMap::from([
72/// ("bindy.firestoned.io/rotation-created-at".to_string(), "2025-01-01T00:00:00Z".to_string()),
73/// ("bindy.firestoned.io/rotation-rotate-at".to_string(), "2025-02-01T00:00:00Z".to_string()),
74/// ])),
75/// ..Default::default()
76/// },
77/// ..Default::default()
78/// };
79///
80/// // Returns duration until rotate_at timestamp
81/// let duration = calculate_requeue_duration(&config, &secret);
82/// ```
83#[allow(dead_code)] // Will be used when requeue logic is integrated
84fn calculate_requeue_duration(
85 config: &crate::crd::RndcKeyConfig,
86 secret: &Secret,
87) -> Option<std::time::Duration> {
88 use chrono::Utc;
89
90 // Only calculate requeue if auto-rotation is enabled
91 if !config.auto_rotate {
92 return None;
93 }
94
95 // Extract rotation annotations from Secret
96 let annotations = secret.metadata.annotations.as_ref()?;
97 let (_created_at, rotate_at, _rotation_count) =
98 crate::bind9::rndc::parse_rotation_annotations(annotations).ok()?;
99
100 // If no rotation scheduled, no need for specific requeue
101 let rotate_at = rotate_at?;
102
103 let now = Utc::now();
104 let time_until_rotation = rotate_at.signed_duration_since(now);
105
106 // If rotation is overdue or very soon, reconcile quickly (30 seconds)
107 if time_until_rotation.num_seconds() <= 0 {
108 return Some(std::time::Duration::from_secs(30));
109 }
110
111 // Otherwise, schedule reconciliation slightly before rotation time (5 minutes early)
112 let requeue_secs = time_until_rotation
113 .num_seconds()
114 .saturating_sub(300) // 5 minutes early
115 .max(30); // At least 30 seconds
116
117 #[allow(clippy::cast_sign_loss)] // Value is guaranteed non-negative by max(30)
118 Some(std::time::Duration::from_secs(requeue_secs as u64))
119}
120
121/// Detects whether the parent cluster's configuration changed since it was last observed.
122///
123/// Compares the parent's current `metadata.generation` against the parent
124/// generation recorded in the instance's `status.observedParentGeneration`.
125/// These are the ONLY two values that may be compared: the instance's own
126/// `observed_generation` tracks a different, unrelated counter.
127///
128/// # Arguments
129///
130/// * `parent_generation` - Current `metadata.generation` of the referenced
131/// `Bind9Cluster`/`ClusterBind9Provider` (`None` if no parent exists)
132/// * `observed_parent_generation` - Parent generation recorded during the last
133/// successful reconciliation (`None` if never recorded)
134///
135/// # Returns
136///
137/// `true` if the parent exists and its generation differs from the recorded
138/// value (or was never recorded), `false` otherwise.
139#[must_use]
140pub fn parent_generation_changed(
141 parent_generation: Option<i64>,
142 observed_parent_generation: Option<i64>,
143) -> bool {
144 match (parent_generation, observed_parent_generation) {
145 (Some(parent), Some(observed)) => parent != observed,
146 (Some(_), None) => true, // Parent exists but was never observed
147 (None, _) => false, // No parent - nothing to track
148 }
149}
150
151/// Update the `Bind9Instance` status with RNDC key rotation information.
152///
153/// Reads rotation metadata from the RNDC Secret annotations and updates the instance
154/// status with current rotation state. This provides visibility into key age and
155/// rotation schedule.
156///
157/// # Arguments
158///
159/// * `client` - Kubernetes API client
160/// * `instance` - The `Bind9Instance` resource to update
161/// * `secret` - The RNDC Secret containing rotation annotations
162/// * `config` - RNDC configuration with rotation settings
163///
164/// # Returns
165///
166/// `Ok(())` on success, error if status update fails.
167///
168/// # Errors
169///
170/// Returns an error if:
171/// - Secret annotations are missing or malformed
172/// - Status patch API call fails
173async fn update_rotation_status(
174 client: &Client,
175 instance: &Bind9Instance,
176 secret: &Secret,
177 config: &crate::crd::RndcKeyConfig,
178) -> Result<()> {
179 use crate::crd::RndcKeyRotationStatus;
180
181 // Only update status if auto-rotation is enabled
182 if !config.auto_rotate {
183 return Ok(());
184 }
185
186 let Some(annotations) = &secret.metadata.annotations else {
187 debug!("Secret has no annotations, skipping rotation status update");
188 return Ok(());
189 };
190
191 let (created_at, rotate_at, rotation_count) =
192 crate::bind9::rndc::parse_rotation_annotations(annotations)?;
193
194 // Determine last_rotated_at: if rotation_count > 0, the current created_at is when it was last rotated
195 let last_rotated_at = if rotation_count > 0 {
196 Some(created_at.to_rfc3339())
197 } else {
198 None
199 };
200
201 let rotation_status = RndcKeyRotationStatus {
202 created_at: created_at.to_rfc3339(),
203 rotate_at: rotate_at.map(|dt| dt.to_rfc3339()),
204 last_rotated_at,
205 rotation_count,
206 };
207
208 // Prepare status update
209 let namespace = instance.namespace().unwrap_or_default();
210 let name = instance.name_any();
211
212 let status = serde_json::json!({
213 "status": {
214 "rndcKeyRotation": rotation_status
215 }
216 });
217
218 let api: Api<Bind9Instance> = Api::namespaced(client.clone(), &namespace);
219 api.patch_status(
220 &name,
221 &PatchParams::default(),
222 &kube::api::Patch::Merge(&status),
223 )
224 .await?;
225
226 debug!(
227 "Updated rotation status for {}/{}: rotation_count={}, rotate_at={:?}",
228 namespace, name, rotation_count, rotate_at
229 );
230
231 Ok(())
232}
233
234/// Implement cleanup trait for `Bind9Instance` finalizer management.
235#[async_trait::async_trait]
236impl FinalizerCleanup for Bind9Instance {
237 async fn cleanup(&self, client: &Client) -> Result<()> {
238 let namespace = self.namespace().unwrap_or_default();
239 let name = self.name_any();
240
241 // Check if this instance is managed by a Bind9Cluster
242 let is_managed: bool = self
243 .metadata
244 .labels
245 .as_ref()
246 .and_then(|labels| labels.get(BINDY_MANAGED_BY_LABEL))
247 .is_some();
248
249 if is_managed {
250 info!(
251 "Bind9Instance {}/{} is managed by a Bind9Cluster, skipping resource cleanup (cluster will handle it)",
252 namespace, name
253 );
254 Ok(())
255 } else {
256 info!(
257 "Running cleanup for standalone Bind9Instance {}/{}",
258 namespace, name
259 );
260 delete_resources(client, &namespace, &name).await
261 }
262 }
263}
264
265/// Reconciles a `Bind9Instance` resource.
266///
267/// Creates or updates all Kubernetes resources needed to run a BIND9 DNS server:
268/// - `ConfigMap` with BIND9 configuration files
269/// - Deployment with BIND9 container pods
270/// - Service for DNS traffic (TCP/UDP port 53)
271///
272/// # Arguments
273///
274/// * `ctx` - Operator context with Kubernetes client and reflector stores
275/// * `instance` - The `Bind9Instance` resource to reconcile
276///
277/// # Returns
278///
279/// * `Ok(())` - If reconciliation succeeded
280/// * `Err(_)` - If resource creation/update failed
281///
282/// # Example
283///
284/// ```rust,no_run
285/// use bindy::reconcilers::reconcile_bind9instance;
286/// use bindy::crd::Bind9Instance;
287/// use bindy::context::Context;
288/// use std::sync::Arc;
289///
290/// async fn handle_instance(ctx: Arc<Context>, instance: Bind9Instance) -> anyhow::Result<()> {
291/// reconcile_bind9instance(ctx, instance).await?;
292/// Ok(())
293/// }
294/// ```
295///
296/// # Errors
297///
298/// Returns an error if Kubernetes API operations fail or resource creation/update fails.
299#[allow(clippy::too_many_lines)]
300pub async fn reconcile_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
301 let client = ctx.client.clone();
302 let namespace = instance.namespace().unwrap_or_default();
303 let name = instance.name_any();
304
305 info!("Reconciling Bind9Instance: {}/{}", namespace, name);
306 debug!(
307 namespace = %namespace,
308 name = %name,
309 generation = ?instance.metadata.generation,
310 "Starting Bind9Instance reconciliation"
311 );
312
313 // Check if the instance is being deleted
314 if instance.metadata.deletion_timestamp.is_some() {
315 return handle_deletion(&client, &instance, FINALIZER_BIND9_INSTANCE).await;
316 }
317
318 // Add finalizer if not present
319 ensure_finalizer(&client, &instance, FINALIZER_BIND9_INSTANCE).await?;
320
321 let spec = &instance.spec;
322 let replicas = spec.replicas.unwrap_or(1);
323 let version = spec
324 .version
325 .as_deref()
326 .unwrap_or(crate::constants::DEFAULT_BIND9_VERSION);
327
328 debug!(
329 cluster_ref = %spec.cluster_ref,
330 replicas,
331 version = %version,
332 role = ?spec.role,
333 "Instance configuration"
334 );
335
336 info!(
337 "Bind9Instance {} configured with {} replicas, version {}",
338 name, replicas, version
339 );
340
341 // Check if spec has changed using the standard generation check
342 let current_generation = instance.metadata.generation;
343 let observed_generation = instance.status.as_ref().and_then(|s| s.observed_generation);
344
345 // Check if this instance is managed by a Bind9Cluster
346 let is_managed: bool = instance
347 .metadata
348 .labels
349 .as_ref()
350 .and_then(|labels| labels.get(BINDY_MANAGED_BY_LABEL))
351 .is_some();
352
353 // Fetch cluster information early for rotation checking and zone reconciliation
354 // We need this to set the cluster reference in DNSZone status
355 let (cluster, cluster_provider) = fetch_cluster_info(&client, &namespace, &instance).await;
356
357 // Check if parent cluster configuration has changed since last reconciliation
358 // This is critical for detecting when RNDC config is added/changed at the cluster level.
359 //
360 // The parent's generation is tracked SEPARATELY from the instance's own
361 // observed_generation via status.observedParentGeneration - the two counters
362 // are unrelated and must never be compared against each other.
363 let parent_generation = cluster
364 .as_ref()
365 .and_then(|c| c.metadata.generation)
366 .or_else(|| {
367 cluster_provider
368 .as_ref()
369 .and_then(|cp| cp.metadata.generation)
370 });
371 let observed_parent_generation = instance
372 .status
373 .as_ref()
374 .and_then(|s| s.observed_parent_generation);
375
376 let parent_config_changed =
377 parent_generation_changed(parent_generation, observed_parent_generation);
378
379 if parent_config_changed {
380 debug!(
381 "Parent cluster generation ({:?}) differs from last observed parent generation ({:?})",
382 parent_generation, observed_parent_generation
383 );
384 }
385
386 if parent_config_changed {
387 info!(
388 "Parent cluster configuration may have changed for Bind9Instance {}/{}, will check for drift",
389 namespace, name
390 );
391 }
392
393 // Check if ALL required resources actually exist AND match desired state (drift detection)
394 let (all_resources_exist, deployment_labels_match, rotation_needed) = {
395 let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), &namespace);
396 let service_api: Api<Service> = Api::namespaced(client.clone(), &namespace);
397 let configmap_api: Api<ConfigMap> = Api::namespaced(client.clone(), &namespace);
398 let secret_api: Api<Secret> = Api::namespaced(client.clone(), &namespace);
399
400 // Fetch deployment to check if it exists AND if OUR labels match
401 let (deployment_exists, labels_match) = match deployment_api.get(&name).await {
402 Ok(deployment) => {
403 // Build desired labels from instance - these are the labels WE manage
404 let desired_labels =
405 crate::bind9_resources::build_labels_from_instance(&name, &instance);
406
407 // Check if deployment has all OUR labels with correct values
408 // IMPORTANT: Only check labels we explicitly set via build_labels_from_instance()
409 // Other controllers or users may add additional labels - we don't care about those
410 let labels_match = if let Some(actual_labels) = &deployment.metadata.labels {
411 desired_labels
412 .iter()
413 .all(|(key, value)| actual_labels.get(key) == Some(value))
414 } else {
415 false // No labels at all = no match
416 };
417
418 (true, labels_match)
419 }
420 Err(_) => (false, false),
421 };
422
423 let service_exists = service_api.get(&name).await.is_ok();
424
425 // Check ConfigMap - managed instances use cluster ConfigMap, standalone use instance ConfigMap
426 let configmap_name = if is_managed {
427 format!("{}-config", spec.cluster_ref)
428 } else {
429 format!("{name}-config")
430 };
431 let configmap_exists = configmap_api.get(&configmap_name).await.is_ok();
432
433 // Check Secret existence AND rotation status
434 let secret_name = format!("{name}-rndc-key");
435 let (secret_exists, needs_rotation) = match secret_api.get(&secret_name).await {
436 Ok(secret) => {
437 // Resolve RNDC config to check if rotation is due
438 let rndc_config = resources::resolve_full_rndc_config(
439 &instance,
440 cluster.as_ref(),
441 cluster_provider.as_ref(),
442 );
443
444 // Check if rotation is needed using the existing function
445 let needs_rotation =
446 resources::should_rotate_secret(&secret, &rndc_config).unwrap_or(false);
447
448 if needs_rotation {
449 debug!(
450 "RNDC Secret {}/{} rotation is due, will trigger reconciliation",
451 namespace, secret_name
452 );
453 }
454
455 (true, needs_rotation)
456 }
457 Err(_) => (false, false),
458 };
459
460 let all_exist = deployment_exists && service_exists && configmap_exists && secret_exists;
461 (all_exist, labels_match, needs_rotation)
462 };
463 let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
464
465 if let Some(ref cr) = cluster_ref {
466 debug!(
467 "Built cluster reference for instance {}/{}: {}/{} in namespace {:?}",
468 namespace, name, cr.kind, cr.name, cr.namespace
469 );
470 } else {
471 debug!(
472 "No cluster reference built for instance {}/{} - spec.clusterRef may be empty or cluster not found",
473 namespace, name
474 );
475 }
476
477 // Only reconcile resources if:
478 // 1. Spec changed (generation mismatch), OR
479 // 2. We haven't processed this resource yet (no observed_generation), OR
480 // 3. Resources are missing (drift detected), OR
481 // 4. RNDC Secret rotation is due, OR
482 // 5. Parent cluster configuration has changed
483 let should_reconcile =
484 crate::reconcilers::should_reconcile(current_generation, observed_generation);
485
486 // REMOVED: Zone discovery logic - instances no longer select zones
487 // Zone selection is now reversed: DNSZone.spec.bind9_instances_from selects instances
488 // This logic was removed as part of the architectural change to reverse selector direction
489
490 if !should_reconcile
491 && all_resources_exist
492 && deployment_labels_match
493 && !rotation_needed
494 && !parent_config_changed
495 {
496 debug!(
497 "Spec unchanged (generation={:?}), all resources exist, deployment labels match, no rotation needed, and parent config unchanged - skipping resource reconciliation",
498 current_generation
499 );
500 // Update status from current deployment state (only patches if status changed)
501 // Preserve existing cluster_ref from instance status if available
502 let cluster_ref = instance.status.as_ref().and_then(|s| s.cluster_ref.clone());
503 update_status_from_deployment(
504 &client,
505 &namespace,
506 &name,
507 &instance,
508 cluster_ref,
509 parent_generation,
510 )
511 .await?;
512
513 // Reconcile zones after status update
514 reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
515
516 return Ok(());
517 }
518
519 // If we reach here, reconciliation is needed because:
520 // - Spec changed (generation mismatch), OR
521 // - Resources don't exist (drift), OR
522 // - Deployment labels don't match desired state (drift), OR
523 // - RNDC Secret rotation is due, OR
524 // - Parent cluster configuration has changed
525 if !deployment_labels_match && all_resources_exist {
526 info!(
527 "Deployment labels don't match desired state for {}/{}, triggering reconciliation to update labels",
528 namespace, name
529 );
530 }
531
532 if !should_reconcile && !all_resources_exist {
533 info!(
534 "Drift detected for Bind9Instance {}/{}: One or more resources missing, will recreate",
535 namespace, name
536 );
537 }
538
539 if rotation_needed {
540 info!(
541 "RNDC Secret rotation is due for Bind9Instance {}/{}, triggering reconciliation",
542 namespace, name
543 );
544 }
545
546 if parent_config_changed {
547 info!(
548 "Parent cluster configuration changed for Bind9Instance {}/{}, triggering reconciliation to apply new config",
549 namespace, name
550 );
551 }
552
553 debug!(
554 "Reconciliation needed: current_generation={:?}, observed_generation={:?}",
555 current_generation, observed_generation
556 );
557
558 // Create or update resources
559 match create_or_update_resources(&client, &namespace, &name, &instance).await {
560 Ok((cluster, cluster_provider, secret)) => {
561 info!(
562 "Successfully created/updated resources for {}/{}",
563 namespace, name
564 );
565
566 // Build cluster reference for status
567 let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
568
569 // Record the parent generation observed during this successful
570 // reconciliation. Use the freshly fetched parent (it may have been
571 // re-fetched by create_or_update_resources).
572 let observed_parent_generation = cluster
573 .as_ref()
574 .and_then(|c| c.metadata.generation)
575 .or_else(|| {
576 cluster_provider
577 .as_ref()
578 .and_then(|cp| cp.metadata.generation)
579 });
580
581 // Update status based on actual deployment state
582 update_status_from_deployment(
583 &client,
584 &namespace,
585 &name,
586 &instance,
587 cluster_ref,
588 observed_parent_generation,
589 )
590 .await?;
591
592 // Update rotation status if Secret is available
593 if let Some(ref secret) = secret {
594 // Resolve RNDC config for rotation status update
595 let rndc_config = resources::resolve_full_rndc_config(
596 &instance,
597 cluster.as_ref(),
598 cluster_provider.as_ref(),
599 );
600
601 if let Err(e) =
602 update_rotation_status(&client, &instance, secret, &rndc_config).await
603 {
604 warn!(
605 "Failed to update rotation status for {}/{}: {}",
606 namespace, name, e
607 );
608 // Non-fatal error, continue reconciliation
609 }
610 }
611
612 // Reconcile zones after deployment creation/update
613 reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
614 }
615 Err(e) => {
616 error!(
617 "Failed to create/update resources for {}/{}: {}",
618 namespace, name, e
619 );
620
621 // Update status to show error
622 let error_condition = Condition {
623 r#type: CONDITION_TYPE_READY.to_string(),
624 status: "False".to_string(),
625 reason: Some(REASON_NOT_READY.to_string()),
626 message: Some(format!("Failed to create resources: {e}")),
627 last_transition_time: Some(Utc::now().to_rfc3339()),
628 };
629 // No cluster info available on error, pass None for cluster_ref.
630 // Preserve the previously observed parent generation: the new parent
631 // config was NOT applied, so it must not be recorded as observed.
632 update_status(
633 &client,
634 &instance,
635 vec![error_condition],
636 None,
637 observed_parent_generation,
638 )
639 .await?;
640
641 return Err(e);
642 }
643 }
644
645 Ok(())
646}
647
648/// Delete handler for `Bind9Instance` resources (cleanup logic).
649///
650/// This function is kept for backward compatibility but deletion is now handled
651/// by the finalizer in `reconcile_bind9instance`.
652///
653/// # Errors
654///
655/// This function currently never returns an error, but returns `Result` for API consistency.
656pub async fn delete_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
657 let _client = ctx.client.clone();
658 let namespace = instance.namespace().unwrap_or_default();
659 let name = instance.name_any();
660
661 info!(
662 "Delete called for Bind9Instance {}/{} (handled by finalizer)",
663 namespace, name
664 );
665
666 // Deletion is now handled by the finalizer in reconcile_bind9instance
667 Ok(())
668}
669
670#[cfg(test)]
671#[path = "mod_tests.rs"]
672mod mod_tests;