bindy/
bind9_resources.rs

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