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