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