bindy/reconcilers/bind9instance/
resources.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Kubernetes resource lifecycle management for `Bind9Instance` resources.
5//!
6//! This module handles creating, updating, and deleting all Kubernetes resources
7//! needed to run a BIND9 DNS server (`ConfigMap`, Deployment, Service, etc.).
8
9#[allow(clippy::wildcard_imports)]
10use super::types::*;
11
12use crate::bind9::Bind9Manager;
13use crate::bind9_resources::{
14    build_configmap, build_deployment, build_service, build_service_account,
15};
16use crate::constants::{API_GROUP_VERSION, KIND_BIND9_INSTANCE};
17use crate::reconcilers::resources::create_or_apply;
18use anyhow::Context as _;
19
20/// Resolve RNDC configuration from instance and cluster levels.
21///
22/// Applies the precedence order: Instance > Role > Default
23///
24/// # Arguments
25///
26/// * `instance` - The `Bind9Instance` being reconciled
27/// * `cluster` - Optional `Bind9Cluster` (namespace-scoped)
28/// * `cluster_provider` - Optional `ClusterBind9Provider` (cluster-scoped)
29///
30/// # Returns
31///
32/// Resolved `RndcKeyConfig` with highest-precedence configuration applied.
33pub(super) fn resolve_full_rndc_config(
34    instance: &Bind9Instance,
35    cluster: Option<&Bind9Cluster>,
36    _cluster_provider: Option<&crate::crd::ClusterBind9Provider>,
37) -> crate::crd::RndcKeyConfig {
38    use super::config::{resolve_rndc_config, resolve_rndc_config_from_deprecated};
39
40    // Extract instance-level config
41    let instance_config = instance.spec.rndc_key.as_ref();
42
43    // Extract role-level config (from cluster primary/secondary config)
44    // Note: Although serde flattens, the Rust struct still has the common field
45    let role_config = cluster.and_then(|c| match instance.spec.role {
46        crate::crd::ServerRole::Primary => c
47            .spec
48            .common
49            .primary
50            .as_ref()
51            .and_then(|p| p.rndc_key.as_ref()),
52        crate::crd::ServerRole::Secondary => c
53            .spec
54            .common
55            .secondary
56            .as_ref()
57            .and_then(|s| s.rndc_key.as_ref()),
58    });
59
60    // No global-level RNDC config is supported in the current design
61    // RNDC keys are instance-specific or role-specific only
62
63    // Handle backward compatibility with deprecated fields
64    #[allow(deprecated)]
65    let deprecated_instance_ref = instance.spec.rndc_secret_ref.as_ref();
66
67    // First, resolve from new fields (no global level for RNDC keys)
68    let resolved = resolve_rndc_config(instance_config, role_config, None);
69
70    // Then, apply backward compatibility if needed
71    if instance_config.is_none() && role_config.is_none() {
72        // Only use deprecated fields if no new fields are present
73        if deprecated_instance_ref.is_some() {
74            return resolve_rndc_config_from_deprecated(
75                None,
76                deprecated_instance_ref,
77                instance.spec.role.clone(),
78            );
79        }
80    }
81
82    resolved
83}
84
85#[allow(clippy::too_many_lines)] // Function orchestrates multiple resource creation steps
86pub(super) async fn create_or_update_resources(
87    client: &Client,
88    namespace: &str,
89    name: &str,
90    instance: &Bind9Instance,
91) -> Result<(
92    Option<Bind9Cluster>,
93    Option<crate::crd::ClusterBind9Provider>,
94    Option<Secret>, // Added: return Secret for rotation status updates
95)> {
96    debug!(
97        namespace = %namespace,
98        name = %name,
99        "Creating or updating Kubernetes resources"
100    );
101
102    // Fetch the Bind9Cluster (namespace-scoped) if referenced
103    let cluster = if instance.spec.cluster_ref.is_empty() {
104        debug!("No cluster reference, proceeding with standalone instance");
105        None
106    } else {
107        debug!(cluster_ref = %instance.spec.cluster_ref, "Fetching Bind9Cluster");
108        let cluster_api: Api<Bind9Cluster> = Api::namespaced(client.clone(), namespace);
109        match cluster_api.get(&instance.spec.cluster_ref).await {
110            Ok(cluster) => {
111                debug!(
112                    cluster_name = %instance.spec.cluster_ref,
113                    "Successfully fetched Bind9Cluster"
114                );
115                info!(
116                    "Found Bind9Cluster: {}/{}",
117                    namespace, instance.spec.cluster_ref
118                );
119                Some(cluster)
120            }
121            Err(e) => {
122                warn!(
123                    "Failed to fetch Bind9Cluster {}/{}: {}. Proceeding with instance-only config.",
124                    namespace, instance.spec.cluster_ref, e
125                );
126                None
127            }
128        }
129    };
130
131    // Fetch the ClusterBind9Provider (cluster-scoped) if no namespace-scoped cluster was found
132    let cluster_provider = if cluster.is_none() && !instance.spec.cluster_ref.is_empty() {
133        debug!(cluster_ref = %instance.spec.cluster_ref, "Fetching ClusterBind9Provider");
134        let cluster_provider_api: Api<crate::crd::ClusterBind9Provider> = Api::all(client.clone());
135        match cluster_provider_api.get(&instance.spec.cluster_ref).await {
136            Ok(gc) => {
137                debug!(
138                    cluster_name = %instance.spec.cluster_ref,
139                    "Successfully fetched ClusterBind9Provider"
140                );
141                info!("Found ClusterBind9Provider: {}", instance.spec.cluster_ref);
142                Some(gc)
143            }
144            Err(e) => {
145                warn!(
146                    "Failed to fetch ClusterBind9Provider {}: {}. Proceeding with instance-only config.",
147                    instance.spec.cluster_ref, e
148                );
149                None
150            }
151        }
152    } else {
153        None
154    };
155
156    // Resolve RNDC configuration with proper precedence
157    let rndc_config =
158        resolve_full_rndc_config(instance, cluster.as_ref(), cluster_provider.as_ref());
159    debug!(
160        "Resolved RNDC config: auto_rotate={}, rotate_after={}",
161        rndc_config.auto_rotate, rndc_config.rotate_after
162    );
163
164    // 1. Create/update ServiceAccount (must be first, as deployment will reference it)
165    debug!("Step 1: Creating/updating ServiceAccount");
166    create_or_update_service_account(client, namespace, instance).await?;
167
168    // 2. Create/update RNDC Secret with rotation support (must be before deployment, as it will be mounted)
169    debug!("Step 2: Creating/updating RNDC Secret with rotation support");
170    let secret_name =
171        create_or_update_rndc_secret_with_config(client, namespace, name, instance, &rndc_config)
172            .await?;
173
174    // Fetch the Secret for rotation status updates (only if rotation is enabled)
175    let secret = if rndc_config.auto_rotate {
176        let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
177        secret_api.get(&secret_name).await.ok()
178    } else {
179        None
180    };
181
182    // 3. Create/update ConfigMap
183    debug!("Step 3: Creating/updating ConfigMap");
184    create_or_update_configmap(
185        client,
186        namespace,
187        name,
188        instance,
189        cluster.as_ref(),
190        cluster_provider.as_ref(),
191    )
192    .await?;
193
194    // 4. Create/update Deployment
195    debug!("Step 4: Creating/updating Deployment");
196    create_or_update_deployment(
197        client,
198        namespace,
199        name,
200        instance,
201        cluster.as_ref(),
202        cluster_provider.as_ref(),
203    )
204    .await?;
205
206    // 5. Create/update Service
207    debug!("Step 5: Creating/updating Service");
208    create_or_update_service(
209        client,
210        namespace,
211        name,
212        instance,
213        cluster.as_ref(),
214        cluster_provider.as_ref(),
215    )
216    .await?;
217
218    debug!("Successfully created/updated all resources");
219    Ok((cluster, cluster_provider, secret))
220}
221
222/// Create or update the `ServiceAccount` for BIND9 pods
223async fn create_or_update_service_account(
224    client: &Client,
225    namespace: &str,
226    instance: &Bind9Instance,
227) -> Result<()> {
228    let service_account = build_service_account(namespace, instance);
229    create_or_apply(client, namespace, &service_account, "bindy-controller").await
230}
231
232/// Create or update the RNDC Secret for BIND9 remote control
233/// Creates or updates RNDC `Secret` based on configuration.
234///
235/// Supports three modes:
236/// 1. **Auto-generated**: Operator creates and optionally rotates RNDC keys
237/// 2. **Secret reference**: Use existing `Secret` (no operator management)
238/// 3. **Inline spec**: Create `Secret` from inline specification
239///
240/// # Arguments
241///
242/// * `client` - Kubernetes client
243/// * `namespace` - Namespace for the `Secret`
244/// * `name` - Instance name (used for `Secret` naming)
245/// * `instance` - `Bind9Instance` resource
246/// * `config` - RNDC configuration (resolved via precedence)
247///
248/// # Returns
249///
250/// Returns the `Secret` name to use in `Deployment` configuration.
251///
252/// # Errors
253///
254/// Returns error if `Secret` creation/update fails or API call fails.
255#[allow(dead_code)] // Will be used when integrated into reconciler
256#[allow(clippy::too_many_lines)] // Function implements three Secret modes
257async fn create_or_update_rndc_secret_with_config(
258    client: &Client,
259    namespace: &str,
260    name: &str,
261    instance: &Bind9Instance,
262    config: &crate::crd::RndcKeyConfig,
263) -> Result<String> {
264    use chrono::Utc;
265
266    // Mode 1: Use existing Secret via secret_ref
267    if let Some(ref secret_ref) = config.secret_ref {
268        info!(
269            "Using existing Secret reference: {}/{}",
270            namespace, secret_ref.name
271        );
272        return Ok(secret_ref.name.clone());
273    }
274
275    // Mode 2 & 3: Create/manage Secret (inline spec or auto-generated)
276    let secret_name = if let Some(ref secret_spec) = config.secret {
277        // Use name from inline spec
278        secret_spec.metadata.name.clone()
279    } else {
280        // Default name for auto-generated
281        format!("{name}-rndc-key")
282    };
283
284    let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
285
286    // Check if Secret exists and if rotation is due
287    match secret_api.get(&secret_name).await {
288        Ok(existing_secret) => {
289            // Verify Secret has required keys first
290            if let Some(ref data) = existing_secret.data {
291                if !data.contains_key("key-name")
292                    || !data.contains_key("algorithm")
293                    || !data.contains_key("secret")
294                {
295                    warn!(
296                        "RNDC Secret {}/{} is missing required keys, will recreate",
297                        namespace, secret_name
298                    );
299                    secret_api
300                        .delete(&secret_name, &kube::api::DeleteParams::default())
301                        .await?;
302                    // Fall through to create new Secret
303                }
304            } else {
305                warn!(
306                    "RNDC Secret {}/{} has no data, will recreate",
307                    namespace, secret_name
308                );
309                secret_api
310                    .delete(&secret_name, &kube::api::DeleteParams::default())
311                    .await?;
312                // Fall through to create new Secret
313            }
314
315            // Check if rotation annotations need to be added or updated
316            let has_annotations = existing_secret
317                .metadata
318                .annotations
319                .as_ref()
320                .and_then(|a| a.get(crate::constants::ANNOTATION_RNDC_CREATED_AT))
321                .is_some();
322
323            if config.auto_rotate && !has_annotations {
324                info!(
325                    "RNDC Secret {}/{} missing rotation annotations, adding them",
326                    namespace, secret_name
327                );
328                add_rotation_annotations_to_secret(&secret_api, &secret_name, config).await?;
329                return Ok(secret_name);
330            }
331
332            // Check if rotation is needed
333            if config.auto_rotate && should_rotate_secret(&existing_secret, config)? {
334                info!(
335                    "RNDC Secret {}/{} rotation is due, rotating",
336                    namespace, secret_name
337                );
338                rotate_rndc_secret(
339                    client,
340                    namespace,
341                    &secret_name,
342                    config,
343                    instance,
344                    &existing_secret,
345                )
346                .await?;
347                return Ok(secret_name);
348            }
349
350            // Check for configuration drift (algorithm changed)
351            if let Some(ref data) = existing_secret.data {
352                if data.contains_key("algorithm") {
353                    let current_algorithm =
354                        std::str::from_utf8(&data.get("algorithm").unwrap().0).unwrap_or("unknown");
355                    let desired_algorithm = config.algorithm.as_str();
356
357                    if current_algorithm == desired_algorithm {
358                        info!(
359                            "RNDC Secret {}/{} exists and is valid, skipping creation",
360                            namespace, secret_name
361                        );
362                        return Ok(secret_name);
363                    }
364                    warn!(
365                        "RNDC Secret {}/{} algorithm mismatch (current: {}, desired: {}), will recreate",
366                        namespace, secret_name, current_algorithm, desired_algorithm
367                    );
368                    secret_api
369                        .delete(&secret_name, &kube::api::DeleteParams::default())
370                        .await?;
371                    // Fall through to create new Secret
372                }
373            }
374        }
375        Err(_) => {
376            info!(
377                "RNDC Secret {}/{} does not exist, creating",
378                namespace, secret_name
379            );
380        }
381    }
382
383    // Mode 2: Create from inline spec
384    if let Some(_secret_spec) = &config.secret {
385        // TODO: Implement inline Secret creation from SecretSpec
386        // For now, fall through to auto-generated
387        info!("Creating RNDC Secret from inline spec with rotation enabled");
388    }
389
390    // Mode 3: Auto-generate Secret
391    let mut key_data = Bind9Manager::generate_rndc_key();
392    key_data.name = "bindy-operator".to_string();
393    key_data.algorithm = config.algorithm.clone();
394
395    // Calculate rotation timestamps if enabled
396    let created_at = Utc::now();
397    let rotate_after = if config.auto_rotate {
398        crate::bind9::duration::parse_duration(&config.rotate_after).ok()
399    } else {
400        None
401    };
402
403    // Create Secret with annotations using helper function
404    let secret = crate::bind9::rndc::create_rndc_secret_with_annotations(
405        namespace,
406        &secret_name,
407        &key_data,
408        created_at,
409        rotate_after,
410        0, // Initial rotation count
411    );
412
413    // Add owner reference
414    let owner_ref = OwnerReference {
415        api_version: API_GROUP_VERSION.to_string(),
416        kind: KIND_BIND9_INSTANCE.to_string(),
417        name: name.to_string(),
418        uid: instance.metadata.uid.clone().unwrap_or_default(),
419        controller: Some(true),
420        block_owner_deletion: Some(true),
421    };
422
423    let mut secret_with_owner = secret;
424    secret_with_owner
425        .metadata
426        .owner_references
427        .get_or_insert_with(Vec::new)
428        .push(owner_ref);
429
430    // Create the Secret
431    if config.auto_rotate {
432        info!(
433            "Creating RNDC Secret {}/{} with rotation enabled (rotate after: {})",
434            namespace, secret_name, config.rotate_after
435        );
436    } else {
437        info!(
438            "Creating RNDC Secret {}/{} without rotation",
439            namespace, secret_name
440        );
441    }
442
443    secret_api
444        .create(&PostParams::default(), &secret_with_owner)
445        .await?;
446
447    Ok(secret_name)
448}
449
450/// Legacy Secret creation function (backward compatibility).
451///
452/// This function maintains the original behavior for existing reconciler code.
453/// New code should use `create_or_update_rndc_secret_with_config` instead.
454#[allow(dead_code)] // Kept for backward compatibility, may be removed in future
455async fn create_or_update_rndc_secret(
456    client: &Client,
457    namespace: &str,
458    name: &str,
459    instance: &Bind9Instance,
460) -> Result<()> {
461    let secret_name = format!("{name}-rndc-key");
462    let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
463
464    // Check if secret already exists
465    match secret_api.get(&secret_name).await {
466        Ok(existing_secret) => {
467            // Secret exists, don't regenerate the key
468            info!(
469                "RNDC Secret {}/{} already exists, skipping",
470                namespace, secret_name
471            );
472            // Verify it has the required keys
473            if let Some(ref data) = existing_secret.data {
474                if !data.contains_key("key-name")
475                    || !data.contains_key("algorithm")
476                    || !data.contains_key("secret")
477                {
478                    warn!(
479                        "RNDC Secret {}/{} is missing required keys, will recreate",
480                        namespace, secret_name
481                    );
482                    // Delete and recreate
483                    secret_api
484                        .delete(&secret_name, &kube::api::DeleteParams::default())
485                        .await?;
486                } else {
487                    return Ok(());
488                }
489            } else {
490                warn!(
491                    "RNDC Secret {}/{} has no data, will recreate",
492                    namespace, secret_name
493                );
494                secret_api
495                    .delete(&secret_name, &kube::api::DeleteParams::default())
496                    .await?;
497            }
498        }
499        Err(_) => {
500            info!(
501                "RNDC Secret {}/{} does not exist, creating",
502                namespace, secret_name
503            );
504        }
505    }
506
507    // Generate new RNDC key
508    let mut key_data = Bind9Manager::generate_rndc_key();
509    key_data.name = "bindy-operator".to_string();
510
511    // Create Secret data
512    let secret_data = Bind9Manager::create_rndc_secret_data(&key_data);
513
514    // Create owner reference to the Bind9Instance
515    let owner_ref = OwnerReference {
516        api_version: API_GROUP_VERSION.to_string(),
517        kind: KIND_BIND9_INSTANCE.to_string(),
518        name: name.to_string(),
519        uid: instance.metadata.uid.clone().unwrap_or_default(),
520        controller: Some(true),
521        block_owner_deletion: Some(true),
522    };
523
524    // Build Secret object
525    let secret = Secret {
526        metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
527            name: Some(secret_name.clone()),
528            namespace: Some(namespace.to_string()),
529            owner_references: Some(vec![owner_ref]),
530            ..Default::default()
531        },
532        string_data: Some(secret_data),
533        ..Default::default()
534    };
535
536    // Create the secret
537    info!("Creating RNDC Secret {}/{}", namespace, secret_name);
538    secret_api.create(&PostParams::default(), &secret).await?;
539
540    Ok(())
541}
542
543/// Adds rotation annotations to an existing RNDC `Secret` without regenerating the key.
544///
545/// This is used when rotation is enabled for a Secret that was created without rotation.
546///
547/// # Arguments
548///
549/// * `secret_api` - Kubernetes API client for Secrets
550/// * `secret_name` - Name of the Secret to update
551/// * `config` - RNDC configuration with rotation settings
552///
553/// # Errors
554///
555/// Returns error if Secret patch fails or duration parsing fails.
556async fn add_rotation_annotations_to_secret(
557    secret_api: &Api<Secret>,
558    secret_name: &str,
559    config: &crate::crd::RndcKeyConfig,
560) -> Result<()> {
561    use chrono::Utc;
562    use kube::api::{Patch, PatchParams};
563    use std::collections::BTreeMap;
564
565    let created_at = Utc::now();
566    let rotate_after = crate::bind9::duration::parse_duration(&config.rotate_after)?;
567    let rotate_at = created_at + chrono::Duration::from_std(rotate_after)?;
568
569    let mut annotations = BTreeMap::new();
570    annotations.insert(
571        crate::constants::ANNOTATION_RNDC_CREATED_AT.to_string(),
572        created_at.to_rfc3339(),
573    );
574    annotations.insert(
575        crate::constants::ANNOTATION_RNDC_ROTATE_AT.to_string(),
576        rotate_at.to_rfc3339(),
577    );
578    annotations.insert(
579        crate::constants::ANNOTATION_RNDC_ROTATION_COUNT.to_string(),
580        "0".to_string(),
581    );
582
583    let patch = serde_json::json!({
584        "metadata": {
585            "annotations": annotations
586        }
587    });
588
589    info!(
590        "Adding rotation annotations to existing Secret {} (rotate at: {})",
591        secret_name,
592        rotate_at.to_rfc3339()
593    );
594
595    secret_api
596        .patch(
597            secret_name,
598            &PatchParams::apply("bindy-operator"),
599            &Patch::Merge(&patch),
600        )
601        .await?;
602
603    Ok(())
604}
605
606/// Checks if RNDC `Secret` rotation is due.
607///
608/// # Arguments
609///
610/// * `secret` - The RNDC `Secret` to check
611/// * `config` - RNDC configuration with rotation settings
612///
613/// # Returns
614///
615/// Returns `true` if rotation is due, `false` otherwise.
616///
617/// # Rotation Criteria
618///
619/// - Auto-rotation must be enabled in config
620/// - `rotate_at` annotation must be in the past
621/// - At least 1 hour must have passed since last rotation (rate limit)
622///
623/// # Errors
624///
625/// Returns error if annotation parsing fails.
626pub(super) fn should_rotate_secret(
627    secret: &Secret,
628    config: &crate::crd::RndcKeyConfig,
629) -> Result<bool> {
630    use chrono::Utc;
631
632    // Auto-rotation must be enabled
633    if !config.auto_rotate {
634        return Ok(false);
635    }
636
637    // Parse rotation annotations
638    let Some(annotations) = &secret.metadata.annotations else {
639        debug!("Secret has no annotations, rotation not due");
640        return Ok(false);
641    };
642
643    let (created_at, rotate_at, _rotation_count) =
644        crate::bind9::rndc::parse_rotation_annotations(annotations)?;
645
646    let now = Utc::now();
647
648    // Rate limit: Ensure at least 1 hour has passed since creation/last rotation
649    let time_since_creation = now.signed_duration_since(created_at);
650    if time_since_creation.num_hours() < crate::constants::MIN_TIME_BETWEEN_ROTATIONS_HOURS {
651        debug!(
652            "Skipping rotation - Secret was created/rotated {} minutes ago (min 1 hour required)",
653            time_since_creation.num_minutes()
654        );
655        return Ok(false);
656    }
657
658    // Check if rotation is due based on rotate_at annotation
659    Ok(crate::bind9::rndc::is_rotation_due(rotate_at, now))
660}
661
662/// Rotates RNDC `Secret` by generating new key and updating annotations.
663///
664/// # Arguments
665///
666/// * `client` - Kubernetes client
667/// * `namespace` - `Secret` namespace
668/// * `secret_name` - Name of the `Secret` to rotate
669/// * `config` - RNDC configuration with rotation settings
670/// * `instance` - `Bind9Instance` for owner reference
671/// * `existing_secret` - Current `Secret` (for incrementing rotation count)
672///
673/// # Rotation Process
674///
675/// 1. Generate new RNDC key
676/// 2. Increment rotation count from existing `Secret`
677/// 3. Update `Secret` with new key data
678/// 4. Update annotations: `created_at`, `rotate_at`, `rotation_count`
679/// 5. Trigger `Deployment` rollout via annotation
680///
681/// # Errors
682///
683/// Returns error if `Secret` update fails or annotation parsing fails.
684#[allow(dead_code)] // Will be used when integrated into reconciler
685async fn rotate_rndc_secret(
686    client: &Client,
687    namespace: &str,
688    secret_name: &str,
689    config: &crate::crd::RndcKeyConfig,
690    instance: &Bind9Instance,
691    existing_secret: &Secret,
692) -> Result<()> {
693    use chrono::Utc;
694
695    // Parse existing rotation annotations
696    let annotations = existing_secret
697        .metadata
698        .annotations
699        .as_ref()
700        .context("Secret missing annotations")?;
701
702    let (_created_at, _rotate_at, rotation_count) =
703        crate::bind9::rndc::parse_rotation_annotations(annotations)?;
704
705    // Increment rotation count
706    let new_rotation_count = rotation_count + 1;
707
708    info!(
709        "Rotating RNDC Secret {}/{} (rotation #{})",
710        namespace, secret_name, new_rotation_count
711    );
712
713    // Generate new RNDC key
714    let mut key_data = Bind9Manager::generate_rndc_key();
715    key_data.name = "bindy-operator".to_string();
716    key_data.algorithm = config.algorithm.clone();
717
718    // Calculate new rotation timestamps
719    let created_at = Utc::now();
720    let rotate_after = crate::bind9::duration::parse_duration(&config.rotate_after)?;
721
722    // Create new Secret with updated annotations and data
723    let new_secret = crate::bind9::rndc::create_rndc_secret_with_annotations(
724        namespace,
725        secret_name,
726        &key_data,
727        created_at,
728        Some(rotate_after),
729        new_rotation_count,
730    );
731
732    // Preserve owner references from existing Secret
733    let mut updated_secret = new_secret;
734    updated_secret
735        .metadata
736        .owner_references
737        .clone_from(&existing_secret.metadata.owner_references);
738
739    // Replace the Secret
740    let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
741    secret_api
742        .replace(secret_name, &PostParams::default(), &updated_secret)
743        .await?;
744
745    info!(
746        "Successfully rotated RNDC Secret {}/{} (rotation #{})",
747        namespace, secret_name, new_rotation_count
748    );
749
750    // Trigger Deployment rollout by patching pod template annotation
751    trigger_deployment_rollout(client, namespace, &instance.name_any()).await?;
752
753    Ok(())
754}
755
756/// Triggers a `Deployment` rollout by updating pod template annotation.
757///
758/// # Arguments
759///
760/// * `client` - Kubernetes client
761/// * `namespace` - Deployment namespace
762/// * `instance_name` - Name of the `Bind9Instance` (= Deployment name)
763///
764/// # Errors
765///
766/// Returns error if Deployment patch fails.
767async fn trigger_deployment_rollout(
768    client: &Client,
769    namespace: &str,
770    instance_name: &str,
771) -> Result<()> {
772    use chrono::Utc;
773    use serde_json::json;
774
775    let deployment_api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
776
777    // Patch deployment pod template annotation to trigger rolling restart
778    let patch = json!({
779        "spec": {
780            "template": {
781                "metadata": {
782                    "annotations": {
783                        crate::constants::ANNOTATION_RNDC_ROTATED_AT: Utc::now().to_rfc3339()
784                    }
785                }
786            }
787        }
788    });
789
790    deployment_api
791        .patch(
792            instance_name,
793            &PatchParams::default(),
794            &kube::api::Patch::Merge(&patch),
795        )
796        .await?;
797
798    info!(
799        "Triggered Deployment {}/{} rollout after RNDC rotation",
800        namespace, instance_name
801    );
802
803    Ok(())
804}
805
806/// Create or update the `ConfigMap` for BIND9 configuration
807///
808/// **Note:** If the instance belongs to a cluster (has `spec.clusterRef`), this function
809/// does NOT create an instance-specific `ConfigMap`. Instead, the instance will use the
810/// cluster-level shared `ConfigMap` created by the `Bind9Cluster` reconciler.
811async fn create_or_update_configmap(
812    client: &Client,
813    namespace: &str,
814    name: &str,
815    instance: &Bind9Instance,
816    cluster: Option<&Bind9Cluster>,
817    _cluster_provider: Option<&crate::crd::ClusterBind9Provider>,
818) -> Result<()> {
819    // If instance belongs to a cluster, skip ConfigMap creation
820    // The cluster creates a shared ConfigMap that all instances use
821    if !instance.spec.cluster_ref.is_empty() {
822        debug!(
823            "Instance {}/{} belongs to cluster '{}', using cluster ConfigMap",
824            namespace, name, instance.spec.cluster_ref
825        );
826        return Ok(());
827    }
828
829    // Instance is standalone (no clusterRef), create instance-specific ConfigMap
830    info!(
831        "Instance {}/{} is standalone, creating instance-specific ConfigMap",
832        namespace, name
833    );
834
835    // Get role-specific allow-transfer override from cluster config
836    // Note: We only reach this code for standalone instances (no clusterRef),
837    // so we should only have a namespace-scoped cluster here, not a global cluster
838    let role_allow_transfer = cluster.and_then(|c| match instance.spec.role {
839        crate::crd::ServerRole::Primary => c
840            .spec
841            .common
842            .primary
843            .as_ref()
844            .and_then(|p| p.allow_transfer.as_ref()),
845        crate::crd::ServerRole::Secondary => c
846            .spec
847            .common
848            .secondary
849            .as_ref()
850            .and_then(|s| s.allow_transfer.as_ref()),
851    });
852
853    // build_configmap returns None if custom ConfigMaps are referenced
854    if let Some(configmap) =
855        build_configmap(name, namespace, instance, cluster, role_allow_transfer)
856    {
857        let cm_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
858        let cm_name = format!("{name}-config");
859
860        if (cm_api.get(&cm_name).await).is_ok() {
861            // ConfigMap exists, update it
862            info!("Updating ConfigMap {}/{}", namespace, cm_name);
863            cm_api
864                .replace(&cm_name, &PostParams::default(), &configmap)
865                .await?;
866        } else {
867            // ConfigMap doesn't exist, create it
868            info!("Creating ConfigMap {}/{}", namespace, cm_name);
869            cm_api.create(&PostParams::default(), &configmap).await?;
870        }
871    } else {
872        info!(
873            "Using custom ConfigMaps for {}/{}, skipping ConfigMap creation",
874            namespace, name
875        );
876    }
877
878    Ok(())
879}
880
881/// Check if a deployment needs updating by comparing current and desired state.
882///
883/// Returns true if any of the following have changed:
884/// - Replicas count
885/// - API container image
886/// - API container environment variables
887/// - API container imagePullPolicy
888/// - API container resources
889fn deployment_needs_update(current: &Deployment, desired: &Deployment) -> bool {
890    // Compare desired replicas with current replicas
891    let desired_replicas = desired.spec.as_ref().and_then(|s| s.replicas);
892    let current_replicas = current.spec.as_ref().and_then(|s| s.replicas);
893
894    if desired_replicas != current_replicas {
895        debug!(
896            "Replicas changed: current={:?}, desired={:?}",
897            current_replicas, desired_replicas
898        );
899        return true;
900    }
901
902    // Get the current api container
903    let current_api_container = current
904        .spec
905        .as_ref()
906        .and_then(|s| s.template.spec.as_ref())
907        .and_then(|pod_spec| {
908            pod_spec
909                .containers
910                .iter()
911                .find(|c| c.name == crate::constants::CONTAINER_NAME_BINDCAR)
912        });
913
914    // Get the desired api container
915    let desired_api_container = desired
916        .spec
917        .as_ref()
918        .and_then(|s| s.template.spec.as_ref())
919        .and_then(|pod_spec| {
920            pod_spec
921                .containers
922                .iter()
923                .find(|c| c.name == crate::constants::CONTAINER_NAME_BINDCAR)
924        });
925
926    // Check api container fields if both exist
927    if let (Some(current_api), Some(desired_api)) = (current_api_container, desired_api_container) {
928        // Check image
929        if current_api.image != desired_api.image {
930            debug!(
931                "API container image changed: current={:?}, desired={:?}",
932                current_api.image, desired_api.image
933            );
934            return true;
935        }
936
937        // Check env variables
938        if current_api.env != desired_api.env {
939            debug!("API container env changed");
940            return true;
941        }
942
943        // Check imagePullPolicy
944        if current_api.image_pull_policy != desired_api.image_pull_policy {
945            debug!(
946                "API container imagePullPolicy changed: current={:?}, desired={:?}",
947                current_api.image_pull_policy, desired_api.image_pull_policy
948            );
949            return true;
950        }
951
952        // Check resources
953        if current_api.resources != desired_api.resources {
954            debug!("API container resources changed");
955            return true;
956        }
957    } else if current_api_container.is_some() != desired_api_container.is_some() {
958        // One exists but not the other - needs update
959        debug!("API container existence changed");
960        return true;
961    }
962
963    false
964}
965
966/// Create or update the Deployment for BIND9
967async fn create_or_update_deployment(
968    client: &Client,
969    namespace: &str,
970    name: &str,
971    instance: &Bind9Instance,
972    cluster: Option<&Bind9Cluster>,
973    cluster_provider: Option<&crate::crd::ClusterBind9Provider>,
974) -> Result<()> {
975    let deployment = build_deployment(name, namespace, instance, cluster, cluster_provider);
976    let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
977
978    // Check if deployment exists - if not, create it and return early
979    if api.get(name).await.is_err() {
980        info!("Creating Deployment {}/{}", namespace, name);
981        api.create(&PostParams::default(), &deployment).await?;
982        return Ok(());
983    }
984
985    // Deployment exists - check if it needs updating before patching
986    debug!(
987        "Checking if Deployment {}/{} needs updating",
988        namespace, name
989    );
990
991    // Get the current deployment from the cluster
992    let current_deployment = api.get(name).await?;
993
994    // Compare current and desired state using helper function
995    if !deployment_needs_update(&current_deployment, &deployment) {
996        debug!(
997            "Deployment {}/{} is up to date, skipping patch",
998            namespace, name
999        );
1000        return Ok(());
1001    }
1002
1003    // Deployment needs updating - use strategic merge patch
1004    info!("Patching Deployment {}/{}", namespace, name);
1005
1006    let api_container = deployment
1007        .spec
1008        .as_ref()
1009        .and_then(|s| s.template.spec.as_ref())
1010        .and_then(|pod_spec| {
1011            pod_spec
1012                .containers
1013                .iter()
1014                .find(|c| c.name == crate::constants::CONTAINER_NAME_BINDCAR)
1015        });
1016
1017    let mut patch_containers = vec![];
1018
1019    // Add bind9 container name to preserve ordering (strategic merge needs this)
1020    patch_containers.push(json!({
1021        "name": crate::constants::CONTAINER_NAME_BIND9
1022    }));
1023
1024    // Add api container with only the fields we want to update
1025    if let Some(api) = api_container {
1026        let mut api_patch = json!({
1027            "name": crate::constants::CONTAINER_NAME_BINDCAR
1028        });
1029
1030        // Only include image if it exists (from bindcarConfig)
1031        if let Some(ref image) = api.image {
1032            api_patch["image"] = json!(image);
1033        }
1034
1035        // Only include env if it exists (from bindcarConfig)
1036        if let Some(ref env) = api.env {
1037            api_patch["env"] = json!(env);
1038        }
1039
1040        // Only include imagePullPolicy if it exists (from bindcarConfig)
1041        if let Some(ref pull_policy) = api.image_pull_policy {
1042            api_patch["imagePullPolicy"] = json!(pull_policy);
1043        }
1044
1045        // Only include resources if they exist (from bindcarConfig)
1046        if let Some(ref resources) = api.resources {
1047            api_patch["resources"] = json!(resources);
1048        }
1049
1050        patch_containers.push(api_patch);
1051    }
1052
1053    // Get labels from desired deployment (includes role label if present on instance)
1054    let labels = deployment.metadata.labels.as_ref();
1055    let pod_labels = deployment
1056        .spec
1057        .as_ref()
1058        .and_then(|s| s.template.metadata.as_ref())
1059        .and_then(|m| m.labels.as_ref());
1060
1061    // NOTE: We do NOT patch spec.selector because it is immutable in Kubernetes
1062    // Attempting to change selector labels will cause an API error: "field is immutable"
1063
1064    let mut patch = json!({
1065        "spec": {
1066            "replicas": deployment.spec.as_ref().and_then(|s| s.replicas),
1067            "template": {
1068                "spec": {
1069                    "containers": patch_containers,
1070                    "$setElementOrder/containers": [
1071                        {"name": crate::constants::CONTAINER_NAME_BIND9},
1072                        {"name": crate::constants::CONTAINER_NAME_BINDCAR}
1073                    ]
1074                }
1075            }
1076        }
1077    });
1078
1079    // Add metadata labels if present
1080    // NOTE: Strategic merge will update/add our labels but preserve any other labels
1081    // added by other controllers (e.g., kubectl, Helm, etc.)
1082    if let Some(labels) = labels {
1083        patch["metadata"] = json!({"labels": labels});
1084    }
1085
1086    // Add pod template labels if present
1087    // When pod template labels change, Kubernetes will recreate pods with new labels
1088    if let Some(pod_labels) = pod_labels {
1089        patch["spec"]["template"]["metadata"] = json!({"labels": pod_labels});
1090    }
1091
1092    api.patch(name, &PatchParams::default(), &Patch::Strategic(&patch))
1093        .await?;
1094
1095    Ok(())
1096}
1097
1098/// Create or update the Service for BIND9
1099async fn create_or_update_service(
1100    client: &Client,
1101    namespace: &str,
1102    name: &str,
1103    instance: &Bind9Instance,
1104    cluster: Option<&Bind9Cluster>,
1105    cluster_provider: Option<&crate::crd::ClusterBind9Provider>,
1106) -> Result<()> {
1107    // Get custom service spec based on instance role from cluster (namespace-scoped or global)
1108    let custom_spec = cluster
1109        .and_then(|c| match instance.spec.role {
1110            crate::crd::ServerRole::Primary => c
1111                .spec
1112                .common
1113                .primary
1114                .as_ref()
1115                .and_then(|p| p.service.as_ref()),
1116            crate::crd::ServerRole::Secondary => c
1117                .spec
1118                .common
1119                .secondary
1120                .as_ref()
1121                .and_then(|s| s.service.as_ref()),
1122        })
1123        .or_else(|| {
1124            // Fall back to global cluster if no namespace-scoped cluster
1125            cluster_provider.and_then(|gc| match instance.spec.role {
1126                crate::crd::ServerRole::Primary => gc
1127                    .spec
1128                    .common
1129                    .primary
1130                    .as_ref()
1131                    .and_then(|p| p.service.as_ref()),
1132                crate::crd::ServerRole::Secondary => gc
1133                    .spec
1134                    .common
1135                    .secondary
1136                    .as_ref()
1137                    .and_then(|s| s.service.as_ref()),
1138            })
1139        });
1140
1141    let service = build_service(name, namespace, instance, custom_spec);
1142    let svc_api: Api<Service> = Api::namespaced(client.clone(), namespace);
1143
1144    if let Ok(existing) = svc_api.get(name).await {
1145        // Service exists, update it (preserve clusterIP)
1146        info!("Updating Service {}/{}", namespace, name);
1147        let mut updated_service = service;
1148        if let Some(ref mut spec) = updated_service.spec {
1149            if let Some(ref existing_spec) = existing.spec {
1150                spec.cluster_ip.clone_from(&existing_spec.cluster_ip);
1151                spec.cluster_ips.clone_from(&existing_spec.cluster_ips);
1152            }
1153        }
1154        svc_api
1155            .replace(name, &PostParams::default(), &updated_service)
1156            .await?;
1157    } else {
1158        // Service doesn't exist, create it
1159        info!("Creating Service {}/{}", namespace, name);
1160        svc_api.create(&PostParams::default(), &service).await?;
1161    }
1162
1163    Ok(())
1164}
1165
1166/// Deletes all resources associated with a `Bind9Instance`.
1167///
1168/// Cleans up Kubernetes resources in reverse order:
1169/// 1. Service
1170/// 2. Deployment
1171/// 3. `ConfigMap`
1172///
1173/// # Arguments
1174///
1175/// * `client` - Kubernetes API client
1176/// * `instance` - The `Bind9Instance` resource to delete
1177///
1178/// # Returns
1179///
1180/// * `Ok(())` - If deletion succeeded or resources didn't exist
1181/// * `Err(_)` - If a critical error occurred during deletion
1182///
1183/// # Errors
1184///
1185/// Returns an error if Kubernetes API operations fail during resource deletion.
1186pub async fn delete_bind9instance(ctx: Arc<Context>, instance: Bind9Instance) -> Result<()> {
1187    let namespace = instance.namespace().unwrap_or_default();
1188    let name = instance.name_any();
1189
1190    info!("Deleting Bind9Instance: {}/{}", namespace, name);
1191
1192    // Delete resources in reverse order (Service, Deployment, ConfigMap)
1193    delete_resources(&ctx.client, &namespace, &name).await?;
1194
1195    info!("Successfully deleted resources for {}/{}", namespace, name);
1196
1197    Ok(())
1198}
1199
1200/// Delete all Kubernetes resources for a `Bind9Instance`
1201pub(super) async fn delete_resources(client: &Client, namespace: &str, name: &str) -> Result<()> {
1202    let delete_params = kube::api::DeleteParams::default();
1203
1204    // 1. Delete Service (if it exists)
1205    let svc_api: Api<Service> = Api::namespaced(client.clone(), namespace);
1206    match svc_api.delete(name, &delete_params).await {
1207        Ok(_) => info!("Deleted Service {}/{}", namespace, name),
1208        Err(e) => warn!("Failed to delete Service {}/{}: {}", namespace, name, e),
1209    }
1210
1211    // 2. Delete Deployment (if it exists)
1212    let deploy_api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
1213    match deploy_api.delete(name, &delete_params).await {
1214        Ok(_) => info!("Deleted Deployment {}/{}", namespace, name),
1215        Err(e) => warn!("Failed to delete Deployment {}/{}: {}", namespace, name, e),
1216    }
1217
1218    // 3. Delete ConfigMap (if it exists)
1219    let cm_api: Api<ConfigMap> = Api::namespaced(client.clone(), namespace);
1220    let cm_name = format!("{name}-config");
1221    match cm_api.delete(&cm_name, &delete_params).await {
1222        Ok(_) => info!("Deleted ConfigMap {}/{}", namespace, cm_name),
1223        Err(e) => warn!(
1224            "Failed to delete ConfigMap {}/{}: {}",
1225            namespace, cm_name, e
1226        ),
1227    }
1228
1229    // 4. Delete RNDC Secret (if it exists)
1230    let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
1231    let secret_name = format!("{name}-rndc-key");
1232    match secret_api.delete(&secret_name, &delete_params).await {
1233        Ok(_) => info!("Deleted Secret {}/{}", namespace, secret_name),
1234        Err(e) => warn!(
1235            "Failed to delete Secret {}/{}: {}",
1236            namespace, secret_name, e
1237        ),
1238    }
1239
1240    // 5. Delete ServiceAccount (if it exists and is owned by this instance)
1241    let sa_api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
1242    let sa_name = crate::constants::BIND9_SERVICE_ACCOUNT;
1243    match sa_api.get(sa_name).await {
1244        Ok(sa) => {
1245            // Check if this instance owns the ServiceAccount
1246            let is_owner = sa
1247                .metadata
1248                .owner_references
1249                .as_ref()
1250                .is_some_and(|owners| owners.iter().any(|owner| owner.name == name));
1251
1252            if is_owner {
1253                match sa_api.delete(sa_name, &delete_params).await {
1254                    Ok(_) => info!("Deleted ServiceAccount {}/{}", namespace, sa_name),
1255                    Err(e) => warn!(
1256                        "Failed to delete ServiceAccount {}/{}: {}",
1257                        namespace, sa_name, e
1258                    ),
1259                }
1260            } else {
1261                debug!(
1262                    "ServiceAccount {}/{} is not owned by this instance, skipping deletion",
1263                    namespace, sa_name
1264                );
1265            }
1266        }
1267        Err(e) => {
1268            debug!(
1269                "ServiceAccount {}/{} does not exist or cannot be retrieved: {}",
1270                namespace, sa_name, e
1271            );
1272        }
1273    }
1274
1275    Ok(())
1276}
1277
1278#[cfg(test)]
1279#[path = "resources_tests.rs"]
1280mod resources_tests;