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