bindy/
bind9_resources.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! BIND9 Kubernetes resource builders
5//!
6//! This module provides functions to build Kubernetes resources (`Deployment`, `ConfigMap`, `Service`)
7//! for BIND9 instances. All functions are pure and easily testable.
8
9use crate::bind9_acl::build_acl_list;
10use crate::constants::{
11    API_GROUP_VERSION, BIND9_MALLOC_CONF, BIND9_NONROOT_UID, BIND9_SERVICE_ACCOUNT,
12    CONTAINER_NAME_BIND9, CONTAINER_NAME_BINDCAR, DEFAULT_BIND9_VERSION, DNS_CONTAINER_PORT,
13    DNS_PORT, KIND_BIND9_INSTANCE, LIVENESS_FAILURE_THRESHOLD, LIVENESS_INITIAL_DELAY_SECS,
14    LIVENESS_PERIOD_SECS, LIVENESS_TIMEOUT_SECS, READINESS_FAILURE_THRESHOLD,
15    READINESS_INITIAL_DELAY_SECS, READINESS_PERIOD_SECS, READINESS_TIMEOUT_SECS, RNDC_PORT,
16};
17use crate::crd::{Bind9Cluster, Bind9Instance, ConfigMapRefs, ImageConfig};
18use crate::labels::{
19    APP_NAME_BIND9, COMPONENT_DNS_CLUSTER, COMPONENT_DNS_SERVER, K8S_COMPONENT, K8S_INSTANCE,
20    K8S_MANAGED_BY, K8S_NAME, K8S_PART_OF, MANAGED_BY_BIND9_CLUSTER, MANAGED_BY_BIND9_INSTANCE,
21    PART_OF_BINDY,
22};
23use anyhow::Context;
24use k8s_openapi::api::{
25    apps::v1::{Deployment, DeploymentSpec},
26    core::v1::{
27        Capabilities, ConfigMap, Container, ContainerPort, EmptyDirVolumeSource, EnvVar,
28        EnvVarSource, PodSecurityContext, PodSpec, PodTemplateSpec, Probe, SeccompProfile,
29        SecretKeySelector, SecurityContext, Service, ServiceAccount, ServicePort, ServiceSpec,
30        TCPSocketAction, Volume, VolumeMount,
31    },
32};
33use k8s_openapi::apimachinery::pkg::{
34    apis::meta::v1::{LabelSelector, ObjectMeta, OwnerReference},
35    util::intstr::IntOrString,
36};
37use kube::ResourceExt;
38use std::collections::BTreeMap;
39use tracing::{debug, warn};
40
41// Embed configuration templates at compile time
42const NAMED_CONF_TEMPLATE: &str = include_str!("../templates/named.conf.tmpl");
43const NAMED_CONF_OPTIONS_TEMPLATE: &str = include_str!("../templates/named.conf.options.tmpl");
44const RNDC_CONF_TEMPLATE: &str = include_str!("../templates/rndc.conf.tmpl");
45
46// DNSSEC policy template for zone signing
47const DNSSEC_POLICY_TEMPLATE: &str = r#"
48dnssec-policy "{{POLICY_NAME}}" {
49    // Key configuration
50    keys {
51        ksk lifetime {{KSK_LIFETIME}} algorithm {{ALGORITHM}};
52        zsk lifetime {{ZSK_LIFETIME}} algorithm {{ALGORITHM}};
53    };
54{{NSEC_CONFIG}}
55    // Signature validity periods
56    signatures-refresh 5d;
57    signatures-validity 30d;
58    signatures-validity-dnskey 30d;
59
60    // Zone propagation delay (time for zone updates to reach all servers)
61    zone-propagation-delay 300;  // 5 minutes
62
63    // Parent propagation delay (time for DS updates in parent zone)
64    parent-propagation-delay 3600;  // 1 hour
65
66    // Maximum zone TTL (affects key rollover timing)
67    max-zone-ttl 86400;  // 24 hours
68};
69"#;
70
71// BIND configuration file paths and mount points
72const BIND_ZONES_PATH: &str = "/etc/bind/zones";
73const BIND_CACHE_PATH: &str = "/var/cache/bind";
74const BIND_KEYS_PATH: &str = "/etc/bind/keys";
75const BIND_DNSSEC_KEYS_PATH: &str = "/var/cache/bind/keys";
76const BIND_NAMED_CONF_PATH: &str = "/etc/bind/named.conf";
77const BIND_NAMED_CONF_OPTIONS_PATH: &str = "/etc/bind/named.conf.options";
78const BIND_NAMED_CONF_ZONES_PATH: &str = "/etc/bind/named.conf.zones";
79const BIND_RNDC_CONF_PATH: &str = "/etc/bind/rndc.conf";
80
81// BIND configuration file names
82const NAMED_CONF_FILENAME: &str = "named.conf";
83const NAMED_CONF_OPTIONS_FILENAME: &str = "named.conf.options";
84const NAMED_CONF_ZONES_FILENAME: &str = "named.conf.zones";
85const RNDC_CONF_FILENAME: &str = "rndc.conf";
86
87// Volume mount names
88const VOLUME_ZONES: &str = "zones";
89const VOLUME_CACHE: &str = "cache";
90const VOLUME_RNDC_KEY: &str = "rndc-key";
91const VOLUME_CONFIG: &str = "config";
92const VOLUME_NAMED_CONF: &str = "named-conf";
93const VOLUME_NAMED_CONF_OPTIONS: &str = "named-conf-options";
94const VOLUME_NAMED_CONF_ZONES: &str = "named-conf-zones";
95const VOLUME_DNSSEC_KEYS: &str = "dnssec-keys";
96/// Memory-backed writable scratch volume for the bindcar sidecar (`TMPDIR`).
97/// Required because the sidecar runs with `readOnlyRootFilesystem: true` under
98/// Pod Security Admission `restricted` yet must write a `0600` TSIG key file
99/// for `nsupdate -k`.
100const VOLUME_TMP: &str = "tmp";
101
102// named.conf.options directive names for listen addresses
103const LISTEN_ON_DIRECTIVE: &str = "listen-on";
104const LISTEN_ON_V6_DIRECTIVE: &str = "listen-on-v6";
105/// Default listen address match list when `listenOn` / `listenOnV6` are unset.
106const LISTEN_ON_DEFAULT: &str = "any";
107
108// Default DNSSEC signing parameters
109const DEFAULT_DNSSEC_POLICY_NAME: &str = "default";
110const DEFAULT_DNSSEC_ALGORITHM: &str = "ECDSAP256SHA256";
111const DEFAULT_KSK_LIFETIME: &str = "unlimited";
112const DEFAULT_ZSK_LIFETIME: &str = "unlimited";
113const DEFAULT_NSEC3_SALT_LENGTH: u8 = 16;
114
115/// Generate DNSSEC policy configuration from cluster or instance config
116///
117/// Checks both instance and global configuration for DNSSEC signing settings.
118/// Instance config takes precedence over global config.
119///
120/// # Arguments
121///
122/// * `global_config` - Optional global cluster configuration
123/// * `instance_config` - Optional instance-specific configuration
124///
125/// # Returns
126///
127/// A string containing DNSSEC policy definitions, or empty string if signing is not enabled
128pub(crate) fn generate_dnssec_policies(
129    global_config: Option<&crate::crd::Bind9Config>,
130    instance_config: Option<&crate::crd::Bind9Config>,
131) -> String {
132    // Resolve the signing config with the same precedence/fallback semantics
133    // as get_dnssec_signing_config: instance config wins when it enables
134    // signing, otherwise fall back to the global config. Returns None when
135    // signing is not enabled anywhere.
136    let Some(signing) = get_dnssec_signing_config(global_config, instance_config) else {
137        return String::new();
138    };
139
140    // Extract policy parameters with defaults
141    let policy_name = signing
142        .policy
143        .as_deref()
144        .unwrap_or(DEFAULT_DNSSEC_POLICY_NAME);
145    let algorithm = signing
146        .algorithm
147        .as_deref()
148        .unwrap_or(DEFAULT_DNSSEC_ALGORITHM);
149    let ksk_lifetime = signing
150        .ksk_lifetime
151        .as_deref()
152        .unwrap_or(DEFAULT_KSK_LIFETIME);
153    let zsk_lifetime = signing
154        .zsk_lifetime
155        .as_deref()
156        .unwrap_or(DEFAULT_ZSK_LIFETIME);
157
158    // Configure NSEC/NSEC3. BIND 9.18 dnssec-policy grammar has an
159    // `nsec3param` statement but NO `nsec` keyword: NSEC is selected by
160    // omitting `nsec3param`, so the NSEC case renders nothing at all.
161    let nsec_config = if signing.nsec3.unwrap_or(false) {
162        let iterations = signing.nsec3_iterations.unwrap_or(0);
163        let salt_length = DEFAULT_NSEC3_SALT_LENGTH;
164        format!(
165            "\n    // Authenticated denial of existence (NSEC3)\n    nsec3param iterations {iterations} optout no salt-length {salt_length};\n"
166        )
167    } else {
168        String::new()
169    };
170
171    // Substitute template variables
172    DNSSEC_POLICY_TEMPLATE
173        .replace("{{POLICY_NAME}}", policy_name)
174        .replace("{{ALGORITHM}}", algorithm)
175        .replace("{{KSK_LIFETIME}}", ksk_lifetime)
176        .replace("{{ZSK_LIFETIME}}", zsk_lifetime)
177        .replace("{{NSEC_CONFIG}}", &nsec_config)
178}
179
180/// Check if DNSSEC signing is enabled in either instance or global config
181///
182/// Instance config takes precedence over global config.
183///
184/// # Arguments
185///
186/// * `global_config` - Optional global cluster configuration
187/// * `instance_config` - Optional instance-specific configuration
188///
189/// # Returns
190///
191/// `true` if DNSSEC signing is enabled, `false` otherwise
192#[allow(dead_code)]
193pub(crate) fn is_dnssec_signing_enabled(
194    global_config: Option<&crate::crd::Bind9Config>,
195    instance_config: Option<&crate::crd::Bind9Config>,
196) -> bool {
197    // Check instance config first, then fall back to global config
198    let dnssec_config = if let Some(instance) = instance_config {
199        instance.dnssec.as_ref().and_then(|d| d.signing.as_ref())
200    } else {
201        global_config.and_then(|g| g.dnssec.as_ref().and_then(|d| d.signing.as_ref()))
202    };
203
204    dnssec_config.is_some_and(|signing| signing.enabled)
205}
206
207/// Get DNSSEC signing configuration from either instance or global config
208///
209/// Instance config takes precedence over global config.
210///
211/// # Arguments
212///
213/// * `global_config` - Optional global cluster configuration
214/// * `instance_config` - Optional instance-specific configuration
215///
216/// # Returns
217///
218/// Reference to `DNSSECSigningConfig` if signing is enabled, `None` otherwise
219pub(crate) fn get_dnssec_signing_config<'a>(
220    global_config: Option<&'a crate::crd::Bind9Config>,
221    instance_config: Option<&'a crate::crd::Bind9Config>,
222) -> Option<&'a crate::crd::DNSSECSigningConfig> {
223    // Check instance config first, then fall back to global config
224    if let Some(instance) = instance_config {
225        if let Some(config) = instance.dnssec.as_ref().and_then(|d| d.signing.as_ref()) {
226            if config.enabled {
227                return Some(config);
228            }
229        }
230    }
231
232    global_config
233        .and_then(|g| g.dnssec.as_ref().and_then(|d| d.signing.as_ref()))
234        .filter(|config| config.enabled)
235}
236
237/// Build DNSSEC key volumes and volume mounts based on configuration
238///
239/// Creates appropriate volumes for DNSSEC keys based on the key source configuration:
240/// - User-supplied Secret: Mount keys from Secret (read-only for keys, writable for state files)
241/// - Auto-generated: Use `emptyDir` for BIND9 to generate keys
242/// - Persistent storage: Use `PersistentVolumeClaim` for keys
243///
244/// # Arguments
245///
246/// * `global_config` - Optional global cluster configuration
247/// * `instance_config` - Optional instance-specific configuration
248///
249/// # Returns
250///
251/// Tuple of (volumes, `volume_mounts`) to add to the pod spec
252pub(crate) fn build_dnssec_key_volumes(
253    global_config: Option<&crate::crd::Bind9Config>,
254    instance_config: Option<&crate::crd::Bind9Config>,
255) -> (Vec<Volume>, Vec<VolumeMount>) {
256    use k8s_openapi::api::core::v1::{
257        EmptyDirVolumeSource, SecretVolumeSource, Volume, VolumeMount,
258    };
259
260    let Some(signing_config) = get_dnssec_signing_config(global_config, instance_config) else {
261        return (vec![], vec![]);
262    };
263
264    let mut volumes = Vec::new();
265    let mut volume_mounts = Vec::new();
266
267    // Determine key source and create appropriate volume
268    match &signing_config.keys_from {
269        // Option 1: User-supplied keys from Secret
270        Some(crate::crd::DNSSECKeySource {
271            secret_ref: Some(secret),
272            ..
273        }) => {
274            volumes.push(Volume {
275                name: VOLUME_DNSSEC_KEYS.to_string(),
276                secret: Some(SecretVolumeSource {
277                    secret_name: Some(secret.name.clone()),
278                    default_mode: Some(0o600), // Secure permissions for key files
279                    ..Default::default()
280                }),
281                ..Default::default()
282            });
283
284            volume_mounts.push(VolumeMount {
285                name: VOLUME_DNSSEC_KEYS.to_string(),
286                mount_path: BIND_DNSSEC_KEYS_PATH.to_string(),
287                read_only: Some(false), // BIND9 may update .state files
288                ..Default::default()
289            });
290
291            debug!(
292                secret_name = %secret.name,
293                "Mounting user-supplied DNSSEC keys from Secret"
294            );
295        }
296
297        // Option 2: Auto-generated keys (emptyDir + Secret backup)
298        // This is also the default if no keys_from is specified
299        None
300        | Some(crate::crd::DNSSECKeySource {
301            secret_ref: None,
302            persistent_volume: None,
303        }) => {
304            if signing_config.auto_generate.unwrap_or(true) {
305                volumes.push(Volume {
306                    name: VOLUME_DNSSEC_KEYS.to_string(),
307                    empty_dir: Some(EmptyDirVolumeSource::default()),
308                    ..Default::default()
309                });
310
311                volume_mounts.push(VolumeMount {
312                    name: VOLUME_DNSSEC_KEYS.to_string(),
313                    mount_path: BIND_DNSSEC_KEYS_PATH.to_string(),
314                    ..Default::default()
315                });
316
317                debug!("DNSSEC keys will be auto-generated by BIND9 in emptyDir");
318
319                if signing_config.export_to_secret.unwrap_or(true) {
320                    debug!("Auto-generated keys will be exported to Secret for backup/restore");
321                }
322            }
323        }
324
325        // Option 3: Persistent storage (not implemented yet - requires StatefulSet)
326        Some(crate::crd::DNSSECKeySource {
327            persistent_volume: Some(_pvc),
328            ..
329        }) => {
330            warn!("Persistent storage for DNSSEC keys is not yet implemented - using emptyDir");
331            volumes.push(Volume {
332                name: VOLUME_DNSSEC_KEYS.to_string(),
333                empty_dir: Some(EmptyDirVolumeSource::default()),
334                ..Default::default()
335            });
336
337            volume_mounts.push(VolumeMount {
338                name: VOLUME_DNSSEC_KEYS.to_string(),
339                mount_path: BIND_DNSSEC_KEYS_PATH.to_string(),
340                ..Default::default()
341            });
342        }
343    }
344
345    (volumes, volume_mounts)
346}
347
348/// Builds standardized Kubernetes labels for BIND9 instance resources.
349///
350/// Creates labels for resources managed by `Bind9Instance` controller.
351/// Use `build_cluster_labels()` for resources managed by `Bind9Cluster`.
352///
353/// # Arguments
354///
355/// * `instance_name` - Name of the `Bind9Instance` resource
356///
357/// # Returns
358///
359/// A `BTreeMap` of label key-value pairs
360///
361/// Builds standardized Kubernetes labels for BIND9 cluster resources.
362///
363/// Creates labels for resources managed by `Bind9Cluster` controller.
364/// Use `build_labels()` for resources managed by `Bind9Instance`.
365///
366/// # Arguments
367///
368/// * `cluster_name` - Name of the `Bind9Cluster` resource
369///
370/// # Returns
371///
372/// A `BTreeMap` of label key-value pairs
373#[must_use]
374pub fn build_cluster_labels(cluster_name: &str) -> BTreeMap<String, String> {
375    let mut labels = BTreeMap::new();
376    labels.insert("app".into(), APP_NAME_BIND9.into());
377    labels.insert("cluster".into(), cluster_name.into());
378    labels.insert(K8S_NAME.into(), APP_NAME_BIND9.into());
379    labels.insert(K8S_INSTANCE.into(), cluster_name.into());
380    labels.insert(K8S_COMPONENT.into(), COMPONENT_DNS_CLUSTER.into());
381    labels.insert(K8S_MANAGED_BY.into(), MANAGED_BY_BIND9_CLUSTER.into());
382    labels.insert(K8S_PART_OF.into(), PART_OF_BINDY.into());
383    labels
384}
385
386/// Builds standardized Kubernetes labels for BIND9 instance resources,
387/// propagating the `managed-by` label from the `Bind9Instance` if it exists.
388///
389/// This function checks if the instance has a `bindy.firestoned.io/managed-by` label.
390/// If it does (indicating the instance is managed by a `Bind9Cluster`), that label
391/// value is propagated to the `app.kubernetes.io/managed-by` label. Otherwise,
392/// it defaults to `Bind9Instance`.
393///
394/// This ensures that when a `Bind9Cluster` creates a `Bind9Instance` with
395/// `managed-by: Bind9Cluster`, all child resources (Deployments, Services) also
396/// get `managed-by: Bind9Cluster`.
397///
398/// # Arguments
399///
400/// * `instance_name` - Name of the `Bind9Instance` resource
401/// * `instance` - The `Bind9Instance` resource to check for management labels
402///
403/// # Returns
404///
405/// A `BTreeMap` of label key-value pairs
406#[must_use]
407pub fn build_labels_from_instance(
408    instance_name: &str,
409    instance: &Bind9Instance,
410) -> BTreeMap<String, String> {
411    use crate::labels::{BINDY_MANAGED_BY_LABEL, BINDY_ROLE_LABEL};
412
413    let mut labels = BTreeMap::new();
414    labels.insert("app".into(), APP_NAME_BIND9.into());
415    labels.insert("instance".into(), instance_name.into());
416    labels.insert(K8S_NAME.into(), APP_NAME_BIND9.into());
417    labels.insert(K8S_INSTANCE.into(), instance_name.into());
418    labels.insert(K8S_COMPONENT.into(), COMPONENT_DNS_SERVER.into());
419    labels.insert(K8S_PART_OF.into(), PART_OF_BINDY.into());
420
421    // Check if instance has bindy.firestoned.io/managed-by label
422    // If it does, propagate it to app.kubernetes.io/managed-by
423    let managed_by = instance
424        .metadata
425        .labels
426        .as_ref()
427        .and_then(|labels| labels.get(BINDY_MANAGED_BY_LABEL))
428        .map_or(MANAGED_BY_BIND9_INSTANCE, String::as_str);
429
430    labels.insert(K8S_MANAGED_BY.into(), managed_by.into());
431
432    // Propagate bindy.firestoned.io/role label if it exists on the instance
433    // This allows selecting pods by role (e.g., all primaries)
434    if let Some(instance_labels) = &instance.metadata.labels {
435        if let Some(role) = instance_labels.get(BINDY_ROLE_LABEL) {
436            labels.insert(BINDY_ROLE_LABEL.into(), role.clone());
437        }
438    }
439
440    labels
441}
442
443/// Builds owner references for a resource owned by a `Bind9Instance`
444///
445/// Sets up cascade deletion so that when the `Bind9Instance` is deleted,
446/// all its child resources (`Deployment`, `Service`, `ConfigMap`) are automatically deleted.
447///
448/// # Arguments
449///
450/// * `instance` - The `Bind9Instance` that owns this resource
451///
452/// # Returns
453///
454/// A vector containing a single `OwnerReference` pointing to the instance
455#[must_use]
456pub fn build_owner_references(instance: &Bind9Instance) -> Vec<OwnerReference> {
457    vec![OwnerReference {
458        api_version: API_GROUP_VERSION.to_string(),
459        kind: KIND_BIND9_INSTANCE.to_string(),
460        name: instance.name_any(),
461        uid: instance.metadata.uid.clone().unwrap_or_default(),
462        controller: Some(true),
463        block_owner_deletion: Some(true),
464    }]
465}
466
467/// Builds a Kubernetes `ConfigMap` containing BIND9 configuration files.
468///
469/// Creates a `ConfigMap` with the files NOT overridden by custom
470/// `configMapRefs` (instance overrides cluster):
471/// - `named.conf` - Main BIND9 configuration (omitted when `namedConf` ref is set)
472/// - `named.conf.options` - BIND9 options (omitted when `namedConfOptions` ref is set)
473/// - `rndc.conf` - RNDC client configuration (ALWAYS generated; it is not
474///   overridable and is mounted from this `ConfigMap` unconditionally)
475///
476/// Because `rndc.conf` is always present, this `ConfigMap` must always be
477/// created — even when both `namedConf` and `namedConfOptions` refs are set —
478/// so the pod's `config` volume always has a backing `ConfigMap`.
479///
480/// # Arguments
481///
482/// * `name` - Name for the `ConfigMap` (typically `{instance-name}-config`)
483/// * `namespace` - Kubernetes namespace
484/// * `instance` - `Bind9Instance` spec containing configuration options
485/// * `cluster` - Optional `Bind9Cluster` containing shared configuration
486/// * `role_allow_transfer` - Role-specific allow-transfer override from cluster spec
487///
488/// # Returns
489///
490/// A Kubernetes `ConfigMap` resource ready for creation/update
491///
492/// # Errors
493/// Returns an error if any ACL, forwarder, or listen-address entry in the
494/// instance or cluster spec fails validation — see [`crate::bind9_acl`] for
495/// the accepted ACL syntax.
496pub fn build_configmap(
497    name: &str,
498    namespace: &str,
499    instance: &Bind9Instance,
500    cluster: Option<&Bind9Cluster>,
501    role_allow_transfer: Option<&Vec<String>>,
502) -> anyhow::Result<ConfigMap> {
503    debug!(
504        name = %name,
505        namespace = %namespace,
506        "Building ConfigMap for Bind9Instance"
507    );
508
509    // Check if custom ConfigMaps are referenced (instance overrides cluster)
510    let config_map_refs = instance
511        .spec
512        .config_map_refs
513        .as_ref()
514        .or_else(|| cluster.and_then(|c| c.spec.common.config_map_refs.as_ref()));
515
516    let named_conf_overridden = config_map_refs.is_some_and(|refs| refs.named_conf.is_some());
517    let options_overridden = config_map_refs.is_some_and(|refs| refs.named_conf_options.is_some());
518
519    // Generate configuration files not overridden by custom ConfigMap refs
520    let mut data = BTreeMap::new();
521    let labels = build_labels_from_instance(name, instance);
522
523    // Build named.conf (unless the user supplies it via namedConf ref)
524    if !named_conf_overridden {
525        let named_conf = build_named_conf(instance, cluster);
526        data.insert(NAMED_CONF_FILENAME.into(), named_conf);
527    }
528
529    // Build named.conf.options (unless the user supplies it via namedConfOptions ref);
530    // validates ACL entries before templating
531    if !options_overridden {
532        let options_conf = build_options_conf(instance, cluster, role_allow_transfer)?;
533        data.insert(NAMED_CONF_OPTIONS_FILENAME.into(), options_conf);
534    }
535
536    // Build rndc.conf (references key file mounted from Secret). This file is
537    // never overridable, so the generated ConfigMap always exists.
538    data.insert(RNDC_CONF_FILENAME.into(), RNDC_CONF_TEMPLATE.to_string());
539
540    // Note: We do NOT auto-generate named.conf.zones anymore.
541    // Users must explicitly provide a namedConfZones ConfigMap if they want zones support.
542
543    let owner_refs = build_owner_references(instance);
544
545    Ok(ConfigMap {
546        metadata: ObjectMeta {
547            name: Some(format!("{name}-config")),
548            namespace: Some(namespace.into()),
549            labels: Some(labels),
550            owner_references: Some(owner_refs),
551            ..Default::default()
552        },
553        data: Some(data),
554        ..Default::default()
555    })
556}
557
558/// Builds a cluster-level shared `ConfigMap` containing BIND9 configuration files.
559///
560/// This `ConfigMap` is shared across all instances in a cluster, containing configuration
561/// from `spec.global`. This eliminates the need for per-instance `ConfigMaps` when all
562/// instances share the same configuration.
563///
564/// # Arguments
565///
566/// * `cluster_name` - Name of the cluster (used for `ConfigMap` naming)
567/// * `namespace` - Kubernetes namespace
568/// * `cluster` - `Bind9Cluster` containing shared configuration
569///
570/// # Returns
571///
572/// A Kubernetes `ConfigMap` resource ready for creation/update
573///
574/// # Errors
575///
576/// Returns an error if configuration generation fails
577pub fn build_cluster_configmap(
578    cluster_name: &str,
579    namespace: &str,
580    cluster: &Bind9Cluster,
581) -> Result<ConfigMap, anyhow::Error> {
582    debug!(
583        cluster_name = %cluster_name,
584        namespace = %namespace,
585        "Building cluster-level shared ConfigMap"
586    );
587
588    // Generate default configuration from cluster spec
589    let mut data = BTreeMap::new();
590    let labels = build_cluster_labels(cluster_name);
591
592    // Build named.conf from cluster
593    let named_conf = build_cluster_named_conf(cluster);
594    data.insert(NAMED_CONF_FILENAME.into(), named_conf);
595
596    // Build named.conf.options from cluster.spec.common.global
597    let options_conf = build_cluster_options_conf(cluster)?;
598    data.insert(NAMED_CONF_OPTIONS_FILENAME.into(), options_conf);
599
600    // Build rndc.conf (references key file mounted from Secret)
601    data.insert(RNDC_CONF_FILENAME.into(), RNDC_CONF_TEMPLATE.to_string());
602
603    Ok(ConfigMap {
604        metadata: ObjectMeta {
605            name: Some(format!("{cluster_name}-config")),
606            namespace: Some(namespace.into()),
607            labels: Some(labels),
608            ..Default::default()
609        },
610        data: Some(data),
611        ..Default::default()
612    })
613}
614
615/// Build the main named.conf configuration from template
616///
617/// Generates the main BIND9 configuration file with conditional zones include.
618/// The zones include directive is only added if the user provides a `namedConfZones` `ConfigMap`.
619///
620/// # Arguments
621///
622/// * `instance` - `Bind9Instance` spec (checked first for config refs)
623/// * `cluster` - Optional `Bind9Cluster` (fallback for config refs)
624///
625/// # Returns
626///
627/// A string containing the complete named.conf configuration
628fn build_named_conf(instance: &Bind9Instance, cluster: Option<&Bind9Cluster>) -> String {
629    // Check if user provided a custom zones ConfigMap
630    let config_map_refs = instance
631        .spec
632        .config_map_refs
633        .as_ref()
634        .or_else(|| cluster.and_then(|c| c.spec.common.config_map_refs.as_ref()));
635
636    let zones_include = if let Some(refs) = config_map_refs {
637        if refs.named_conf_zones.is_some() {
638            // User provided custom zones file, include it from custom ConfigMap location
639            "\n// Include zones file from user-provided ConfigMap\ninclude \"/etc/bind/named.conf.zones\";\n".to_string()
640        } else {
641            // No zones ConfigMap provided, don't include zones file
642            String::new()
643        }
644    } else {
645        // No config refs at all, don't include zones file
646        String::new()
647    };
648
649    // Build RNDC key includes and key names for controls block
650    // For now, we support a single key per instance (bindy-operator)
651    // Future enhancement: support multiple keys from spec
652    let rndc_key_includes = "include \"/etc/bind/keys/rndc.key\";";
653    let rndc_key_names = "\"bindy-operator\"";
654
655    NAMED_CONF_TEMPLATE
656        .replace("{{ZONES_INCLUDE}}", &zones_include)
657        .replace("{{RNDC_KEY_INCLUDES}}", rndc_key_includes)
658        .replace("{{RNDC_KEY_NAMES}}", rndc_key_names)
659}
660
661/// Build the named.conf.options configuration from template
662///
663/// Generates the BIND9 options configuration file from the instance's config spec.
664/// Includes settings for recursion, ACLs (allow-query, allow-transfer), DNSSEC,
665/// forwarders, and listen addresses (listen-on / listen-on-v6).
666///
667/// Priority for configuration values (highest to lowest):
668/// 1. Instance-level settings (`instance.spec.config`)
669/// 2. Role-specific settings (`role_allow_transfer` from cluster primary/secondary spec)
670/// 3. Global cluster settings (`cluster.spec.common.global`)
671/// 4. Defaults (BIND9 defaults or no setting)
672///
673/// # Arguments
674///
675/// * `instance` - `Bind9Instance` spec containing the BIND9 configuration
676/// * `cluster` - Optional `Bind9Cluster` containing global configuration
677/// * `role_allow_transfer` - Role-specific allow-transfer override from cluster spec (primary/secondary)
678///
679/// # Returns
680///
681/// A string containing the complete named.conf.options configuration
682#[allow(clippy::too_many_lines)]
683fn build_options_conf(
684    instance: &Bind9Instance,
685    cluster: Option<&Bind9Cluster>,
686    role_allow_transfer: Option<&Vec<String>>,
687) -> anyhow::Result<String> {
688    let recursion;
689    let mut allow_query = String::new();
690    let allow_transfer;
691    let mut dnssec_validate = String::new();
692
693    // Get global config from cluster if available
694    let global_config = cluster.and_then(|c| c.spec.common.global.as_ref());
695
696    if let Some(config) = &instance.spec.config {
697        // Recursion setting - instance overrides global
698        let recursion_value = if let Some(rec) = config.recursion {
699            if rec {
700                "yes"
701            } else {
702                "no"
703            }
704        } else if let Some(global) = global_config {
705            if global.recursion.unwrap_or(false) {
706                "yes"
707            } else {
708                "no"
709            }
710        } else {
711            "no"
712        };
713        recursion = format!("recursion {recursion_value};");
714
715        // Allow-query ACL - instance overrides global
716        if let Some(acls) = &config.allow_query {
717            if !acls.is_empty() {
718                let acl_list = build_acl_list(acls)
719                    .context("invalid entry in instance spec.config.allow_query")?;
720                allow_query = format!("allow-query {{ {acl_list}; }};");
721            }
722        } else if let Some(global) = global_config {
723            if let Some(global_acls) = &global.allow_query {
724                if !global_acls.is_empty() {
725                    let acl_list = build_acl_list(global_acls)
726                        .context("invalid entry in cluster spec.global.allow_query")?;
727                    allow_query = format!("allow-query {{ {acl_list}; }};");
728                }
729            }
730        }
731
732        // Allow-transfer ACL - priority: instance config > role-specific > global > no default
733        if let Some(acls) = &config.allow_transfer {
734            // Instance-level config takes highest priority
735            let acl_list = if acls.is_empty() {
736                "none".to_string()
737            } else {
738                build_acl_list(acls)
739                    .context("invalid entry in instance spec.config.allow_transfer")?
740            };
741            allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
742        } else if let Some(role_acls) = role_allow_transfer {
743            // Role-specific override from cluster config (primary/secondary)
744            let acl_list = if role_acls.is_empty() {
745                "none".to_string()
746            } else {
747                build_acl_list(role_acls)
748                    .context("invalid entry in cluster role-specific allow_transfer")?
749            };
750            allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
751        } else if let Some(global) = global_config {
752            // Global cluster settings
753            if let Some(global_acls) = &global.allow_transfer {
754                let acl_list = if global_acls.is_empty() {
755                    "none".to_string()
756                } else {
757                    build_acl_list(global_acls)
758                        .context("invalid entry in cluster spec.global.allow_transfer")?
759                };
760                allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
761            } else {
762                allow_transfer = String::new();
763            }
764        } else {
765            // No default - let BIND9 use its own defaults (none)
766            allow_transfer = String::new();
767        }
768
769        // DNSSEC configuration - instance overrides global
770        // Note: dnssec-enable was removed in BIND 9.15+ (DNSSEC is always enabled)
771        // Only dnssec-validation is configurable now
772        if let Some(dnssec) = &config.dnssec {
773            if dnssec.validation.unwrap_or(false) {
774                dnssec_validate = "dnssec-validation yes;".to_string();
775            } else {
776                dnssec_validate = "dnssec-validation no;".to_string();
777            }
778        } else if let Some(global) = global_config {
779            if let Some(global_dnssec) = &global.dnssec {
780                if global_dnssec.validation.unwrap_or(false) {
781                    dnssec_validate = "dnssec-validation yes;".to_string();
782                } else {
783                    dnssec_validate = "dnssec-validation no;".to_string();
784                }
785            }
786        }
787    } else {
788        // No instance config - use global config if available, otherwise defaults
789        if let Some(global) = global_config {
790            // Recursion from global
791            let recursion_value = if global.recursion.unwrap_or(false) {
792                "yes"
793            } else {
794                "no"
795            };
796            recursion = format!("recursion {recursion_value};");
797
798            // Allow-query from global
799            if let Some(acls) = &global.allow_query {
800                if !acls.is_empty() {
801                    let acl_list = build_acl_list(acls)
802                        .context("invalid entry in cluster spec.global.allow_query")?;
803                    allow_query = format!("allow-query {{ {acl_list}; }};");
804                }
805            }
806
807            // Allow-transfer - priority: role-specific > global > no default
808            if let Some(role_acls) = role_allow_transfer {
809                let acl_list = if role_acls.is_empty() {
810                    "none".to_string()
811                } else {
812                    build_acl_list(role_acls)
813                        .context("invalid entry in cluster role-specific allow_transfer")?
814                };
815                allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
816            } else if let Some(global_acls) = &global.allow_transfer {
817                let acl_list = if global_acls.is_empty() {
818                    "none".to_string()
819                } else {
820                    build_acl_list(global_acls)
821                        .context("invalid entry in cluster spec.global.allow_transfer")?
822                };
823                allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
824            } else {
825                allow_transfer = String::new();
826            }
827
828            // DNSSEC from global
829            if let Some(dnssec) = &global.dnssec {
830                if dnssec.validation.unwrap_or(false) {
831                    dnssec_validate = "dnssec-validation yes;".to_string();
832                }
833            }
834        } else {
835            // Defaults when no config is specified
836            recursion = "recursion no;".to_string();
837            // No default for allow-transfer - let BIND9 use its own defaults (none)
838            allow_transfer = String::new();
839        }
840    }
841
842    // Generate DNSSEC policies (instance config overrides global)
843    let dnssec_policies = generate_dnssec_policies(global_config, instance.spec.config.as_ref());
844
845    // Forwarders and listen addresses - instance overrides global, per field
846    let instance_cfg = instance.spec.config.as_ref();
847    let forwarders = render_forwarders(
848        instance_cfg
849            .and_then(|c| c.forwarders.as_ref())
850            .or_else(|| global_config.and_then(|g| g.forwarders.as_ref())),
851    )?;
852    let listen_on = render_listen_on(
853        LISTEN_ON_DIRECTIVE,
854        instance_cfg
855            .and_then(|c| c.listen_on.as_ref())
856            .or_else(|| global_config.and_then(|g| g.listen_on.as_ref())),
857    )?;
858    let listen_on_v6 = render_listen_on(
859        LISTEN_ON_V6_DIRECTIVE,
860        instance_cfg
861            .and_then(|c| c.listen_on_v6.as_ref())
862            .or_else(|| global_config.and_then(|g| g.listen_on_v6.as_ref())),
863    )?;
864
865    // Perform template substitutions
866    Ok(NAMED_CONF_OPTIONS_TEMPLATE
867        .replace("{{LISTEN_ON}}", &listen_on)
868        .replace("{{LISTEN_ON_V6}}", &listen_on_v6)
869        .replace("{{RECURSION}}", &recursion)
870        .replace("{{FORWARDERS}}", &forwarders)
871        .replace("{{ALLOW_QUERY}}", &allow_query)
872        .replace("{{ALLOW_TRANSFER}}", &allow_transfer)
873        .replace("{{DNSSEC_VALIDATE}}", &dnssec_validate)
874        .replace("{{DNSSEC_POLICIES}}", &dnssec_policies))
875}
876
877/// Render the `forwarders { …; };` block for named.conf.options.
878///
879/// Emits only the `forwarders` block (no `forward` mode statement), matching
880/// BIND defaults. Returns an empty string when `forwarders` is `None` or
881/// empty so no directive is rendered.
882///
883/// # Arguments
884///
885/// * `forwarders` - Optional list of upstream DNS server IP addresses
886///
887/// # Errors
888///
889/// Returns an error if any entry is not a plain IPv4 or IPv6 address —
890/// CRD-supplied values flow directly into named.conf, so anything else is
891/// rejected to prevent configuration injection.
892fn render_forwarders(forwarders: Option<&Vec<String>>) -> anyhow::Result<String> {
893    let Some(list) = forwarders else {
894        return Ok(String::new());
895    };
896    if list.is_empty() {
897        return Ok(String::new());
898    }
899
900    for entry in list {
901        let trimmed = entry.trim();
902        if trimmed.parse::<std::net::IpAddr>().is_err() {
903            anyhow::bail!("invalid forwarder {trimmed:?}: must be a plain IPv4 or IPv6 address");
904        }
905    }
906
907    let joined = list
908        .iter()
909        .map(|entry| entry.trim().to_string())
910        .collect::<Vec<_>>()
911        .join("; ");
912    Ok(format!("forwarders {{ {joined}; }};"))
913}
914
915/// Render a `listen-on` / `listen-on-v6` directive for named.conf.options.
916///
917/// Defaults to `{directive} port 5353 {{ any; }};` when `addresses` is `None`
918/// or empty. The port is [`DNS_CONTAINER_PORT`] — the port `named` actually binds
919/// inside the pod — not the client-facing service port [`DNS_PORT`].
920///
921/// # Arguments
922///
923/// * `directive` - Either [`LISTEN_ON_DIRECTIVE`] or [`LISTEN_ON_V6_DIRECTIVE`]
924/// * `addresses` - Optional address match list from the CRD
925///
926/// # Errors
927///
928/// Returns an error if any entry fails address-match-list validation — see
929/// [`crate::bind9_acl`] for the accepted syntax.
930fn render_listen_on(directive: &str, addresses: Option<&Vec<String>>) -> anyhow::Result<String> {
931    let list = match addresses {
932        Some(addrs) if !addrs.is_empty() => build_acl_list(addrs)
933            .with_context(|| format!("invalid entry in {directive} address list"))?,
934        _ => LISTEN_ON_DEFAULT.to_string(),
935    };
936    Ok(format!(
937        "{directive} port {DNS_CONTAINER_PORT} {{ {list}; }};"
938    ))
939}
940
941/// Build the main named.conf configuration for a cluster from template
942///
943/// Generates the main BIND9 configuration file with conditional zones include.
944/// The zones include directive is only added if the user provides a `namedConfZones` `ConfigMap`.
945///
946/// # Arguments
947///
948/// * `cluster` - `Bind9Cluster` spec (checked for config refs)
949///
950/// # Returns
951///
952/// A string containing the complete named.conf configuration
953fn build_cluster_named_conf(cluster: &Bind9Cluster) -> String {
954    // Check if user provided a custom zones ConfigMap
955    let zones_include = if let Some(refs) = &cluster.spec.common.config_map_refs {
956        if refs.named_conf_zones.is_some() {
957            // User provided custom zones file, include it from custom ConfigMap location
958            "\n// Include zones file from user-provided ConfigMap\ninclude \"/etc/bind/named.conf.zones\";\n".to_string()
959        } else {
960            // No zones ConfigMap provided, don't include zones file
961            String::new()
962        }
963    } else {
964        // No config refs at all, don't include zones file
965        String::new()
966    };
967
968    // Build RNDC key includes and key names for controls block
969    // For now, we support a single key per instance (bindy-operator)
970    // Future enhancement: support multiple keys from spec
971    let rndc_key_includes = "include \"/etc/bind/keys/rndc.key\";";
972    let rndc_key_names = "\"bindy-operator\"";
973
974    NAMED_CONF_TEMPLATE
975        .replace("{{ZONES_INCLUDE}}", &zones_include)
976        .replace("{{RNDC_KEY_INCLUDES}}", rndc_key_includes)
977        .replace("{{RNDC_KEY_NAMES}}", rndc_key_names)
978}
979
980/// Build the named.conf.options configuration for a cluster from template
981///
982/// Generates the BIND9 options configuration file from the cluster's `spec.global` config.
983/// Includes settings for recursion, ACLs (allow-query, allow-transfer), DNSSEC,
984/// forwarders, and listen addresses (listen-on / listen-on-v6).
985///
986/// # Arguments
987///
988/// * `cluster` - `Bind9Cluster` containing global configuration
989///
990/// # Returns
991///
992/// A string containing the complete named.conf.options configuration
993#[allow(clippy::too_many_lines)]
994fn build_cluster_options_conf(cluster: &Bind9Cluster) -> anyhow::Result<String> {
995    let recursion;
996    let mut allow_query = String::new();
997    let mut allow_transfer = String::new();
998    let mut dnssec_validate = String::new();
999
1000    // Use cluster global config
1001    if let Some(global) = &cluster.spec.common.global {
1002        // Recursion setting
1003        let recursion_value = if global.recursion.unwrap_or(false) {
1004            "yes"
1005        } else {
1006            "no"
1007        };
1008        recursion = format!("recursion {recursion_value};");
1009
1010        // allow-query ACL
1011        if let Some(aq) = &global.allow_query {
1012            if !aq.is_empty() {
1013                let acl_list = build_acl_list(aq)
1014                    .context("invalid entry in cluster spec.global.allow_query")?;
1015                allow_query = format!("allow-query {{ {acl_list}; }};");
1016            }
1017        }
1018
1019        // allow-transfer ACL
1020        if let Some(at) = &global.allow_transfer {
1021            if !at.is_empty() {
1022                let acl_list = build_acl_list(at)
1023                    .context("invalid entry in cluster spec.global.allow_transfer")?;
1024                allow_transfer = format!("allow-transfer {{ {acl_list}; }};");
1025            }
1026        }
1027
1028        // DNSSEC validation
1029        if let Some(dnssec) = &global.dnssec {
1030            if dnssec.validation.unwrap_or(false) {
1031                dnssec_validate = "dnssec-validation yes;".to_string();
1032            } else {
1033                dnssec_validate = "dnssec-validation no;".to_string();
1034            }
1035        }
1036    } else {
1037        // No global config, use defaults
1038        recursion = "recursion no;".to_string();
1039    }
1040
1041    // Generate DNSSEC policies from global config
1042    let dnssec_policies = generate_dnssec_policies(cluster.spec.common.global.as_ref(), None);
1043
1044    // Forwarders and listen addresses from global config
1045    let global = cluster.spec.common.global.as_ref();
1046    let forwarders = render_forwarders(global.and_then(|g| g.forwarders.as_ref()))?;
1047    let listen_on = render_listen_on(
1048        LISTEN_ON_DIRECTIVE,
1049        global.and_then(|g| g.listen_on.as_ref()),
1050    )?;
1051    let listen_on_v6 = render_listen_on(
1052        LISTEN_ON_V6_DIRECTIVE,
1053        global.and_then(|g| g.listen_on_v6.as_ref()),
1054    )?;
1055
1056    Ok(NAMED_CONF_OPTIONS_TEMPLATE
1057        .replace("{{LISTEN_ON}}", &listen_on)
1058        .replace("{{LISTEN_ON_V6}}", &listen_on_v6)
1059        .replace("{{RECURSION}}", &recursion)
1060        .replace("{{FORWARDERS}}", &forwarders)
1061        .replace("{{ALLOW_QUERY}}", &allow_query)
1062        .replace("{{ALLOW_TRANSFER}}", &allow_transfer)
1063        .replace("{{DNSSEC_VALIDATE}}", &dnssec_validate)
1064        .replace("{{DNSSEC_POLICIES}}", &dnssec_policies))
1065}
1066
1067/// Builds a Kubernetes Deployment for running BIND9 pods.
1068///
1069/// Creates a Deployment with:
1070/// - BIND9 container using configured or default image
1071/// - `ConfigMap` volume mounts for configuration
1072/// - `EmptyDir` volumes for zones and cache
1073/// - TCP/UDP port 53 exposed
1074/// - Liveness and readiness probes
1075///
1076/// # Arguments
1077///
1078/// * `name` - Name for the Deployment
1079/// * `namespace` - Kubernetes namespace
1080/// * `instance` - `Bind9Instance` spec containing replicas, version, etc.
1081/// * `cluster` - Optional `Bind9Cluster` containing shared configuration
1082/// * `cluster_provider` - Optional `ClusterBind9Provider` containing shared configuration
1083/// * `rndc_secret_name` - Resolved RNDC `Secret` name (from `rndcKey.secretRef`,
1084///   an inline secret spec, or the auto-generated `{name}-rndc-key` default)
1085///
1086/// # Returns
1087///
1088/// A Kubernetes Deployment resource ready for creation/update
1089#[must_use]
1090/// Helper struct to hold resolved configuration for a `Bind9Instance` deployment
1091struct DeploymentConfig<'a> {
1092    image_config: Option<&'a ImageConfig>,
1093    config_map_refs: Option<&'a ConfigMapRefs>,
1094    version: &'a str,
1095    volumes: Option<&'a Vec<Volume>>,
1096    volume_mounts: Option<&'a Vec<VolumeMount>>,
1097    bindcar_config: Option<&'a crate::crd::BindcarConfig>,
1098    configmap_name: String,
1099}
1100
1101/// Extract and resolve deployment configuration from instance and cluster
1102fn resolve_deployment_config<'a>(
1103    name: &str,
1104    instance: &'a Bind9Instance,
1105    cluster: Option<&'a Bind9Cluster>,
1106    cluster_provider: Option<&'a crate::crd::ClusterBind9Provider>,
1107) -> DeploymentConfig<'a> {
1108    // Get image config (instance overrides cluster overrides cluster provider)
1109    let image_config = instance
1110        .spec
1111        .image
1112        .as_ref()
1113        .or_else(|| cluster.and_then(|c| c.spec.common.image.as_ref()))
1114        .or_else(|| cluster_provider.and_then(|cp| cp.spec.common.image.as_ref()));
1115
1116    // Get ConfigMap references (instance overrides cluster overrides cluster provider)
1117    let config_map_refs = instance
1118        .spec
1119        .config_map_refs
1120        .as_ref()
1121        .or_else(|| cluster.and_then(|c| c.spec.common.config_map_refs.as_ref()))
1122        .or_else(|| cluster_provider.and_then(|cp| cp.spec.common.config_map_refs.as_ref()));
1123
1124    // Get version (instance overrides cluster overrides cluster provider)
1125    let version = instance
1126        .spec
1127        .version
1128        .as_deref()
1129        .or_else(|| cluster.and_then(|c| c.spec.common.version.as_deref()))
1130        .or_else(|| cluster_provider.and_then(|cp| cp.spec.common.version.as_deref()))
1131        .unwrap_or(DEFAULT_BIND9_VERSION);
1132
1133    // Get volumes (instance overrides cluster overrides cluster provider)
1134    let volumes = instance
1135        .spec
1136        .volumes
1137        .as_ref()
1138        .or_else(|| cluster.and_then(|c| c.spec.common.volumes.as_ref()))
1139        .or_else(|| cluster_provider.and_then(|cp| cp.spec.common.volumes.as_ref()));
1140
1141    // Get volume mounts (instance overrides cluster overrides cluster provider)
1142    let volume_mounts = instance
1143        .spec
1144        .volume_mounts
1145        .as_ref()
1146        .or_else(|| cluster.and_then(|c| c.spec.common.volume_mounts.as_ref()))
1147        .or_else(|| cluster_provider.and_then(|cp| cp.spec.common.volume_mounts.as_ref()));
1148
1149    // Get bindcar_config (instance overrides cluster global overrides cluster provider global)
1150    let bindcar_config = instance
1151        .spec
1152        .bindcar_config
1153        .as_ref()
1154        .or_else(|| {
1155            cluster.and_then(|c| {
1156                c.spec
1157                    .common
1158                    .global
1159                    .as_ref()
1160                    .and_then(|g| g.bindcar_config.as_ref())
1161            })
1162        })
1163        .or_else(|| {
1164            cluster_provider.and_then(|cp| {
1165                cp.spec
1166                    .common
1167                    .global
1168                    .as_ref()
1169                    .and_then(|g| g.bindcar_config.as_ref())
1170            })
1171        });
1172
1173    // Determine ConfigMap name: use cluster ConfigMap if instance belongs to a cluster
1174    let configmap_name = if instance.spec.cluster_ref.is_empty() {
1175        // Use instance-specific ConfigMap
1176        format!("{name}-config")
1177    } else {
1178        // Use cluster-level shared ConfigMap
1179        format!("{}-config", instance.spec.cluster_ref)
1180    };
1181
1182    DeploymentConfig {
1183        image_config,
1184        config_map_refs,
1185        version,
1186        volumes,
1187        volume_mounts,
1188        bindcar_config,
1189        configmap_name,
1190    }
1191}
1192
1193pub fn build_deployment(
1194    name: &str,
1195    namespace: &str,
1196    instance: &Bind9Instance,
1197    cluster: Option<&Bind9Cluster>,
1198    cluster_provider: Option<&crate::crd::ClusterBind9Provider>,
1199    rndc_secret_name: &str,
1200) -> Deployment {
1201    debug!(
1202        name = %name,
1203        namespace = %namespace,
1204        has_cluster = cluster.is_some(),
1205        has_cluster_provider = cluster_provider.is_some(),
1206        "Building Deployment for Bind9Instance"
1207    );
1208
1209    // Build labels, checking if instance is managed by a cluster
1210    let labels = build_labels_from_instance(name, instance);
1211    let replicas = instance.spec.replicas.unwrap_or(1);
1212    debug!(replicas, "Deployment replica count");
1213
1214    let config = resolve_deployment_config(name, instance, cluster, cluster_provider);
1215
1216    let owner_refs = build_owner_references(instance);
1217
1218    // Get global and instance configs for DNSSEC
1219    let global_config = cluster.and_then(|c| c.spec.common.global.as_ref());
1220    let instance_config = instance.spec.config.as_ref();
1221
1222    // Build DNSSEC key volumes if signing is enabled
1223    let (dnssec_volumes, dnssec_volume_mounts) =
1224        build_dnssec_key_volumes(global_config, instance_config);
1225
1226    // Merge DNSSEC volumes with custom volumes from spec
1227    let all_volumes = if dnssec_volumes.is_empty() {
1228        config.volumes.map(std::borrow::ToOwned::to_owned)
1229    } else {
1230        let mut merged = dnssec_volumes;
1231        if let Some(custom) = config.volumes {
1232            merged.extend(custom.iter().cloned());
1233        }
1234        Some(merged)
1235    };
1236
1237    // Merge DNSSEC volume mounts with custom volume mounts from spec
1238    let all_volume_mounts = if dnssec_volume_mounts.is_empty() {
1239        config.volume_mounts.map(std::borrow::ToOwned::to_owned)
1240    } else {
1241        let mut merged = dnssec_volume_mounts;
1242        if let Some(custom) = config.volume_mounts {
1243            merged.extend(custom.iter().cloned());
1244        }
1245        Some(merged)
1246    };
1247
1248    Deployment {
1249        metadata: ObjectMeta {
1250            name: Some(name.into()),
1251            namespace: Some(namespace.into()),
1252            labels: Some(labels.clone()),
1253            owner_references: Some(owner_refs),
1254            ..Default::default()
1255        },
1256        spec: Some(DeploymentSpec {
1257            replicas: Some(replicas),
1258            selector: LabelSelector {
1259                match_labels: Some(labels.clone()),
1260                ..Default::default()
1261            },
1262            template: PodTemplateSpec {
1263                metadata: Some(ObjectMeta {
1264                    labels: Some(labels.clone()),
1265                    ..Default::default()
1266                }),
1267                spec: Some(build_pod_spec(
1268                    &config.configmap_name,
1269                    rndc_secret_name,
1270                    config.version,
1271                    config.image_config,
1272                    config.config_map_refs,
1273                    all_volumes.as_ref(),
1274                    all_volume_mounts.as_ref(),
1275                    config.bindcar_config,
1276                )),
1277            },
1278            ..Default::default()
1279        }),
1280        ..Default::default()
1281    }
1282}
1283
1284/// Builds pod specification with BIND9 container and API sidecar
1285///
1286/// # Arguments
1287/// * `configmap_name` - Name of the `ConfigMap` with BIND9 configuration
1288/// * `rndc_secret_name` - Name of the Secret with RNDC keys
1289/// * `version` - BIND9 version tag
1290/// * `image_config` - Optional custom image configuration
1291/// * `config_map_refs` - Optional custom `ConfigMap` references
1292/// * `custom_volumes` - Optional custom volumes to add
1293/// * `custom_volume_mounts` - Optional custom volume mounts to add
1294/// * `bindcar_config` - Optional API sidecar configuration
1295#[allow(clippy::too_many_arguments)]
1296#[allow(clippy::too_many_lines)]
1297fn build_pod_spec(
1298    configmap_name: &str,
1299    rndc_secret_name: &str,
1300    version: &str,
1301    image_config: Option<&ImageConfig>,
1302    config_map_refs: Option<&ConfigMapRefs>,
1303    custom_volumes: Option<&Vec<Volume>>,
1304    custom_volume_mounts: Option<&Vec<VolumeMount>>,
1305    bindcar_config: Option<&crate::crd::BindcarConfig>,
1306) -> PodSpec {
1307    // Determine image to use
1308    let image = if let Some(img_cfg) = image_config {
1309        img_cfg
1310            .image
1311            .clone()
1312            .unwrap_or_else(|| format!("internetsystemsconsortium/bind9:{version}"))
1313    } else {
1314        format!("internetsystemsconsortium/bind9:{version}")
1315    };
1316
1317    // Determine image pull policy
1318    let image_pull_policy = image_config
1319        .and_then(|cfg| cfg.image_pull_policy.clone())
1320        .unwrap_or_else(|| "IfNotPresent".into());
1321
1322    // BIND9 container
1323    let bind9_container = Container {
1324        name: CONTAINER_NAME_BIND9.into(),
1325        image: Some(image),
1326        image_pull_policy: Some(image_pull_policy),
1327        command: Some(vec!["named".into()]),
1328        args: Some(vec![
1329            "-c".into(),
1330            BIND_NAMED_CONF_PATH.into(),
1331            "-g".into(), // Run in foreground (required for containers)
1332        ]),
1333        ports: Some(vec![
1334            ContainerPort {
1335                name: Some("dns-tcp".into()),
1336                container_port: i32::from(DNS_CONTAINER_PORT),
1337                protocol: Some("TCP".into()),
1338                ..Default::default()
1339            },
1340            ContainerPort {
1341                name: Some("dns-udp".into()),
1342                container_port: i32::from(DNS_CONTAINER_PORT),
1343                protocol: Some("UDP".into()),
1344                ..Default::default()
1345            },
1346            ContainerPort {
1347                name: Some("rndc".into()),
1348                container_port: i32::from(RNDC_PORT),
1349                protocol: Some("TCP".into()),
1350                ..Default::default()
1351            },
1352        ]),
1353        env: Some(vec![
1354            EnvVar {
1355                name: "TZ".into(),
1356                value: Some("UTC".into()),
1357                ..Default::default()
1358            },
1359            EnvVar {
1360                name: "MALLOC_CONF".into(),
1361                value: Some(BIND9_MALLOC_CONF.into()),
1362                ..Default::default()
1363            },
1364        ]),
1365        volume_mounts: Some(build_volume_mounts(config_map_refs, custom_volume_mounts)),
1366        liveness_probe: Some(Probe {
1367            tcp_socket: Some(TCPSocketAction {
1368                port: IntOrString::Int(i32::from(DNS_CONTAINER_PORT)),
1369                ..Default::default()
1370            }),
1371            initial_delay_seconds: Some(LIVENESS_INITIAL_DELAY_SECS),
1372            period_seconds: Some(LIVENESS_PERIOD_SECS),
1373            timeout_seconds: Some(LIVENESS_TIMEOUT_SECS),
1374            failure_threshold: Some(LIVENESS_FAILURE_THRESHOLD),
1375            ..Default::default()
1376        }),
1377        readiness_probe: Some(Probe {
1378            tcp_socket: Some(TCPSocketAction {
1379                port: IntOrString::Int(i32::from(DNS_CONTAINER_PORT)),
1380                ..Default::default()
1381            }),
1382            initial_delay_seconds: Some(READINESS_INITIAL_DELAY_SECS),
1383            period_seconds: Some(READINESS_PERIOD_SECS),
1384            timeout_seconds: Some(READINESS_TIMEOUT_SECS),
1385            failure_threshold: Some(READINESS_FAILURE_THRESHOLD),
1386            ..Default::default()
1387        }),
1388        security_context: Some(SecurityContext {
1389            run_as_non_root: Some(true),
1390            run_as_user: Some(BIND9_NONROOT_UID),
1391            run_as_group: Some(BIND9_NONROOT_UID),
1392            allow_privilege_escalation: Some(false),
1393            capabilities: Some(Capabilities {
1394                // Drop ALL capabilities and add none back. `named` binds the
1395                // unprivileged DNS port 5353 (DNS_CONTAINER_PORT), so it no
1396                // longer needs NET_BIND_SERVICE. This is the strictest posture
1397                // under Pod Security Admission `restricted`.
1398                drop: Some(vec!["ALL".to_string()]),
1399                add: None,
1400            }),
1401            // PSA `restricted` requires a RuntimeDefault (or Localhost) seccomp
1402            // profile on every container. Set it explicitly at the container
1403            // level in addition to the pod-level default.
1404            seccomp_profile: Some(SeccompProfile {
1405                type_: "RuntimeDefault".to_string(),
1406                ..Default::default()
1407            }),
1408            ..Default::default()
1409        }),
1410        ..Default::default()
1411    };
1412
1413    // Build image pull secrets if specified
1414    let image_pull_secrets = image_config.and_then(|cfg| {
1415        cfg.image_pull_secrets.as_ref().map(|secrets| {
1416            secrets
1417                .iter()
1418                .map(|s| k8s_openapi::api::core::v1::LocalObjectReference { name: s.clone() })
1419                .collect()
1420        })
1421    });
1422
1423    PodSpec {
1424        containers: {
1425            let mut containers = vec![bind9_container];
1426            containers.push(build_api_sidecar_container(
1427                bindcar_config,
1428                rndc_secret_name,
1429            ));
1430            containers
1431        },
1432        volumes: Some(build_volumes(
1433            configmap_name,
1434            rndc_secret_name,
1435            config_map_refs,
1436            custom_volumes,
1437        )),
1438        image_pull_secrets,
1439        service_account_name: Some(BIND9_SERVICE_ACCOUNT.into()),
1440        security_context: Some(PodSecurityContext {
1441            run_as_user: Some(BIND9_NONROOT_UID),
1442            run_as_group: Some(BIND9_NONROOT_UID),
1443            fs_group: Some(BIND9_NONROOT_UID),
1444            run_as_non_root: Some(true),
1445            // Pod-level RuntimeDefault seccomp profile so the pod satisfies Pod
1446            // Security Admission `restricted` (inherited by any container that
1447            // does not set its own).
1448            seccomp_profile: Some(SeccompProfile {
1449                type_: "RuntimeDefault".to_string(),
1450                ..Default::default()
1451            }),
1452            ..Default::default()
1453        }),
1454        ..Default::default()
1455    }
1456}
1457
1458/// Build the Bindcar API sidecar container
1459///
1460/// # Arguments
1461///
1462/// * `bindcar_config` - Optional Bindcar container configuration from the instance spec
1463/// * `rndc_secret_name` - Name of the Secret containing the RNDC key
1464///
1465/// # Returns
1466///
1467/// A `Container` configured to run the Bindcar RNDC API sidecar
1468#[allow(clippy::too_many_lines)]
1469fn build_api_sidecar_container(
1470    bindcar_config: Option<&crate::crd::BindcarConfig>,
1471    rndc_secret_name: &str,
1472) -> Container {
1473    // Use defaults if bindcar_config is not provided
1474    let image = bindcar_config
1475        .and_then(|c| c.image.clone())
1476        .unwrap_or_else(|| crate::constants::DEFAULT_BINDCAR_IMAGE.to_string());
1477
1478    let image_pull_policy = bindcar_config
1479        .and_then(|c| c.image_pull_policy.clone())
1480        .unwrap_or_else(|| "IfNotPresent".to_string());
1481
1482    let port = bindcar_config
1483        .and_then(|c| c.port)
1484        .unwrap_or(i32::from(crate::constants::BINDCAR_API_PORT));
1485
1486    let log_level = bindcar_config
1487        .and_then(|c| c.log_level.clone())
1488        .unwrap_or_else(|| "info".to_string());
1489
1490    let resources = bindcar_config.and_then(|c| c.resources.clone());
1491
1492    // bindcar 0.7.0 (Mode B / TokenReview) validates the *caller's* SA token
1493    // against BIND_ALLOWED_SERVICE_ACCOUNTS. The caller is the bindy operator,
1494    // so the allow-list must name the operator SA in the operator's own
1495    // namespace — NOT the operand `bind9` SA. The operator namespace is taken
1496    // from POD_NAMESPACE (set on the operator Deployment) with a sane fallback.
1497    let operator_namespace = std::env::var("POD_NAMESPACE")
1498        .unwrap_or_else(|_| crate::constants::DEFAULT_OPERATOR_NAMESPACE.to_string());
1499    let allowed_service_account = format!(
1500        "system:serviceaccount:{operator_namespace}:{}",
1501        crate::constants::OPERATOR_SERVICE_ACCOUNT
1502    );
1503
1504    // Build required environment variables
1505    let mut env_vars = vec![
1506        EnvVar {
1507            name: "BIND_ZONE_DIR".into(),
1508            value: Some(BIND_CACHE_PATH.into()),
1509            ..Default::default()
1510        },
1511        EnvVar {
1512            name: "API_PORT".into(),
1513            value: Some(port.to_string()),
1514            ..Default::default()
1515        },
1516        EnvVar {
1517            name: "RUST_LOG".into(),
1518            value: Some(log_level),
1519            ..Default::default()
1520        },
1521        EnvVar {
1522            name: "BIND_ALLOWED_SERVICE_ACCOUNTS".into(),
1523            value: Some(allowed_service_account),
1524            ..Default::default()
1525        },
1526        // bindcar 0.7.0 enforces the token audience from the TokenReview
1527        // response. The operator projects a token with the `bindcar` audience
1528        // (deploy/operator/deployment.yaml); this must match here.
1529        EnvVar {
1530            name: "BIND_TOKEN_AUDIENCES".into(),
1531            value: Some(crate::constants::BINDCAR_TOKEN_AUDIENCE.into()),
1532            ..Default::default()
1533        },
1534        // Writable scratch dir for bindcar's 0600 TSIG key file (nsupdate -k),
1535        // required because the sidecar runs with a read-only root filesystem.
1536        EnvVar {
1537            name: "TMPDIR".into(),
1538            value: Some(crate::constants::BINDCAR_TMP_PATH.into()),
1539            ..Default::default()
1540        },
1541        EnvVar {
1542            name: "RNDC_SECRET".into(),
1543            value_from: Some(EnvVarSource {
1544                secret_key_ref: Some(SecretKeySelector {
1545                    name: rndc_secret_name.to_string(),
1546                    key: "secret".to_string(),
1547                    optional: Some(false),
1548                }),
1549                ..Default::default()
1550            }),
1551            ..Default::default()
1552        },
1553        EnvVar {
1554            name: "RNDC_ALGORITHM".into(),
1555            value_from: Some(EnvVarSource {
1556                secret_key_ref: Some(SecretKeySelector {
1557                    name: rndc_secret_name.to_string(),
1558                    key: "algorithm".to_string(),
1559                    optional: Some(false),
1560                }),
1561                ..Default::default()
1562            }),
1563            ..Default::default()
1564        },
1565        // The co-located `named` listens on the unprivileged DNS_CONTAINER_PORT
1566        // (5353), not 53. Point bindcar's dynamic-update (nsupdate) traffic at
1567        // that port; without this it would default to 53 and every update would
1568        // fail with connection refused.
1569        EnvVar {
1570            name: "NSUPDATE_PORT".into(),
1571            value: Some(DNS_CONTAINER_PORT.to_string()),
1572            ..Default::default()
1573        },
1574    ];
1575
1576    // Add user-provided environment variables if any
1577    if let Some(config) = bindcar_config {
1578        if let Some(user_env_vars) = &config.env_vars {
1579            env_vars.extend(user_env_vars.clone());
1580        }
1581    }
1582
1583    Container {
1584        name: CONTAINER_NAME_BINDCAR.into(),
1585        image: Some(image),
1586        image_pull_policy: Some(image_pull_policy),
1587        ports: Some(vec![ContainerPort {
1588            name: Some("http".into()),
1589            container_port: port,
1590            protocol: Some("TCP".into()),
1591            ..Default::default()
1592        }]),
1593        env: Some(env_vars),
1594        volume_mounts: Some(vec![
1595            VolumeMount {
1596                name: VOLUME_CACHE.into(),
1597                mount_path: BIND_CACHE_PATH.into(),
1598                ..Default::default()
1599            },
1600            VolumeMount {
1601                name: VOLUME_RNDC_KEY.into(),
1602                mount_path: BIND_KEYS_PATH.into(),
1603                read_only: Some(true),
1604                ..Default::default()
1605            },
1606            VolumeMount {
1607                name: VOLUME_CONFIG.into(),
1608                mount_path: BIND_RNDC_CONF_PATH.into(),
1609                sub_path: Some(RNDC_CONF_FILENAME.into()),
1610                ..Default::default()
1611            },
1612            // Writable /tmp (TMPDIR) for the bindcar TSIG key file, required
1613            // because readOnlyRootFilesystem is enabled below.
1614            VolumeMount {
1615                name: VOLUME_TMP.into(),
1616                mount_path: crate::constants::BINDCAR_TMP_PATH.into(),
1617                ..Default::default()
1618            },
1619        ]),
1620        resources,
1621        security_context: Some(SecurityContext {
1622            run_as_non_root: Some(true),
1623            run_as_user: Some(BIND9_NONROOT_UID),
1624            run_as_group: Some(BIND9_NONROOT_UID),
1625            allow_privilege_escalation: Some(false),
1626            // The sidecar never binds a privileged port, so it keeps ALL
1627            // capabilities dropped and a read-only root filesystem — the
1628            // strictest posture under Pod Security Admission `restricted`.
1629            read_only_root_filesystem: Some(true),
1630            capabilities: Some(Capabilities {
1631                drop: Some(vec!["ALL".to_string()]),
1632                ..Default::default()
1633            }),
1634            seccomp_profile: Some(SeccompProfile {
1635                type_: "RuntimeDefault".to_string(),
1636                ..Default::default()
1637            }),
1638            ..Default::default()
1639        }),
1640        ..Default::default()
1641    }
1642}
1643
1644/// Build volume mounts for the BIND9 container
1645///
1646/// Creates volume mounts for:
1647/// - `zones` - `EmptyDir` for zone files
1648/// - `cache` - `EmptyDir` for BIND9 cache
1649/// - `named.conf` - From `ConfigMap` (custom or generated)
1650/// - `named.conf.options` - From `ConfigMap` (custom or generated)
1651/// - `named.conf.zones` - From custom `ConfigMap` (only if `namedConfZones` is specified)
1652///
1653/// # Arguments
1654///
1655/// * `config_map_refs` - Optional references to custom `ConfigMaps`
1656/// * `custom_volume_mounts` - Optional additional volume mounts from instance/cluster spec
1657///
1658/// # Returns
1659///
1660/// A vector of `VolumeMount` objects for the BIND9 container
1661fn build_volume_mounts(
1662    config_map_refs: Option<&ConfigMapRefs>,
1663    custom_volume_mounts: Option<&Vec<VolumeMount>>,
1664) -> Vec<VolumeMount> {
1665    let mut mounts = vec![
1666        VolumeMount {
1667            name: VOLUME_ZONES.into(),
1668            mount_path: BIND_ZONES_PATH.into(),
1669            ..Default::default()
1670        },
1671        VolumeMount {
1672            name: VOLUME_CACHE.into(),
1673            mount_path: BIND_CACHE_PATH.into(),
1674            ..Default::default()
1675        },
1676        VolumeMount {
1677            name: VOLUME_RNDC_KEY.into(),
1678            mount_path: BIND_KEYS_PATH.into(),
1679            read_only: Some(true),
1680            ..Default::default()
1681        },
1682    ];
1683
1684    // Add named.conf mount
1685    if let Some(refs) = config_map_refs {
1686        if let Some(_configmap_name) = &refs.named_conf {
1687            mounts.push(VolumeMount {
1688                name: VOLUME_NAMED_CONF.into(),
1689                mount_path: BIND_NAMED_CONF_PATH.into(),
1690                sub_path: Some(NAMED_CONF_FILENAME.into()),
1691                ..Default::default()
1692            });
1693        } else {
1694            // Use default generated ConfigMap
1695            mounts.push(VolumeMount {
1696                name: VOLUME_CONFIG.into(),
1697                mount_path: BIND_NAMED_CONF_PATH.into(),
1698                sub_path: Some(NAMED_CONF_FILENAME.into()),
1699                ..Default::default()
1700            });
1701        }
1702
1703        if let Some(_configmap_name) = &refs.named_conf_options {
1704            mounts.push(VolumeMount {
1705                name: VOLUME_NAMED_CONF_OPTIONS.into(),
1706                mount_path: BIND_NAMED_CONF_OPTIONS_PATH.into(),
1707                sub_path: Some(NAMED_CONF_OPTIONS_FILENAME.into()),
1708                ..Default::default()
1709            });
1710        } else {
1711            // Use default generated ConfigMap
1712            mounts.push(VolumeMount {
1713                name: VOLUME_CONFIG.into(),
1714                mount_path: BIND_NAMED_CONF_OPTIONS_PATH.into(),
1715                sub_path: Some(NAMED_CONF_OPTIONS_FILENAME.into()),
1716                ..Default::default()
1717            });
1718        }
1719
1720        // Add zones file mount only if user provided a ConfigMap
1721        if let Some(_configmap_name) = &refs.named_conf_zones {
1722            mounts.push(VolumeMount {
1723                name: VOLUME_NAMED_CONF_ZONES.into(),
1724                mount_path: BIND_NAMED_CONF_ZONES_PATH.into(),
1725                sub_path: Some(NAMED_CONF_ZONES_FILENAME.into()),
1726                ..Default::default()
1727            });
1728        }
1729        // Note: No else block - if user doesn't provide zones ConfigMap, we don't mount it
1730    } else {
1731        // No custom ConfigMaps, use default
1732        mounts.push(VolumeMount {
1733            name: VOLUME_CONFIG.into(),
1734            mount_path: BIND_NAMED_CONF_PATH.into(),
1735            sub_path: Some(NAMED_CONF_FILENAME.into()),
1736            ..Default::default()
1737        });
1738        mounts.push(VolumeMount {
1739            name: VOLUME_CONFIG.into(),
1740            mount_path: BIND_NAMED_CONF_OPTIONS_PATH.into(),
1741            sub_path: Some(NAMED_CONF_OPTIONS_FILENAME.into()),
1742            ..Default::default()
1743        });
1744        // Note: No zones mount - users must explicitly provide namedConfZones ConfigMap
1745    }
1746
1747    // Always add rndc.conf mount from default ConfigMap (contains rndc.conf)
1748    mounts.push(VolumeMount {
1749        name: VOLUME_CONFIG.into(),
1750        mount_path: BIND_RNDC_CONF_PATH.into(),
1751        sub_path: Some(RNDC_CONF_FILENAME.into()),
1752        ..Default::default()
1753    });
1754
1755    // Append custom volume mounts from cluster/instance
1756    if let Some(custom_mounts) = custom_volume_mounts {
1757        mounts.extend(custom_mounts.iter().cloned());
1758    }
1759
1760    mounts
1761}
1762
1763/// Build volumes for the BIND9 pod
1764///
1765/// Creates volumes for:
1766/// - `zones` (`EmptyDir`) - Zone files storage
1767/// - `cache` (`EmptyDir`) - BIND9 cache
1768/// - `ConfigMap` volumes (custom or default generated - can be instance or cluster `ConfigMap`)
1769///
1770/// If custom `ConfigMaps` are specified via `config_map_refs`, individual volumes are created
1771/// for each custom `ConfigMap`. If `namedConfZones` is not specified, no zones `ConfigMap` volume
1772/// is created.
1773///
1774/// The generated `config` volume is ALWAYS present regardless of custom refs:
1775/// it backs the unconditional `rndc.conf` mounts in both containers and the
1776/// generated `ConfigMap` always exists (it always contains at least `rndc.conf`).
1777///
1778/// # Arguments
1779///
1780/// * `configmap_name` - Name of the `ConfigMap` to mount (instance or cluster `ConfigMap`)
1781/// * `config_map_refs` - Optional references to custom `ConfigMaps`
1782/// * `custom_volumes` - Optional additional volumes from instance/cluster spec
1783///
1784/// # Returns
1785///
1786/// A vector of `Volume` objects for the pod spec
1787fn build_volumes(
1788    configmap_name: &str,
1789    rndc_secret_name: &str,
1790    config_map_refs: Option<&ConfigMapRefs>,
1791    custom_volumes: Option<&Vec<Volume>>,
1792) -> Vec<Volume> {
1793    let mut volumes = vec![
1794        Volume {
1795            name: VOLUME_ZONES.into(),
1796            empty_dir: Some(k8s_openapi::api::core::v1::EmptyDirVolumeSource::default()),
1797            ..Default::default()
1798        },
1799        Volume {
1800            name: VOLUME_CACHE.into(),
1801            empty_dir: Some(k8s_openapi::api::core::v1::EmptyDirVolumeSource::default()),
1802            ..Default::default()
1803        },
1804        Volume {
1805            name: VOLUME_RNDC_KEY.into(),
1806            secret: Some(k8s_openapi::api::core::v1::SecretVolumeSource {
1807                secret_name: Some(rndc_secret_name.to_string()),
1808                ..Default::default()
1809            }),
1810            ..Default::default()
1811        },
1812        // Memory-backed writable scratch dir mounted at /tmp in the bindcar
1813        // sidecar (TMPDIR). Needed because the sidecar runs with a read-only
1814        // root filesystem under Pod Security Admission `restricted`.
1815        Volume {
1816            name: VOLUME_TMP.into(),
1817            empty_dir: Some(EmptyDirVolumeSource {
1818                medium: Some("Memory".to_string()),
1819                ..Default::default()
1820            }),
1821            ..Default::default()
1822        },
1823    ];
1824
1825    // Add ConfigMap volumes
1826    if let Some(refs) = config_map_refs {
1827        if let Some(configmap_name) = &refs.named_conf {
1828            volumes.push(Volume {
1829                name: VOLUME_NAMED_CONF.into(),
1830                config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
1831                    name: configmap_name.clone(),
1832                    ..Default::default()
1833                }),
1834                ..Default::default()
1835            });
1836        }
1837
1838        if let Some(configmap_name) = &refs.named_conf_options {
1839            volumes.push(Volume {
1840                name: VOLUME_NAMED_CONF_OPTIONS.into(),
1841                config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
1842                    name: configmap_name.clone(),
1843                    ..Default::default()
1844                }),
1845                ..Default::default()
1846            });
1847        }
1848
1849        if let Some(configmap_name) = &refs.named_conf_zones {
1850            volumes.push(Volume {
1851                name: VOLUME_NAMED_CONF_ZONES.into(),
1852                config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
1853                    name: configmap_name.clone(),
1854                    ..Default::default()
1855                }),
1856                ..Default::default()
1857            });
1858        }
1859    }
1860
1861    // ALWAYS add the generated config volume. The generated ConfigMap always
1862    // exists (it always carries at least rndc.conf, which is not overridable),
1863    // and rndc.conf is mounted from this volume unconditionally in both the
1864    // bind9 and bindcar containers. Omitting it when custom refs are set would
1865    // leave those mounts dangling and make the API server reject the Deployment.
1866    volumes.push(Volume {
1867        name: VOLUME_CONFIG.into(),
1868        config_map: Some(k8s_openapi::api::core::v1::ConfigMapVolumeSource {
1869            name: configmap_name.to_string(),
1870            ..Default::default()
1871        }),
1872        ..Default::default()
1873    });
1874
1875    // Append custom volumes from cluster/instance
1876    if let Some(custom_vols) = custom_volumes {
1877        volumes.extend(custom_vols.iter().cloned());
1878    }
1879
1880    volumes
1881}
1882
1883/// Builds a Kubernetes Service for exposing BIND9 DNS ports.
1884///
1885/// Creates a Service exposing:
1886/// - TCP port 53 (for zone transfers and large queries)
1887/// - UDP port 53 (for standard DNS queries)
1888/// - HTTP port 80 (mapped to bindcar API port)
1889///
1890/// Custom service configuration includes both spec fields and metadata annotations.
1891/// These are merged with defaults, allowing partial customization while maintaining
1892/// safe defaults for unspecified fields.
1893///
1894/// # Arguments
1895///
1896/// * `name` - Name for the Service
1897/// * `namespace` - Kubernetes namespace
1898/// * `instance` - The `Bind9Instance` that owns this Service
1899/// * `custom_config` - Optional custom `ServiceConfig` with spec and annotations to merge with defaults
1900///
1901/// # Returns
1902///
1903/// A Kubernetes Service resource ready for creation/update
1904///
1905/// # Example
1906///
1907/// ```rust,no_run
1908/// use bindy::bind9_resources::build_service;
1909/// use bindy::crd::{Bind9Instance, ServiceConfig};
1910/// use std::collections::BTreeMap;
1911///
1912/// # fn example(instance: Bind9Instance) {
1913/// let mut annotations = BTreeMap::new();
1914/// annotations.insert("metallb.universe.tf/address-pool".to_string(), "my-pool".to_string());
1915///
1916/// let config = ServiceConfig {
1917///     annotations: Some(annotations),
1918///     spec: None,
1919/// };
1920///
1921/// let service = build_service("dns-primary", "bindy-system", &instance, Some(&config));
1922/// # }
1923/// ```
1924#[must_use]
1925pub fn build_service(
1926    name: &str,
1927    namespace: &str,
1928    instance: &Bind9Instance,
1929    custom_config: Option<&crate::crd::ServiceConfig>,
1930) -> Service {
1931    // Build labels, checking if instance is managed by a cluster
1932    let labels = build_labels_from_instance(name, instance);
1933    let owner_refs = build_owner_references(instance);
1934
1935    // Get API container port from instance spec, default to BINDCAR_API_PORT
1936    let api_container_port = instance
1937        .spec
1938        .bindcar_config
1939        .as_ref()
1940        .and_then(|c| c.port)
1941        .unwrap_or(i32::from(crate::constants::BINDCAR_API_PORT));
1942
1943    // Build default service spec
1944    let mut default_spec = ServiceSpec {
1945        selector: Some(labels.clone()),
1946        ports: Some(vec![
1947            ServicePort {
1948                name: Some("dns-tcp".into()),
1949                port: i32::from(DNS_PORT),
1950                target_port: Some(IntOrString::Int(i32::from(DNS_CONTAINER_PORT))),
1951                protocol: Some("TCP".into()),
1952                ..Default::default()
1953            },
1954            ServicePort {
1955                name: Some("dns-udp".into()),
1956                port: i32::from(DNS_PORT),
1957                target_port: Some(IntOrString::Int(i32::from(DNS_CONTAINER_PORT))),
1958                protocol: Some("UDP".into()),
1959                ..Default::default()
1960            },
1961            ServicePort {
1962                name: Some("http".into()),
1963                port: i32::from(crate::constants::BINDCAR_SERVICE_PORT),
1964                target_port: Some(IntOrString::Int(api_container_port)),
1965                protocol: Some("TCP".into()),
1966                ..Default::default()
1967            },
1968        ]),
1969        type_: Some("ClusterIP".into()),
1970        ..Default::default()
1971    };
1972
1973    // Merge bindcar service spec if provided (applies before custom_config)
1974    if let Some(bindcar_service_spec) = instance
1975        .spec
1976        .bindcar_config
1977        .as_ref()
1978        .and_then(|c| c.service_spec.as_ref())
1979    {
1980        merge_service_spec(&mut default_spec, bindcar_service_spec);
1981    }
1982
1983    // Extract custom spec and annotations from service config
1984    let (custom_spec, custom_annotations) = custom_config.map_or((None, None), |config| {
1985        (config.spec.as_ref(), config.annotations.as_ref())
1986    });
1987
1988    // Merge custom spec if provided (applies after bindcar config)
1989    if let Some(custom) = custom_spec {
1990        merge_service_spec(&mut default_spec, custom);
1991    }
1992
1993    // Build metadata with optional annotations
1994    let mut metadata = ObjectMeta {
1995        name: Some(name.into()),
1996        namespace: Some(namespace.into()),
1997        labels: Some(labels),
1998        owner_references: Some(owner_refs),
1999        ..Default::default()
2000    };
2001
2002    // Apply custom annotations if provided
2003    if let Some(annotations) = custom_annotations {
2004        metadata.annotations = Some(annotations.clone());
2005    }
2006
2007    Service {
2008        metadata,
2009        spec: Some(default_spec),
2010        ..Default::default()
2011    }
2012}
2013
2014/// Builds a Kubernetes `ServiceAccount` for BIND9 pods.
2015///
2016/// Creates a `ServiceAccount` that will be used by BIND9 pods for authentication
2017/// to the bindcar API sidecar. This enables service-to-service authentication
2018/// using Kubernetes service account tokens.
2019///
2020/// # Arguments
2021///
2022/// * `namespace` - The namespace where the `ServiceAccount` will be created
2023/// * `instance` - The `Bind9Instance` that owns this `ServiceAccount`
2024///
2025/// # Returns
2026///
2027/// A `ServiceAccount` configured for BIND9 pods
2028///
2029/// # Example
2030///
2031/// ```rust,no_run
2032/// use bindy::bind9_resources::build_service_account;
2033/// use bindy::crd::Bind9Instance;
2034///
2035/// # fn example(instance: Bind9Instance) {
2036/// let service_account = build_service_account("bindy-system", &instance);
2037/// assert_eq!(service_account.metadata.name, Some("bind9".to_string()));
2038/// # }
2039/// ```
2040#[must_use]
2041pub fn build_service_account(namespace: &str, _instance: &Bind9Instance) -> ServiceAccount {
2042    // IMPORTANT: ServiceAccount is SHARED across all Bind9Instance resources in the namespace.
2043    // Do NOT set ownerReferences, as multiple instances would conflict (only one can have Controller=true).
2044    // Do NOT use instance-specific labels like managed-by, as multiple instances would conflict during Server-Side Apply.
2045    // The ServiceAccount will be cleaned up manually or via namespace deletion.
2046
2047    // Use static labels that don't vary between instances
2048    let mut labels = BTreeMap::new();
2049    labels.insert(K8S_NAME.into(), APP_NAME_BIND9.into());
2050    labels.insert(K8S_COMPONENT.into(), COMPONENT_DNS_SERVER.into());
2051    labels.insert(K8S_PART_OF.into(), PART_OF_BINDY.into());
2052
2053    ServiceAccount {
2054        metadata: ObjectMeta {
2055            name: Some(BIND9_SERVICE_ACCOUNT.into()),
2056            namespace: Some(namespace.into()),
2057            labels: Some(labels),
2058            owner_references: None, // Shared resource - no owner
2059            ..Default::default()
2060        },
2061        ..Default::default()
2062    }
2063}
2064
2065/// Merge custom service spec fields into the default spec
2066///
2067/// Only updates fields that are explicitly specified in the custom spec.
2068/// This allows partial customization while preserving defaults for other fields.
2069///
2070/// The `selector` and `ports` fields are never overridden to ensure the service
2071/// correctly routes traffic to the BIND9 pods.
2072fn merge_service_spec(default: &mut ServiceSpec, custom: &ServiceSpec) {
2073    // Merge type
2074    if let Some(ref type_) = custom.type_ {
2075        default.type_ = Some(type_.clone());
2076    }
2077
2078    // Merge loadBalancerIP
2079    if let Some(ref lb_ip) = custom.load_balancer_ip {
2080        default.load_balancer_ip = Some(lb_ip.clone());
2081    }
2082
2083    // Merge sessionAffinity
2084    if let Some(ref affinity) = custom.session_affinity {
2085        default.session_affinity = Some(affinity.clone());
2086    }
2087
2088    // Merge sessionAffinityConfig
2089    if let Some(ref config) = custom.session_affinity_config {
2090        default.session_affinity_config = Some(config.clone());
2091    }
2092
2093    // Merge clusterIP
2094    if let Some(ref cluster_ip) = custom.cluster_ip {
2095        default.cluster_ip = Some(cluster_ip.clone());
2096    }
2097
2098    // Merge externalTrafficPolicy
2099    if let Some(ref policy) = custom.external_traffic_policy {
2100        default.external_traffic_policy = Some(policy.clone());
2101    }
2102
2103    // Merge loadBalancerSourceRanges
2104    if let Some(ref ranges) = custom.load_balancer_source_ranges {
2105        default.load_balancer_source_ranges = Some(ranges.clone());
2106    }
2107
2108    // Merge externalIPs
2109    if let Some(ref ips) = custom.external_ips {
2110        default.external_ips = Some(ips.clone());
2111    }
2112
2113    // Merge loadBalancerClass
2114    if let Some(ref class) = custom.load_balancer_class {
2115        default.load_balancer_class = Some(class.clone());
2116    }
2117
2118    // Merge healthCheckNodePort
2119    if let Some(port) = custom.health_check_node_port {
2120        default.health_check_node_port = Some(port);
2121    }
2122
2123    // Merge publishNotReadyAddresses
2124    if let Some(publish) = custom.publish_not_ready_addresses {
2125        default.publish_not_ready_addresses = Some(publish);
2126    }
2127
2128    // Merge allocateLoadBalancerNodePorts
2129    if let Some(allocate) = custom.allocate_load_balancer_node_ports {
2130        default.allocate_load_balancer_node_ports = Some(allocate);
2131    }
2132
2133    // Merge internalTrafficPolicy
2134    if let Some(ref policy) = custom.internal_traffic_policy {
2135        default.internal_traffic_policy = Some(policy.clone());
2136    }
2137
2138    // Merge ipFamilies
2139    if let Some(ref families) = custom.ip_families {
2140        default.ip_families = Some(families.clone());
2141    }
2142
2143    // Merge ipFamilyPolicy
2144    if let Some(ref policy) = custom.ip_family_policy {
2145        default.ip_family_policy = Some(policy.clone());
2146    }
2147
2148    // Merge clusterIPs
2149    if let Some(ref ips) = custom.cluster_ips {
2150        default.cluster_ips = Some(ips.clone());
2151    }
2152
2153    // Merge ports (merge by name, custom ports override defaults)
2154    if let Some(ref custom_ports) = custom.ports {
2155        if let Some(ref mut default_ports) = default.ports {
2156            // Replace ports with matching names, add new ports
2157            for custom_port in custom_ports {
2158                if let Some(existing_port) = default_ports
2159                    .iter_mut()
2160                    .find(|p| p.name == custom_port.name)
2161                {
2162                    // Replace the entire port spec
2163                    *existing_port = custom_port.clone();
2164                } else {
2165                    // Add new port
2166                    default_ports.push(custom_port.clone());
2167                }
2168            }
2169        } else {
2170            // No default ports, use custom ports
2171            default.ports = Some(custom_ports.clone());
2172        }
2173    }
2174
2175    // Note: We intentionally don't merge selector as it needs to match
2176    // the deployment configuration to ensure traffic is routed correctly.
2177}