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;