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 true,
404 deployment_labels_are_current(&deployment, &name, &instance),
405 ),
406 Err(_) => (false, false),
407 };
408
409 let service_exists = service_api.get(&name).await.is_ok();
410
411 // Check ConfigMap - managed instances use cluster ConfigMap, standalone use instance ConfigMap
412 let configmap_name = if is_managed {
413 format!("{}-config", spec.cluster_ref)
414 } else {
415 format!("{name}-config")
416 };
417 let configmap_exists = configmap_api.get(&configmap_name).await.is_ok();
418
419 // Check Secret existence AND rotation status
420 let secret_name = format!("{name}-rndc-key");
421 let (secret_exists, needs_rotation) = match secret_api.get(&secret_name).await {
422 Ok(secret) => {
423 // Resolve RNDC config to check if rotation is due
424 let rndc_config = resources::resolve_full_rndc_config(
425 &instance,
426 cluster.as_ref(),
427 cluster_provider.as_ref(),
428 );
429
430 // Check if rotation is needed using the existing function
431 let needs_rotation =
432 resources::should_rotate_secret(&secret, &rndc_config).unwrap_or(false);
433
434 if needs_rotation {
435 debug!(
436 "RNDC Secret {}/{} rotation is due, will trigger reconciliation",
437 namespace, secret_name
438 );
439 }
440
441 (true, needs_rotation)
442 }
443 Err(_) => (false, false),
444 };
445
446 let all_exist = deployment_exists && service_exists && configmap_exists && secret_exists;
447 (all_exist, labels_match, needs_rotation)
448 };
449 let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
450
451 if let Some(ref cr) = cluster_ref {
452 debug!(
453 "Built cluster reference for instance {}/{}: {}/{} in namespace {:?}",
454 namespace, name, cr.kind, cr.name, cr.namespace
455 );
456 } else {
457 debug!(
458 "No cluster reference built for instance {}/{} - spec.clusterRef may be empty or cluster not found",
459 namespace, name
460 );
461 }
462
463 // Only reconcile resources if:
464 // 1. Spec changed (generation mismatch), OR
465 // 2. We haven't processed this resource yet (no observed_generation), OR
466 // 3. Resources are missing (drift detected), OR
467 // 4. RNDC Secret rotation is due, OR
468 // 5. Parent cluster configuration has changed
469 let should_reconcile =
470 crate::reconcilers::should_reconcile(current_generation, observed_generation);
471
472 // REMOVED: Zone discovery logic - instances no longer select zones
473 // Zone selection is now reversed: DNSZone.spec.bind9_instances_from selects instances
474 // This logic was removed as part of the architectural change to reverse selector direction
475
476 if !should_reconcile
477 && all_resources_exist
478 && deployment_labels_match
479 && !rotation_needed
480 && !parent_config_changed
481 {
482 debug!(
483 "Spec unchanged (generation={:?}), all resources exist, deployment labels match, no rotation needed, and parent config unchanged - skipping resource reconciliation",
484 current_generation
485 );
486 // Update status from current deployment state (only patches if status changed)
487 // Preserve existing cluster_ref from instance status if available
488 let cluster_ref = instance.status.as_ref().and_then(|s| s.cluster_ref.clone());
489 update_status_from_deployment(
490 &client,
491 &namespace,
492 &name,
493 &instance,
494 cluster_ref,
495 parent_generation,
496 )
497 .await?;
498
499 // Reconcile zones after status update
500 reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
501
502 return Ok(());
503 }
504
505 // If we reach here, reconciliation is needed because:
506 // - Spec changed (generation mismatch), OR
507 // - Resources don't exist (drift), OR
508 // - Deployment labels don't match desired state (drift), OR
509 // - RNDC Secret rotation is due, OR
510 // - Parent cluster configuration has changed
511 if !deployment_labels_match && all_resources_exist {
512 info!(
513 "Deployment labels don't match desired state for {}/{}, triggering reconciliation to update labels",
514 namespace, name
515 );
516 }
517
518 if !should_reconcile && !all_resources_exist {
519 info!(
520 "Drift detected for Bind9Instance {}/{}: One or more resources missing, will recreate",
521 namespace, name
522 );
523 }
524
525 if rotation_needed {
526 info!(
527 "RNDC Secret rotation is due for Bind9Instance {}/{}, triggering reconciliation",
528 namespace, name
529 );
530 }
531
532 if parent_config_changed {
533 info!(
534 "Parent cluster configuration changed for Bind9Instance {}/{}, triggering reconciliation to apply new config",
535 namespace, name
536 );
537 }
538
539 debug!(
540 "Reconciliation needed: current_generation={:?}, observed_generation={:?}",
541 current_generation, observed_generation
542 );
543
544 // Create or update resources
545 match create_or_update_resources(&client, &namespace, &name, &instance).await {
546 Ok((cluster, cluster_provider, secret)) => {
547 info!(
548 "Successfully created/updated resources for {}/{}",
549 namespace, name
550 );
551
552 // Build cluster reference for status
553 let cluster_ref = build_cluster_reference(cluster.as_ref(), cluster_provider.as_ref());
554
555 // Record the parent generation observed during this successful
556 // reconciliation. Use the freshly fetched parent (it may have been
557 // re-fetched by create_or_update_resources).
558 let observed_parent_generation = cluster
559 .as_ref()
560 .and_then(|c| c.metadata.generation)
561 .or_else(|| {
562 cluster_provider
563 .as_ref()
564 .and_then(|cp| cp.metadata.generation)
565 });
566
567 // Update status based on actual deployment state
568 update_status_from_deployment(
569 &client,
570 &namespace,
571 &name,
572 &instance,
573 cluster_ref,
574 observed_parent_generation,
575 )
576 .await?;
577
578 // Update rotation status if Secret is available
579 if let Some(ref secret) = secret {
580 // Resolve RNDC config for rotation status update
581 let rndc_config = resources::resolve_full_rndc_config(
582 &instance,
583 cluster.as_ref(),
584 cluster_provider.as_ref(),
585 );
586
587 if let Err(e) =
588 update_rotation_status(&client, &instance, secret, &rndc_config).await
589 {
590 warn!(
591 "Failed to update rotation status for {}/{}: {}",
592 namespace, name, e
593 );
594 // Non-fatal error, continue reconciliation
595 }
596 }
597
598 // Reconcile zones after deployment creation/update
599 reconcile_zones_internal(&client, &ctx.stores, &instance).await?;
600 }
601 Err(e) => {
602 error!(
603 "Failed to create/update resources for {}/{}: {}",
604 namespace, name, e
605 );
606
607 // Update status to show error
608 let error_condition = Condition {
609 r#type: CONDITION_TYPE_READY.to_string(),
610 status: "False".to_string(),
611 reason: Some(REASON_NOT_READY.to_string()),
612 message: Some(format!("Failed to create resources: {e}")),
613 last_transition_time: Some(Utc::now().to_rfc3339()),
614 };
615 // No cluster info available on error, pass None for cluster_ref.
616 // Preserve the previously observed parent generation: the new parent
617 // config was NOT applied, so it must not be recorded as observed.
618 update_status(
619 &client,
620 &instance,
621 vec![error_condition],
622 None,
623 observed_parent_generation,
624 )
625 .await?;
626
627 return Err(e);
628 }
629 }
630
631 Ok(())
632}
633
634/// Delete handler for `Bind9Instance` resources (cleanup logic).
635///
636/// This function is kept for backward compatibility but deletion is now handled
637/// by the finalizer in `reconcile_bind9instance`.
638///
639/// # Errors
640///
641/// This function currently never returns an error, but returns `Result` for API consistency.
642pub async fn delete_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
643 let _client = ctx.client.clone();
644 let namespace = instance.namespace().unwrap_or_default();
645 let name = instance.name_any();
646
647 info!(
648 "Delete called for Bind9Instance {}/{} (handled by finalizer)",
649 namespace, name
650 );
651
652 // Deletion is now handled by the finalizer in reconcile_bind9instance
653 Ok(())
654}
655
656/// Whether a Deployment already carries every label the operator manages.
657///
658/// Used by the reconcile short-circuit: when this returns `false`, resource
659/// reconciliation runs even though nothing else looks stale.
660///
661/// Two label sets are checked, and the second is what makes an operator
662/// upgrade converge. `spec.selector` is immutable, so labels added after a
663/// Deployment exists can only go on the Pod template
664/// (`build_pod_labels_from_instance`, a superset of the metadata set). A
665/// Deployment created before topology spreading has correct *metadata* labels
666/// but no `bindy.firestoned.io/cluster` on its Pod template; checking metadata
667/// alone would let the short-circuit skip it forever, leaving it permanently
668/// without spread constraints.
669///
670/// Both checks are subset tests, not equality: other controllers, `kubectl`,
671/// and Helm add labels of their own, and those are none of our business.
672fn deployment_labels_are_current(
673 deployment: &Deployment,
674 name: &str,
675 instance: &Bind9Instance,
676) -> bool {
677 let desired_metadata = crate::bind9_resources::build_labels_from_instance(name, instance);
678 let metadata_matches = deployment.metadata.labels.as_ref().is_some_and(|actual| {
679 desired_metadata
680 .iter()
681 .all(|(key, value)| actual.get(key) == Some(value))
682 });
683
684 let desired_pod = crate::bind9_resources::build_pod_labels_from_instance(name, instance);
685 let pod_matches = deployment
686 .spec
687 .as_ref()
688 .and_then(|s| s.template.metadata.as_ref())
689 .and_then(|m| m.labels.as_ref())
690 .is_some_and(|actual| {
691 desired_pod
692 .iter()
693 .all(|(key, value)| actual.get(key) == Some(value))
694 });
695
696 if metadata_matches && !pod_matches {
697 debug!(
698 "Deployment {name} Pod template labels are stale (missing operator-managed labels); \
699 reconciling resources"
700 );
701 }
702
703 metadata_matches && pod_matches
704}
705
706/// Test-only re-export of `deployment_labels_are_current`.
707#[cfg(test)]
708pub(crate) fn deployment_labels_are_current_for_test(
709 deployment: &Deployment,
710 name: &str,
711 instance: &Bind9Instance,
712) -> bool {
713 deployment_labels_are_current(deployment, name, instance)
714}
715
716#[cfg(test)]
717#[path = "mod_tests.rs"]
718mod mod_tests;