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