bindy/
crd.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Custom Resource Definitions (CRDs) for DNS management.
5//!
6//! This module defines all Kubernetes Custom Resource Definitions used by Bindy
7//! to manage BIND9 DNS infrastructure declaratively.
8//!
9//! # Resource Types
10//!
11//! ## Infrastructure
12//!
13//! - [`Bind9Instance`] - Represents a BIND9 DNS server deployment
14//!
15//! ## DNS Zones
16//!
17//! - [`DNSZone`] - Defines DNS zones with SOA records and instance targeting
18//!
19//! ## DNS Records
20//!
21//! - [`ARecord`] - IPv4 address records
22//! - [`AAAARecord`] - IPv6 address records
23//! - [`CNAMERecord`] - Canonical name (alias) records
24//! - [`MXRecord`] - Mail exchange records
25//! - [`TXTRecord`] - Text records (SPF, DKIM, DMARC, etc.)
26//! - [`NSRecord`] - Nameserver delegation records
27//! - [`SRVRecord`] - Service location records
28//! - [`CAARecord`] - Certificate authority authorization records
29//!
30//! # Example: Creating a DNS Zone
31//!
32//! ```rust,no_run,ignore
33//! use bindy::crd::{DNSZoneSpec, SOARecord};
34//!
35//! let soa = SOARecord {
36//!     primary_ns: "ns1.example.com.".to_string(),
37//!     admin_email: "admin@example.com".to_string(),
38//!     serial: 2024010101,
39//!     refresh: 3600,
40//!     retry: 600,
41//!     expire: 604800,
42//!     negative_ttl: 86400,
43//! };
44//!
45//! // Example showing DNSZone spec structure
46//! // Note: Actual spec fields may vary - see DNSZoneSpec definition
47//! let spec = DNSZoneSpec {
48//!     zone_name: "example.com".to_string(),
49//!     soa_record: soa,
50//!     ttl: Some(3600),
51//!     name_server_ips: None,
52//!     records_from: None,
53//! };
54//! ```
55//!
56//! # Example: Creating DNS Records
57//!
58//! ```rust,no_run
59//! use bindy::crd::{ARecordSpec, MXRecordSpec};
60//!
61//! // A Record for www.example.com
62//! let a_record = ARecordSpec {
63//!     name: "www".to_string(),
64//!     ipv4_addresses: vec!["192.0.2.1".to_string()],
65//!     ttl: Some(300),
66//! };
67//!
68//! // MX Record for mail routing
69//! let mx_record = MXRecordSpec {
70//!     name: "@".to_string(),
71//!     priority: 10,
72//!     mail_server: "mail.example.com.".to_string(),
73//!     ttl: Some(3600),
74//! };
75//! ```
76
77use k8s_openapi::api::core::v1::{EnvVar, ServiceSpec, Volume, VolumeMount};
78use kube::CustomResource;
79use schemars::JsonSchema;
80use serde::{Deserialize, Serialize};
81use std::collections::{BTreeMap, HashMap};
82
83/// DNS record kind (type) enumeration.
84///
85/// Represents the Kubernetes `kind` field for all DNS record custom resources.
86/// This enum eliminates magic strings when matching record types and provides
87/// type-safe conversions between string representations and enum values.
88///
89/// # Example
90///
91/// ```rust,ignore
92/// use bindy::crd::DNSRecordKind;
93///
94/// // Parse from string (fallible — unknown kinds return Err instead of panicking)
95/// let kind = DNSRecordKind::try_from("ARecord").unwrap();
96/// assert_eq!(kind, DNSRecordKind::A);
97///
98/// // Convert to string
99/// assert_eq!(kind.as_str(), "ARecord");
100/// ```
101#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
102pub enum DNSRecordKind {
103    /// IPv4 address record (A)
104    A,
105    /// IPv6 address record (AAAA)
106    AAAA,
107    /// Text record (TXT)
108    TXT,
109    /// Canonical name record (CNAME)
110    CNAME,
111    /// Mail exchange record (MX)
112    MX,
113    /// Nameserver record (NS)
114    NS,
115    /// Service record (SRV)
116    SRV,
117    /// Certificate authority authorization record (CAA)
118    CAA,
119}
120
121impl DNSRecordKind {
122    /// Returns the Kubernetes `kind` string for this record type.
123    ///
124    /// # Example
125    ///
126    /// ```rust,ignore
127    /// use bindy::crd::DNSRecordKind;
128    ///
129    /// assert_eq!(DNSRecordKind::A.as_str(), "ARecord");
130    /// assert_eq!(DNSRecordKind::MX.as_str(), "MXRecord");
131    /// ```
132    #[must_use]
133    pub const fn as_str(self) -> &'static str {
134        match self {
135            Self::A => "ARecord",
136            Self::AAAA => "AAAARecord",
137            Self::TXT => "TXTRecord",
138            Self::CNAME => "CNAMERecord",
139            Self::MX => "MXRecord",
140            Self::NS => "NSRecord",
141            Self::SRV => "SRVRecord",
142            Self::CAA => "CAARecord",
143        }
144    }
145
146    /// Returns all DNS record kinds as a slice.
147    ///
148    /// Useful for iterating over all supported record types.
149    ///
150    /// # Example
151    ///
152    /// ```rust,ignore
153    /// use bindy::crd::DNSRecordKind;
154    ///
155    /// for kind in DNSRecordKind::all() {
156    ///     println!("Record type: {}", kind.as_str());
157    /// }
158    /// ```
159    #[must_use]
160    pub const fn all() -> &'static [Self] {
161        &[
162            Self::A,
163            Self::AAAA,
164            Self::TXT,
165            Self::CNAME,
166            Self::MX,
167            Self::NS,
168            Self::SRV,
169            Self::CAA,
170        ]
171    }
172
173    /// Converts this DNS record kind to a Hickory DNS `RecordType`.
174    ///
175    /// This is useful when interfacing with the Hickory DNS library for
176    /// zone file generation or DNS protocol operations.
177    ///
178    /// # Example
179    ///
180    /// ```rust,ignore
181    /// use bindy::crd::DNSRecordKind;
182    /// use hickory_proto::rr::RecordType;
183    ///
184    /// let kind = DNSRecordKind::A;
185    /// let record_type = kind.to_hickory_record_type();
186    /// assert_eq!(record_type, RecordType::A);
187    /// ```
188    #[must_use]
189    pub const fn to_hickory_record_type(self) -> hickory_proto::rr::RecordType {
190        use hickory_proto::rr::RecordType;
191        match self {
192            Self::A => RecordType::A,
193            Self::AAAA => RecordType::AAAA,
194            Self::TXT => RecordType::TXT,
195            Self::CNAME => RecordType::CNAME,
196            Self::MX => RecordType::MX,
197            Self::NS => RecordType::NS,
198            Self::SRV => RecordType::SRV,
199            Self::CAA => RecordType::CAA,
200        }
201    }
202}
203
204/// Error returned when a string does not match a known [`DNSRecordKind`].
205///
206/// Previously the `From<&str>` impl panicked on unknown input. Now the
207/// fallible `TryFrom` impls surface this error so the caller can return it
208/// through normal `Result` propagation rather than crashing the reconciler.
209#[derive(Debug, thiserror::Error, PartialEq, Eq)]
210#[error("unknown DNS record kind {0:?} (expected one of: ARecord, AAAARecord, TXTRecord, CNAMERecord, MXRecord, NSRecord, SRVRecord, CAARecord)")]
211pub struct UnknownDNSRecordKind(pub String);
212
213impl TryFrom<&str> for DNSRecordKind {
214    type Error = UnknownDNSRecordKind;
215
216    fn try_from(s: &str) -> Result<Self, Self::Error> {
217        match s {
218            "ARecord" => Ok(Self::A),
219            "AAAARecord" => Ok(Self::AAAA),
220            "TXTRecord" => Ok(Self::TXT),
221            "CNAMERecord" => Ok(Self::CNAME),
222            "MXRecord" => Ok(Self::MX),
223            "NSRecord" => Ok(Self::NS),
224            "SRVRecord" => Ok(Self::SRV),
225            "CAARecord" => Ok(Self::CAA),
226            _ => Err(UnknownDNSRecordKind(s.to_string())),
227        }
228    }
229}
230
231impl TryFrom<String> for DNSRecordKind {
232    type Error = UnknownDNSRecordKind;
233
234    fn try_from(s: String) -> Result<Self, Self::Error> {
235        Self::try_from(s.as_str())
236    }
237}
238
239impl std::str::FromStr for DNSRecordKind {
240    type Err = UnknownDNSRecordKind;
241
242    fn from_str(s: &str) -> Result<Self, Self::Err> {
243        Self::try_from(s)
244    }
245}
246
247impl std::fmt::Display for DNSRecordKind {
248    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
249        write!(f, "{}", self.as_str())
250    }
251}
252
253/// Label selector to match Kubernetes resources.
254///
255/// A label selector is a label query over a set of resources. The result of matchLabels and
256/// matchExpressions are `ANDed`. An empty label selector matches all objects.
257#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema, PartialEq)]
258#[serde(rename_all = "camelCase")]
259pub struct LabelSelector {
260    /// Map of {key,value} pairs. A single {key,value} in the matchLabels map is equivalent
261    /// to an element of matchExpressions, whose key field is "key", the operator is "In",
262    /// and the values array contains only "value". All requirements must be satisfied.
263    #[serde(skip_serializing_if = "Option::is_none")]
264    pub match_labels: Option<BTreeMap<String, String>>,
265
266    /// List of label selector requirements. All requirements must be satisfied.
267    #[serde(skip_serializing_if = "Option::is_none")]
268    pub match_expressions: Option<Vec<LabelSelectorRequirement>>,
269}
270
271/// A label selector requirement is a selector that contains values, a key, and an operator
272/// that relates the key and values.
273#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
274#[serde(rename_all = "camelCase")]
275pub struct LabelSelectorRequirement {
276    /// The label key that the selector applies to.
277    pub key: String,
278
279    /// Operator represents a key's relationship to a set of values.
280    /// Valid operators are In, `NotIn`, Exists and `DoesNotExist`.
281    pub operator: String,
282
283    /// An array of string values. If the operator is In or `NotIn`,
284    /// the values array must be non-empty. If the operator is Exists or `DoesNotExist`,
285    /// the values array must be empty.
286    #[serde(skip_serializing_if = "Option::is_none")]
287    pub values: Option<Vec<String>>,
288}
289
290/// Source for DNS records to include in a zone.
291///
292/// Specifies how DNS records should be associated with this zone using label selectors.
293/// Records matching the selector criteria will be automatically included in the zone.
294///
295/// # Example
296///
297/// ```yaml
298/// recordsFrom:
299///   - selector:
300///       matchLabels:
301///         app: podinfo
302///       matchExpressions:
303///         - key: environment
304///           operator: In
305///           values:
306///             - dev
307///             - staging
308/// ```
309#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
310#[serde(rename_all = "camelCase")]
311pub struct RecordSource {
312    /// Label selector to match DNS records.
313    ///
314    /// Records (`ARecord`, `CNAMERecord`, `MXRecord`, etc.) with labels matching this selector
315    /// will be automatically associated with this zone.
316    ///
317    /// The selector uses standard Kubernetes label selector semantics:
318    /// - `matchLabels`: All specified labels must match (AND logic)
319    /// - `matchExpressions`: All expressions must be satisfied (AND logic)
320    /// - Both `matchLabels` and `matchExpressions` can be used together
321    pub selector: LabelSelector,
322}
323
324/// Source for `Bind9Instance` resources to target for zone configuration.
325///
326/// Specifies how `Bind9Instance` resources should be selected using label selectors.
327/// The `DNSZone` controller will configure zones on all matching instances.
328///
329/// # Example
330///
331/// ```yaml
332/// instancesFrom:
333///   - selector:
334///       matchLabels:
335///         environment: production
336///         role: primary
337///       matchExpressions:
338///         - key: region
339///           operator: In
340///           values:
341///             - us-east-1
342///             - us-west-2
343/// ```
344#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
345#[serde(rename_all = "camelCase")]
346pub struct InstanceSource {
347    /// Label selector to match `Bind9Instance` resources.
348    ///
349    /// `Bind9Instance` resources with labels matching this selector will be automatically
350    /// targeted for zone configuration by this `DNSZone`.
351    ///
352    /// The selector uses standard Kubernetes label selector semantics:
353    /// - `matchLabels`: All specified labels must match (AND logic)
354    /// - `matchExpressions`: All expressions must be satisfied (AND logic)
355    /// - Both `matchLabels` and `matchExpressions` can be used together
356    pub selector: LabelSelector,
357}
358
359impl LabelSelector {
360    /// Checks if this label selector matches the given labels.
361    ///
362    /// Returns `true` if all match requirements are satisfied.
363    ///
364    /// # Arguments
365    ///
366    /// * `labels` - The labels to match against (from `metadata.labels`)
367    ///
368    /// # Examples
369    ///
370    /// ```rust
371    /// use std::collections::BTreeMap;
372    /// use bindy::crd::LabelSelector;
373    ///
374    /// let selector = LabelSelector {
375    ///     match_labels: Some(BTreeMap::from([
376    ///         ("app".to_string(), "podinfo".to_string()),
377    ///     ])),
378    ///     match_expressions: None,
379    /// };
380    ///
381    /// let labels = BTreeMap::from([
382    ///     ("app".to_string(), "podinfo".to_string()),
383    ///     ("env".to_string(), "dev".to_string()),
384    /// ]);
385    ///
386    /// assert!(selector.matches(&labels));
387    /// ```
388    #[must_use]
389    pub fn matches(&self, labels: &BTreeMap<String, String>) -> bool {
390        // Check matchLabels (all must match)
391        if let Some(ref match_labels) = self.match_labels {
392            for (key, value) in match_labels {
393                if labels.get(key) != Some(value) {
394                    return false;
395                }
396            }
397        }
398
399        // Check matchExpressions (all must be satisfied)
400        if let Some(ref expressions) = self.match_expressions {
401            for expr in expressions {
402                if !expr.matches(labels) {
403                    return false;
404                }
405            }
406        }
407
408        true
409    }
410}
411
412impl LabelSelectorRequirement {
413    /// Checks if this requirement matches the given labels.
414    ///
415    /// # Arguments
416    ///
417    /// * `labels` - The labels to match against
418    ///
419    /// # Returns
420    ///
421    /// * `true` if the requirement is satisfied, `false` otherwise
422    #[must_use]
423    pub fn matches(&self, labels: &BTreeMap<String, String>) -> bool {
424        match self.operator.as_str() {
425            "In" => {
426                // Label value must be in the values list
427                if let Some(ref values) = self.values {
428                    if let Some(label_value) = labels.get(&self.key) {
429                        values.contains(label_value)
430                    } else {
431                        false
432                    }
433                } else {
434                    false
435                }
436            }
437            "NotIn" => {
438                // Label value must NOT be in the values list
439                if let Some(ref values) = self.values {
440                    if let Some(label_value) = labels.get(&self.key) {
441                        !values.contains(label_value)
442                    } else {
443                        true // Label doesn't exist, so it's not in the list
444                    }
445                } else {
446                    true
447                }
448            }
449            "Exists" => {
450                // Label key must exist (any value)
451                labels.contains_key(&self.key)
452            }
453            "DoesNotExist" => {
454                // Label key must NOT exist
455                !labels.contains_key(&self.key)
456            }
457            _ => false, // Unknown operator
458        }
459    }
460}
461
462/// SOA (Start of Authority) Record specification.
463///
464/// The SOA record defines authoritative information about a DNS zone, including
465/// the primary nameserver, responsible party's email, and timing parameters for
466/// zone transfers and caching.
467///
468/// # Example
469///
470/// ```rust
471/// use bindy::crd::SOARecord;
472///
473/// let soa = SOARecord {
474///     primary_ns: "ns1.example.com.".to_string(),
475///     admin_email: "admin.example.com.".to_string(), // Note: @ replaced with .
476///     serial: 2024010101,
477///     refresh: 3600,   // Check for updates every hour
478///     retry: 600,      // Retry after 10 minutes on failure
479///     expire: 604800,  // Expire after 1 week
480///     negative_ttl: 86400, // Cache negative responses for 1 day
481/// };
482/// ```
483#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
484#[serde(rename_all = "camelCase")]
485pub struct SOARecord {
486    /// Primary nameserver for this zone (must be a FQDN ending with .).
487    ///
488    /// Example: `ns1.example.com.`
489    #[schemars(regex(
490        pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.$"
491    ))]
492    pub primary_ns: String,
493
494    /// Email address of the zone administrator (@ replaced with ., must end with .).
495    ///
496    /// Example: `admin.example.com.` for admin@example.com
497    #[schemars(regex(
498        pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.$"
499    ))]
500    pub admin_email: String,
501
502    /// Serial number for this zone. Typically in YYYYMMDDNN format.
503    /// Secondaries use this to determine if they need to update.
504    ///
505    /// Must be a 32-bit unsigned integer (0 to 4294967295).
506    /// The field is i64 to accommodate the full u32 range.
507    #[schemars(range(min = 0, max = 4_294_967_295_i64))]
508    pub serial: i64,
509
510    /// Refresh interval in seconds. How often secondaries should check for updates.
511    ///
512    /// Typical values: 3600-86400 (1 hour to 1 day).
513    #[schemars(range(min = 1, max = 2_147_483_647))]
514    pub refresh: i32,
515
516    /// Retry interval in seconds. How long to wait before retrying a failed refresh.
517    ///
518    /// Should be less than refresh. Typical values: 600-7200 (10 minutes to 2 hours).
519    #[schemars(range(min = 1, max = 2_147_483_647))]
520    pub retry: i32,
521
522    /// Expire time in seconds. After this time, secondaries stop serving the zone
523    /// if they can't contact the primary.
524    ///
525    /// Should be much larger than refresh+retry. Typical values: 604800-2419200 (1-4 weeks).
526    #[schemars(range(min = 1, max = 2_147_483_647))]
527    pub expire: i32,
528
529    /// Negative caching TTL in seconds. How long to cache NXDOMAIN responses.
530    ///
531    /// Typical values: 300-86400 (5 minutes to 1 day).
532    #[schemars(range(min = 0, max = 2_147_483_647))]
533    pub negative_ttl: i32,
534}
535
536/// Authoritative nameserver configuration for a DNS zone.
537///
538/// Defines an authoritative nameserver that will have an NS record automatically
539/// generated in the zone. Optionally includes IP addresses for glue record generation
540/// when the nameserver is within the zone's own domain.
541///
542/// # Examples
543///
544/// ## In-zone nameserver with glue records
545///
546/// ```yaml
547/// nameServers:
548///   - hostname: ns2.example.com.
549///     ipv4Address: "192.0.2.2"
550///     ipv6Address: "2001:db8::2"
551/// ```
552///
553/// ## Out-of-zone nameserver (no glue needed)
554///
555/// ```yaml
556/// nameServers:
557///   - hostname: ns1.external-provider.net.
558/// ```
559#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
560#[serde(rename_all = "camelCase")]
561pub struct NameServer {
562    /// Fully qualified domain name of the nameserver.
563    ///
564    /// Must end with a dot (.) for FQDN. This nameserver will have an NS record
565    /// automatically generated at the zone apex (@).
566    ///
567    /// Example: `ns2.example.com.`
568    #[schemars(regex(
569        pattern = r"^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*\.$"
570    ))]
571    pub hostname: String,
572
573    /// Optional IPv4 address for glue record generation.
574    ///
575    /// Required when the nameserver is within the zone's own domain (in-zone delegation).
576    /// When provided, an A record will be automatically generated for the nameserver.
577    ///
578    /// Example: For `ns2.example.com.` in zone `example.com`, provide `"192.0.2.2"`
579    ///
580    /// Glue records allow resolvers to find the IP addresses of nameservers that are
581    /// within the zone they serve, avoiding circular dependencies.
582    #[serde(skip_serializing_if = "Option::is_none")]
583    #[schemars(regex(pattern = r"^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$"))]
584    pub ipv4_address: Option<String>,
585
586    /// Optional IPv6 address for glue record generation (AAAA record).
587    ///
588    /// When provided along with (or instead of) `ipv4Address`, an AAAA record will be
589    /// automatically generated for the nameserver.
590    ///
591    /// Example: `"2001:db8::2"`
592    #[serde(skip_serializing_if = "Option::is_none")]
593    #[schemars(regex(
594        pattern = r"^([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|^::$|^::1$|^([0-9a-fA-F]{1,4}:){1,7}:$|^([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}$|^([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}$|^([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}$|^([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}$|^([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}$|^[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})$|^:((:[0-9a-fA-F]{1,4}){1,7}|:)$"
595    ))]
596    pub ipv6_address: Option<String>,
597}
598
599/// Condition represents an observation of a resource's current state.
600///
601/// Conditions are used in status subresources to communicate the state of
602/// a resource to users and controllers.
603#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
604#[serde(rename_all = "camelCase")]
605pub struct Condition {
606    /// Type of condition. Common types include: Ready, Available, Progressing, Degraded, Failed.
607    pub r#type: String,
608
609    /// Status of the condition: True, False, or Unknown.
610    pub status: String,
611
612    /// Brief CamelCase reason for the condition's last transition.
613    #[serde(skip_serializing_if = "Option::is_none")]
614    pub reason: Option<String>,
615
616    /// Human-readable message indicating details about the transition.
617    #[serde(skip_serializing_if = "Option::is_none")]
618    pub message: Option<String>,
619
620    /// Last time the condition transitioned from one status to another (RFC3339 format).
621    #[serde(skip_serializing_if = "Option::is_none")]
622    pub last_transition_time: Option<String>,
623}
624
625/// Reference to a DNS record associated with a zone
626#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
627#[serde(rename_all = "camelCase")]
628pub struct RecordReference {
629    /// API version of the record (e.g., "bindy.firestoned.io/v1beta1")
630    pub api_version: String,
631    /// Kind of the record (e.g., `ARecord`, `CNAMERecord`, `MXRecord`)
632    pub kind: String,
633    /// Name of the record resource
634    pub name: String,
635    /// Namespace of the record resource
636    pub namespace: String,
637    /// DNS record name from spec.name (e.g., "www", "@", "_service._tcp")
638    /// Used for self-healing cleanup when verifying records in BIND9
639    #[serde(skip_serializing_if = "Option::is_none")]
640    pub record_name: Option<String>,
641    /// DNS zone name (e.g., "example.com")
642    /// Used for self-healing cleanup when querying BIND9
643    #[serde(skip_serializing_if = "Option::is_none")]
644    pub zone_name: Option<String>,
645}
646
647/// Reference to a DNS record with reconciliation timestamp tracking.
648///
649/// This struct tracks which records are assigned to a zone and whether
650/// they need reconciliation based on the `lastReconciledAt` timestamp.
651///
652/// **Event-Driven Pattern:**
653/// - Records with `lastReconciledAt == None` need reconciliation
654/// - Records with `lastReconciledAt == Some(timestamp)` are already configured
655///
656/// This pattern prevents redundant BIND9 API calls for already-configured records,
657/// following the same architecture as `Bind9Instance.status.selectedZones[]`.
658#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
659#[serde(rename_all = "camelCase")]
660pub struct RecordReferenceWithTimestamp {
661    /// API version of the record (e.g., "bindy.firestoned.io/v1beta1")
662    pub api_version: String,
663    /// Kind of the record (e.g., "`ARecord`", "`CNAMERecord`", "`MXRecord`")
664    pub kind: String,
665    /// Name of the record resource
666    pub name: String,
667    /// Namespace of the record resource
668    pub namespace: String,
669    /// DNS record name from spec.name (e.g., "www", "@", "_service._tcp")
670    #[serde(skip_serializing_if = "Option::is_none")]
671    pub record_name: Option<String>,
672    /// Timestamp when this record was last successfully reconciled to BIND9.
673    ///
674    /// - `None` = Record needs reconciliation (new or spec changed)
675    /// - `Some(timestamp)` = Record already configured, skip reconciliation
676    ///
677    /// This field is set by the record operator after successful BIND9 update.
678    /// The zone controller resets it to `None` when spec changes or zone is recreated.
679    #[serde(skip_serializing_if = "Option::is_none")]
680    pub last_reconciled_at: Option<k8s_openapi::apimachinery::pkg::apis::meta::v1::Time>,
681}
682
683/// Status of a `Bind9Instance` relationship with a `DNSZone`.
684///
685/// Tracks the lifecycle of zone assignment from initial selection through configuration.
686#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
687#[serde(rename_all = "PascalCase")]
688pub enum InstanceStatus {
689    /// Zone has selected this instance via bind9InstancesFrom, but zone not yet configured
690    Claimed,
691    /// Zone successfully configured on instance
692    Configured,
693    /// Zone configuration failed on instance
694    Failed,
695    /// Instance no longer selected by this zone (cleanup pending)
696    Unclaimed,
697}
698
699impl InstanceStatus {
700    #[must_use]
701    pub fn as_str(&self) -> &'static str {
702        match self {
703            InstanceStatus::Claimed => "Claimed",
704            InstanceStatus::Configured => "Configured",
705            InstanceStatus::Failed => "Failed",
706            InstanceStatus::Unclaimed => "Unclaimed",
707        }
708    }
709}
710
711/// Reference to a `Bind9Instance` with status and timestamp.
712///
713/// Extends `InstanceReference` with status tracking for zone claiming and configuration.
714#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq, Hash)]
715#[serde(rename_all = "camelCase")]
716pub struct InstanceReferenceWithStatus {
717    /// API version of the `Bind9Instance` resource
718    pub api_version: String,
719    /// Kind of the resource (always "`Bind9Instance`")
720    pub kind: String,
721    /// Name of the `Bind9Instance` resource
722    pub name: String,
723    /// Namespace of the `Bind9Instance` resource
724    pub namespace: String,
725    /// Current status of this instance's relationship with the zone
726    pub status: InstanceStatus,
727    /// Timestamp when the instance status was last reconciled for this zone
728    #[serde(skip_serializing_if = "Option::is_none")]
729    pub last_reconciled_at: Option<String>,
730    /// Additional message (for Failed status, error details, etc.)
731    #[serde(skip_serializing_if = "Option::is_none")]
732    pub message: Option<String>,
733}
734
735/// `DNSZone` status
736#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
737#[serde(rename_all = "camelCase")]
738pub struct DNSZoneStatus {
739    #[serde(default)]
740    pub conditions: Vec<Condition>,
741    #[serde(skip_serializing_if = "Option::is_none")]
742    pub observed_generation: Option<i64>,
743    /// Count of records selected by recordsFrom label selectors.
744    ///
745    /// This field is automatically calculated from the length of `records`.
746    /// It provides a quick view of how many records are associated with this zone.
747    ///
748    /// Defaults to 0 when no records are selected.
749    #[serde(default)]
750    pub records_count: i32,
751    /// List of DNS records selected by recordsFrom label selectors.
752    ///
753    /// **Event-Driven Pattern:**
754    /// - Records with `lastReconciledAt == None` need reconciliation
755    /// - Records with `lastReconciledAt == Some(timestamp)` are already configured
756    ///
757    /// This field is populated by the `DNSZone` controller when evaluating `recordsFrom` selectors.
758    /// The timestamp is set by the record operator after successful BIND9 update.
759    ///
760    /// **Single Source of Truth:**
761    /// This status field is authoritative for which records belong to this zone and whether
762    /// they need reconciliation, preventing redundant BIND9 API calls.
763    #[serde(default)]
764    pub records: Vec<RecordReferenceWithTimestamp>,
765    /// List of `Bind9Instance` resources and their status for this zone.
766    ///
767    /// **Single Source of Truth for Instance-Zone Relationships:**
768    /// This field tracks all `Bind9Instances` selected by this zone via `bind9InstancesFrom` selectors,
769    /// along with the current status of zone configuration on each instance.
770    ///
771    /// **Status Lifecycle:**
772    /// - `Claimed`: Zone selected this instance (via `bind9InstancesFrom`), waiting for configuration
773    /// - `Configured`: Zone successfully configured on instance
774    /// - `Failed`: Zone configuration failed on instance
775    /// - `Unclaimed`: Instance no longer selected by this zone (cleanup pending)
776    ///
777    /// **Event-Driven Pattern:**
778    /// - `DNSZone` controller evaluates `bind9InstancesFrom` selectors to find matching instances
779    /// - `DNSZone` controller reads this field to track configuration status
780    /// - `DNSZone` controller updates status after configuration attempts
781    ///
782    /// **Automatic Selection:**
783    /// When a `DNSZone` reconciles, the controller automatically:
784    /// 1. Queries all `Bind9Instances` matching `bind9InstancesFrom` selectors
785    /// 2. Adds them to this list with status="Claimed"
786    /// 3. Configures zones on each instance
787    ///
788    /// # Example
789    ///
790    /// ```yaml
791    /// status:
792    ///   bind9Instances:
793    ///     - apiVersion: bindy.firestoned.io/v1beta1
794    ///       kind: Bind9Instance
795    ///       name: primary-dns-0
796    ///       namespace: bindy-system
797    ///       status: Configured
798    ///       lastReconciledAt: "2026-01-03T20:00:00Z"
799    ///     - apiVersion: bindy.firestoned.io/v1beta1
800    ///       kind: Bind9Instance
801    ///       name: secondary-dns-0
802    ///       namespace: bindy-system
803    ///       status: Claimed
804    ///       lastReconciledAt: "2026-01-03T20:01:00Z"
805    /// ```
806    #[serde(default, skip_serializing_if = "Vec::is_empty")]
807    pub bind9_instances: Vec<InstanceReferenceWithStatus>,
808    /// Number of `Bind9Instance` resources in the `bind9_instances` list.
809    ///
810    /// This field is automatically updated whenever the `bind9_instances` list changes.
811    /// It provides a quick view of how many instances are serving this zone without
812    /// requiring clients to count array elements.
813    #[serde(skip_serializing_if = "Option::is_none")]
814    pub bind9_instances_count: Option<i32>,
815
816    /// DNSSEC signing status for this zone
817    ///
818    /// Populated when DNSSEC signing is enabled. Contains DS records,
819    /// key tags, and rotation information.
820    ///
821    /// **Important**: DS records must be published in the parent zone
822    /// to complete the DNSSEC chain of trust.
823    ///
824    /// # Example
825    ///
826    /// ```yaml
827    /// dnssec:
828    ///   signed: true
829    ///   dsRecords:
830    ///     - "example.com. IN DS 12345 13 2 ABC123..."
831    ///   keyTag: 12345
832    ///   algorithm: "ECDSAP256SHA256"
833    ///   nextKeyRollover: "2026-04-02T00:00:00Z"
834    /// ```
835    #[serde(default, skip_serializing_if = "Option::is_none")]
836    pub dnssec: Option<DNSSECStatus>,
837}
838
839/// Secondary Zone configuration
840#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
841#[serde(rename_all = "camelCase")]
842pub struct SecondaryZoneConfig {
843    /// Primary server addresses for zone transfer
844    pub primary_servers: Vec<String>,
845    /// Optional TSIG key for authenticated transfers
846    #[serde(skip_serializing_if = "Option::is_none")]
847    pub tsig_key: Option<String>,
848}
849
850/// `DNSZone` defines a DNS zone to be managed by BIND9.
851///
852/// A `DNSZone` represents an authoritative DNS zone (e.g., example.com) that will be
853/// served by a BIND9 cluster. The zone includes SOA record information and will be
854/// synchronized to all instances in the referenced cluster via AXFR/IXFR.
855///
856/// `DNSZones` can reference either:
857/// - A namespace-scoped `Bind9Cluster` (using `clusterRef`)
858/// - A cluster-scoped `ClusterBind9Provider` (using `clusterProviderRef`)
859///
860/// Exactly one of `clusterRef` or `clusterProviderRef` must be specified.
861///
862/// # Example: Namespace-scoped Cluster
863///
864/// ```yaml
865/// apiVersion: bindy.firestoned.io/v1beta1
866/// kind: DNSZone
867/// metadata:
868///   name: example-com
869///   namespace: dev-team-alpha
870/// spec:
871///   zoneName: example.com
872///   clusterRef: dev-team-dns  # References Bind9Cluster in same namespace
873///   soaRecord:
874///     primaryNs: ns1.example.com.
875///     adminEmail: admin.example.com.
876///     serial: 2024010101
877///     refresh: 3600
878///     retry: 600
879///     expire: 604800
880///     negativeTtl: 86400
881///   ttl: 3600
882/// ```
883///
884/// # Example: Cluster-scoped Global Cluster
885///
886/// ```yaml
887/// apiVersion: bindy.firestoned.io/v1beta1
888/// kind: DNSZone
889/// metadata:
890///   name: production-example-com
891///   namespace: production
892/// spec:
893///   zoneName: example.com
894///   clusterProviderRef: shared-production-dns  # References ClusterBind9Provider (cluster-scoped)
895///   soaRecord:
896///     primaryNs: ns1.example.com.
897///     adminEmail: admin.example.com.
898///     serial: 2024010101
899///     refresh: 3600
900///     retry: 600
901///     expire: 604800
902///     negativeTtl: 86400
903///   ttl: 3600
904/// ```
905#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
906#[kube(
907    group = "bindy.firestoned.io",
908    version = "v1beta1",
909    kind = "DNSZone",
910    namespaced,
911    shortname = "zone",
912    shortname = "zones",
913    shortname = "dz",
914    shortname = "dzs",
915    doc = "DNSZone represents an authoritative DNS zone managed by BIND9. Each DNSZone defines a zone (e.g., example.com) with SOA record parameters. Can reference either a namespace-scoped Bind9Cluster or cluster-scoped ClusterBind9Provider.",
916    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".spec.zoneName"}"#,
917    printcolumn = r#"{"name":"Provider","type":"string","jsonPath":".spec.clusterProviderRef"}"#,
918    printcolumn = r#"{"name":"Records","type":"integer","jsonPath":".status.recordsCount"}"#,
919    printcolumn = r#"{"name":"Instances","type":"integer","jsonPath":".status.bind9InstancesCount"}"#,
920    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
921    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
922)]
923#[kube(status = "DNSZoneStatus")]
924#[serde(rename_all = "camelCase")]
925pub struct DNSZoneSpec {
926    /// DNS zone name (e.g., "example.com").
927    ///
928    /// Must be a valid DNS zone name. Can be a domain or subdomain.
929    /// Examples: "example.com", "internal.example.com", "10.in-addr.arpa"
930    #[schemars(regex(
931        pattern = r"^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$"
932    ))]
933    pub zone_name: String,
934
935    /// SOA (Start of Authority) record - defines zone authority and refresh parameters.
936    ///
937    /// The SOA record is required for all authoritative zones and contains
938    /// timing information for zone transfers and caching.
939    pub soa_record: SOARecord,
940
941    /// Default TTL (Time To Live) for records in this zone, in seconds.
942    ///
943    /// If not specified, individual records must specify their own TTL.
944    /// Typical values: 300-86400 (5 minutes to 1 day).
945    #[serde(default)]
946    #[schemars(range(min = 0, max = 2_147_483_647))]
947    pub ttl: Option<i32>,
948
949    /// Reference to a `Bind9Cluster` or `ClusterBind9Provider` to serve this zone.
950    ///
951    /// When specified, this zone will be automatically configured on all `Bind9Instance`
952    /// resources that belong to the referenced cluster. This provides a simple way to
953    /// assign zones to entire clusters.
954    ///
955    /// **Relationship with `bind9_instances_from`:**
956    /// - If only `cluster_ref` is specified: Zone targets all instances in that cluster
957    /// - If only `bind9_instances_from` is specified: Zone targets instances matching label selectors
958    /// - If both are specified: Zone targets union of cluster instances AND label-selected instances
959    ///
960    /// # Example
961    ///
962    /// ```yaml
963    /// spec:
964    ///   clusterRef: production-dns  # Target all instances in this cluster
965    ///   zoneName: example.com
966    /// ```
967    #[serde(default, skip_serializing_if = "Option::is_none")]
968    pub cluster_ref: Option<String>,
969
970    /// Authoritative nameservers for this zone (v0.4.0+).
971    ///
972    /// NS records are automatically generated at the zone apex (@) for all entries.
973    /// The primary nameserver from `soaRecord.primaryNs` is always included automatically.
974    ///
975    /// Each entry can optionally include IP addresses to generate glue records (A/AAAA)
976    /// for in-zone nameservers. Glue records are required when the nameserver is within
977    /// the zone's own domain to avoid circular dependencies.
978    ///
979    /// # Examples
980    ///
981    /// ```yaml
982    /// # In-zone nameservers with glue records
983    /// nameServers:
984    ///   - hostname: ns2.example.com.
985    ///     ipv4Address: "192.0.2.2"
986    ///   - hostname: ns3.example.com.
987    ///     ipv4Address: "192.0.2.3"
988    ///     ipv6Address: "2001:db8::3"
989    ///
990    /// # Out-of-zone nameserver (no glue needed)
991    ///   - hostname: ns4.external-provider.net.
992    /// ```
993    ///
994    /// **Generated Records:**
995    /// - `@ IN NS ns2.example.com.` (NS record)
996    /// - `ns2.example.com. IN A 192.0.2.2` (glue record for in-zone NS)
997    /// - `@ IN NS ns3.example.com.` (NS record)
998    /// - `ns3.example.com. IN A 192.0.2.3` (IPv4 glue)
999    /// - `ns3.example.com. IN AAAA 2001:db8::3` (IPv6 glue)
1000    /// - `@ IN NS ns4.external-provider.net.` (NS record only, no glue)
1001    ///
1002    /// **Benefits over `nameServerIps` (deprecated):**
1003    /// - Clearer purpose: authoritative nameservers, not just glue records
1004    /// - IPv6 support via `ipv6Address` field
1005    /// - Automatic NS record generation (no manual `NSRecord` CRs needed)
1006    ///
1007    /// **Migration:** See [docs/src/operations/migration-guide.md](../operations/migration-guide.md)
1008    #[serde(default, skip_serializing_if = "Option::is_none")]
1009    pub name_servers: Option<Vec<NameServer>>,
1010
1011    /// (DEPRECATED in v0.4.0) Map of nameserver hostnames to IP addresses for glue records.
1012    ///
1013    /// **Use `nameServers` instead.** This field will be removed in v1.0.0.
1014    ///
1015    /// Glue records provide IP addresses for nameservers within the zone's own domain.
1016    /// This is necessary when delegating subdomains where the nameserver is within the
1017    /// delegated zone itself.
1018    ///
1019    /// Example: When delegating `sub.example.com` with nameserver `ns1.sub.example.com`,
1020    /// you must provide the IP address of `ns1.sub.example.com` as a glue record.
1021    ///
1022    /// Format: `{"ns1.example.com.": "192.0.2.1", "ns2.example.com.": "192.0.2.2"}`
1023    ///
1024    /// Note: Nameserver hostnames should end with a dot (.) for FQDN.
1025    ///
1026    /// **Migration to `nameServers`:**
1027    /// ```yaml
1028    /// # Old (deprecated):
1029    /// nameServerIps:
1030    ///   ns2.example.com.: "192.0.2.2"
1031    ///
1032    /// # New (recommended):
1033    /// nameServers:
1034    ///   - hostname: ns2.example.com.
1035    ///     ipv4Address: "192.0.2.2"
1036    /// ```
1037    #[deprecated(
1038        since = "0.4.0",
1039        note = "Use `name_servers` instead. This field will be removed in v1.0.0. See migration guide at docs/src/operations/migration-guide.md"
1040    )]
1041    #[serde(default, skip_serializing_if = "Option::is_none")]
1042    pub name_server_ips: Option<HashMap<String, String>>,
1043
1044    /// Sources for DNS records to include in this zone.
1045    ///
1046    /// This field defines label selectors that automatically associate DNS records with this zone.
1047    /// Records with matching labels will be included in the zone's DNS configuration.
1048    ///
1049    /// This follows the standard Kubernetes selector pattern used by Services, `NetworkPolicies`,
1050    /// and other resources for declarative resource association.
1051    ///
1052    /// # Example: Match podinfo records in dev/staging environments
1053    ///
1054    /// ```yaml
1055    /// recordsFrom:
1056    ///   - selector:
1057    ///       matchLabels:
1058    ///         app: podinfo
1059    ///       matchExpressions:
1060    ///         - key: environment
1061    ///           operator: In
1062    ///           values:
1063    ///             - dev
1064    ///             - staging
1065    /// ```
1066    ///
1067    /// # Selector Operators
1068    ///
1069    /// - **In**: Label value must be in the specified values list
1070    /// - **`NotIn`**: Label value must NOT be in the specified values list
1071    /// - **Exists**: Label key must exist (any value)
1072    /// - **`DoesNotExist`**: Label key must NOT exist
1073    ///
1074    /// # Use Cases
1075    ///
1076    /// - **Multi-environment zones**: Dynamically include records based on environment labels
1077    /// - **Application-specific zones**: Group all records for an application using `app` label
1078    /// - **Team-based zones**: Use team labels to automatically route records to team-owned zones
1079    /// - **Temporary records**: Use labels to include/exclude records without changing `zoneRef`
1080    #[serde(default, skip_serializing_if = "Option::is_none")]
1081    pub records_from: Option<Vec<RecordSource>>,
1082
1083    /// Select `Bind9Instance` resources to target for zone configuration using label selectors.
1084    ///
1085    /// This field enables dynamic, label-based selection of DNS instances to serve this zone.
1086    /// Instances matching these selectors will automatically receive zone configuration from
1087    /// the `DNSZone` controller.
1088    ///
1089    /// This follows the standard Kubernetes selector pattern used by Services, `NetworkPolicies`,
1090    /// and other resources for declarative resource association.
1091    ///
1092    /// **IMPORTANT**: This is the **preferred** method for zone-instance association. It provides:
1093    /// - **Decoupled Architecture**: Zones select instances, not vice versa
1094    /// - **Zone Ownership**: Zone authors control which instances serve their zones
1095    /// - **Dynamic Scaling**: New instances matching labels automatically pick up zones
1096    /// - **Multi-Tenancy**: Zones can target specific instance groups (prod, staging, team-specific)
1097    ///
1098    /// # Example: Target production primary instances
1099    ///
1100    /// ```yaml
1101    /// apiVersion: bindy.firestoned.io/v1beta1
1102    /// kind: DNSZone
1103    /// metadata:
1104    ///   name: example-com
1105    ///   namespace: bindy-system
1106    /// spec:
1107    ///   zoneName: example.com
1108    ///   bind9InstancesFrom:
1109    ///     - selector:
1110    ///         matchLabels:
1111    ///           environment: production
1112    ///           bindy.firestoned.io/role: primary
1113    /// ```
1114    ///
1115    /// # Example: Target instances by region and tier
1116    ///
1117    /// ```yaml
1118    /// bind9InstancesFrom:
1119    ///   - selector:
1120    ///       matchLabels:
1121    ///         tier: frontend
1122    ///       atchExpressions:
1123    ///         - key: region
1124    ///           operator: In
1125    ///           values:
1126    ///             - us-east-1
1127    ///             - us-west-2
1128    /// ```
1129    ///
1130    /// # Selector Operators
1131    ///
1132    /// - **In**: Label value must be in the specified values list
1133    /// - **`NotIn`**: Label value must NOT be in the specified values list
1134    /// - **Exists**: Label key must exist (any value)
1135    /// - **`DoesNotExist`**: Label key must NOT exist
1136    ///
1137    /// # Use Cases
1138    ///
1139    /// - **Environment Isolation**: Target only production instances (`environment: production`)
1140    /// - **Role-Based Selection**: Select only primary or secondary instances
1141    /// - **Geographic Distribution**: Target instances in specific regions
1142    /// - **Team Boundaries**: Select instances managed by specific teams
1143    /// - **Testing Zones**: Target staging instances for non-production zones
1144    ///
1145    /// # Relationship with `clusterRef`
1146    ///
1147    /// - **`clusterRef`**: Explicitly assigns zone to ALL instances in a cluster
1148    /// - **`bind9InstancesFrom`**: Dynamically selects specific instances using labels (more flexible)
1149    ///
1150    /// You can use both approaches together - the zone will target the **union** of:
1151    /// - All instances in `clusterRef` cluster
1152    /// - Plus any additional instances matching `bind9InstancesFrom` selectors
1153    ///
1154    /// # Event-Driven Architecture
1155    ///
1156    /// The `DNSZone` controller watches both `DNSZone` and `Bind9Instance` resources.
1157    /// When labels change on either:
1158    /// 1. Controller re-evaluates label selector matching
1159    /// 2. Automatically configures zones on newly-matched instances
1160    /// 3. Removes zone configuration from instances that no longer match
1161    #[serde(default, skip_serializing_if = "Option::is_none")]
1162    pub bind9_instances_from: Option<Vec<InstanceSource>>,
1163
1164    /// Override DNSSEC policy for this zone
1165    ///
1166    /// Allows per-zone override of the cluster's global DNSSEC signing policy.
1167    /// If not specified, the zone inherits the DNSSEC configuration from the
1168    /// cluster's `global.dnssec.signing.policy`.
1169    ///
1170    /// Use this to:
1171    /// - Disable signing for specific zones in a signing-enabled cluster
1172    /// - Use stricter security policies for sensitive zones
1173    /// - Test different signing algorithms on specific zones
1174    ///
1175    /// # Example: Custom High-Security Policy
1176    ///
1177    /// ```yaml
1178    /// apiVersion: bindy.firestoned.io/v1beta1
1179    /// kind: DNSZone
1180    /// metadata:
1181    ///   name: secure-zone
1182    /// spec:
1183    ///   zoneName: secure.example.com
1184    ///   clusterRef: production-dns
1185    ///   dnssecPolicy: "high-security"  # Override cluster default
1186    /// ```
1187    ///
1188    /// # Example: Disable Signing for One Zone
1189    ///
1190    /// ```yaml
1191    /// dnssecPolicy: "none"  # Disable signing (cluster has signing enabled)
1192    /// ```
1193    ///
1194    /// **Note**: Custom policies require BIND9 `dnssec-policy` configuration.
1195    /// Built-in policies: `"default"`, `"none"`
1196    #[serde(default, skip_serializing_if = "Option::is_none")]
1197    pub dnssec_policy: Option<String>,
1198}
1199
1200/// `ARecord` maps a DNS name to an IPv4 address.
1201///
1202/// A records are the most common DNS record type, mapping hostnames to IPv4 addresses.
1203/// Multiple A records can exist for the same name (round-robin DNS).
1204///
1205/// # Example
1206///
1207/// ```yaml
1208/// apiVersion: bindy.firestoned.io/v1beta1
1209/// kind: ARecord
1210/// metadata:
1211///   name: www-example-com
1212///   namespace: bindy-system
1213///   labels:
1214///     zone: example.com
1215/// spec:
1216///   name: www
1217///   ipv4Address: 192.0.2.1
1218///   ttl: 300
1219/// ```
1220///
1221/// Records are associated with `DNSZones` via label selectors.
1222/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1223#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1224#[kube(
1225    group = "bindy.firestoned.io",
1226    version = "v1beta1",
1227    kind = "ARecord",
1228    namespaced,
1229    shortname = "a",
1230    doc = "ARecord maps a DNS hostname to an IPv4 address. Multiple A records for the same name enable round-robin DNS load balancing.",
1231    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1232    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1233    printcolumn = r#"{"name":"Addresses","type":"string","jsonPath":".status.addresses"}"#,
1234    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1235    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1236)]
1237#[kube(status = "RecordStatus")]
1238#[serde(rename_all = "camelCase")]
1239pub struct ARecordSpec {
1240    /// Record name within the zone. Use "@" for the zone apex.
1241    ///
1242    /// Examples: "www", "mail", "ftp", "@"
1243    /// The full DNS name will be: {name}.{zone}
1244    pub name: String,
1245
1246    /// List of IPv4 addresses for this DNS record.
1247    ///
1248    /// Multiple addresses create round-robin DNS (load balancing).
1249    /// All addresses in the list belong to the same DNS name.
1250    ///
1251    /// Must contain at least one valid IPv4 address in dotted-decimal notation.
1252    ///
1253    /// Examples: `["192.0.2.1"]`, `["192.0.2.1", "192.0.2.2", "192.0.2.3"]`
1254    #[schemars(length(min = 1))]
1255    pub ipv4_addresses: Vec<String>,
1256
1257    /// Time To Live in seconds. Overrides zone default TTL if specified.
1258    ///
1259    /// Typical values: 60-86400 (1 minute to 1 day).
1260    #[serde(default)]
1261    #[schemars(range(min = 0, max = 2_147_483_647))]
1262    pub ttl: Option<i32>,
1263}
1264
1265/// `AAAARecord` maps a DNS name to an IPv6 address.
1266///
1267/// AAAA records are the IPv6 equivalent of A records, mapping hostnames to IPv6 addresses.
1268///
1269/// # Example
1270///
1271/// ```yaml
1272/// apiVersion: bindy.firestoned.io/v1beta1
1273/// kind: AAAARecord
1274/// metadata:
1275///   name: www-example-com-ipv6
1276///   namespace: bindy-system
1277///   labels:
1278///     zone: example.com
1279/// spec:
1280///   name: www
1281///   ipv6Address: "2001:db8::1"
1282///   ttl: 300
1283/// ```
1284///
1285/// Records are associated with `DNSZones` via label selectors.
1286/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1287#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1288#[kube(
1289    group = "bindy.firestoned.io",
1290    version = "v1beta1",
1291    kind = "AAAARecord",
1292    namespaced,
1293    shortname = "aaaa",
1294    doc = "AAAARecord maps a DNS hostname to an IPv6 address. This is the IPv6 equivalent of an A record.",
1295    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1296    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1297    printcolumn = r#"{"name":"Addresses","type":"string","jsonPath":".status.addresses"}"#,
1298    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1299    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1300)]
1301#[kube(status = "RecordStatus")]
1302#[serde(rename_all = "camelCase")]
1303pub struct AAAARecordSpec {
1304    /// Record name within the zone.
1305    pub name: String,
1306
1307    /// List of IPv6 addresses for this DNS record.
1308    ///
1309    /// Multiple addresses create round-robin DNS (load balancing).
1310    /// All addresses in the list belong to the same DNS name.
1311    ///
1312    /// Must contain at least one valid IPv6 address in standard notation.
1313    ///
1314    /// Examples: `["2001:db8::1"]`, `["2001:db8::1", "2001:db8::2"]`
1315    #[schemars(length(min = 1))]
1316    pub ipv6_addresses: Vec<String>,
1317
1318    /// Time To Live in seconds.
1319    #[serde(default)]
1320    #[schemars(range(min = 0, max = 2_147_483_647))]
1321    pub ttl: Option<i32>,
1322}
1323
1324/// `TXTRecord` holds arbitrary text data.
1325///
1326/// TXT records are commonly used for SPF, DKIM, DMARC, domain verification,
1327/// and other text-based metadata.
1328///
1329/// # Example
1330///
1331/// ```yaml
1332/// apiVersion: bindy.firestoned.io/v1beta1
1333/// kind: TXTRecord
1334/// metadata:
1335///   name: spf-example-com
1336///   namespace: bindy-system
1337///   labels:
1338///     zone: example.com
1339/// spec:
1340///   name: "@"
1341///   text:
1342///     - "v=spf1 include:_spf.google.com ~all"
1343///   ttl: 3600
1344/// ```
1345///
1346/// Records are associated with `DNSZones` via label selectors.
1347/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1348#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1349#[kube(
1350    group = "bindy.firestoned.io",
1351    version = "v1beta1",
1352    kind = "TXTRecord",
1353    namespaced,
1354    shortname = "txt",
1355    doc = "TXTRecord stores arbitrary text data in DNS. Commonly used for SPF, DKIM, DMARC policies, and domain verification.",
1356    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1357    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1358    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1359    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1360)]
1361#[kube(status = "RecordStatus")]
1362#[serde(rename_all = "camelCase")]
1363pub struct TXTRecordSpec {
1364    /// Record name within the zone.
1365    pub name: String,
1366
1367    /// Array of text strings. Each string can be up to 255 characters.
1368    ///
1369    /// Multiple strings are concatenated by DNS resolvers.
1370    /// For long text, split into multiple strings.
1371    pub text: Vec<String>,
1372
1373    /// Time To Live in seconds.
1374    #[serde(default)]
1375    #[schemars(range(min = 0, max = 2_147_483_647))]
1376    pub ttl: Option<i32>,
1377}
1378
1379/// `CNAMERecord` creates an alias from one name to another.
1380///
1381/// CNAME (Canonical Name) records create an alias from one DNS name to another.
1382/// The target can be in the same zone or a different zone.
1383///
1384/// **Important**: A CNAME cannot coexist with other record types for the same name.
1385///
1386/// # Example
1387///
1388/// ```yaml
1389/// apiVersion: bindy.firestoned.io/v1beta1
1390/// kind: CNAMERecord
1391/// metadata:
1392///   name: blog-example-com
1393///   namespace: bindy-system
1394///   labels:
1395///     zone: example.com
1396/// spec:
1397///   name: blog
1398///   target: example.github.io.
1399///   ttl: 3600
1400/// ```
1401///
1402/// Records are associated with `DNSZones` via label selectors.
1403/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1404#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1405#[kube(
1406    group = "bindy.firestoned.io",
1407    version = "v1beta1",
1408    kind = "CNAMERecord",
1409    namespaced,
1410    shortname = "cname",
1411    doc = "CNAMERecord creates a DNS alias from one hostname to another. A CNAME cannot coexist with other record types for the same name.",
1412    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1413    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1414    printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.target"}"#,
1415    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1416    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1417)]
1418#[kube(status = "RecordStatus")]
1419#[serde(rename_all = "camelCase")]
1420pub struct CNAMERecordSpec {
1421    /// Record name within the zone.
1422    ///
1423    /// Note: CNAME records cannot be created at the zone apex (@).
1424    pub name: String,
1425
1426    /// Target hostname (canonical name).
1427    ///
1428    /// Should be a fully qualified domain name ending with a dot.
1429    /// Example: "example.com." or "www.example.com."
1430    pub target: String,
1431
1432    /// Time To Live in seconds.
1433    #[serde(default)]
1434    #[schemars(range(min = 0, max = 2_147_483_647))]
1435    pub ttl: Option<i32>,
1436}
1437
1438/// `MXRecord` specifies mail servers for a domain.
1439///
1440/// MX (Mail Exchange) records specify the mail servers responsible for accepting email
1441/// for a domain. Lower priority values indicate higher preference.
1442///
1443/// # Example
1444///
1445/// ```yaml
1446/// apiVersion: bindy.firestoned.io/v1beta1
1447/// kind: MXRecord
1448/// metadata:
1449///   name: mail-example-com
1450///   namespace: bindy-system
1451///   labels:
1452///     zone: example.com
1453/// spec:
1454///   name: "@"
1455///   priority: 10
1456///   mailServer: mail.example.com.
1457///   ttl: 3600
1458/// ```
1459///
1460/// Records are associated with `DNSZones` via label selectors.
1461/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1462#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1463#[kube(
1464    group = "bindy.firestoned.io",
1465    version = "v1beta1",
1466    kind = "MXRecord",
1467    namespaced,
1468    shortname = "mx",
1469    doc = "MXRecord specifies mail exchange servers for a domain. Lower priority values indicate higher preference for mail delivery.",
1470    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1471    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1472    printcolumn = r#"{"name":"Priority","type":"integer","jsonPath":".spec.priority"}"#,
1473    printcolumn = r#"{"name":"Mail Server","type":"string","jsonPath":".spec.mailServer"}"#,
1474    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1475    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1476)]
1477#[kube(status = "RecordStatus")]
1478#[serde(rename_all = "camelCase")]
1479pub struct MXRecordSpec {
1480    /// Record name within the zone. Use "@" for the zone apex.
1481    pub name: String,
1482
1483    /// Priority (preference) of this mail server. Lower values = higher priority.
1484    ///
1485    /// Common values: 0-100. Multiple MX records can exist with different priorities.
1486    #[schemars(range(min = 0, max = 65535))]
1487    pub priority: i32,
1488
1489    /// Fully qualified domain name of the mail server.
1490    ///
1491    /// Must end with a dot. Example: "mail.example.com."
1492    pub mail_server: String,
1493
1494    /// Time To Live in seconds.
1495    #[serde(default)]
1496    #[schemars(range(min = 0, max = 2_147_483_647))]
1497    pub ttl: Option<i32>,
1498}
1499
1500/// `NSRecord` delegates a subdomain to other nameservers.
1501///
1502/// NS (Nameserver) records specify which DNS servers are authoritative for a subdomain.
1503/// They are used for delegating subdomains to different nameservers.
1504///
1505/// # Example
1506///
1507/// ```yaml
1508/// apiVersion: bindy.firestoned.io/v1beta1
1509/// kind: NSRecord
1510/// metadata:
1511///   name: subdomain-ns
1512///   namespace: bindy-system
1513///   labels:
1514///     zone: example.com
1515/// spec:
1516///   name: subdomain
1517///   nameserver: ns1.other-provider.com.
1518///   ttl: 86400
1519/// ```
1520///
1521/// Records are associated with `DNSZones` via label selectors.
1522/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1523#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1524#[kube(
1525    group = "bindy.firestoned.io",
1526    version = "v1beta1",
1527    kind = "NSRecord",
1528    namespaced,
1529    shortname = "ns",
1530    doc = "NSRecord delegates a subdomain to authoritative nameservers. Used for subdomain delegation to different DNS providers or servers.",
1531    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1532    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1533    printcolumn = r#"{"name":"Nameserver","type":"string","jsonPath":".spec.nameserver"}"#,
1534    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1535    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1536)]
1537#[kube(status = "RecordStatus")]
1538#[serde(rename_all = "camelCase")]
1539pub struct NSRecordSpec {
1540    /// Subdomain to delegate. For zone apex, use "@".
1541    pub name: String,
1542
1543    /// Fully qualified domain name of the nameserver.
1544    ///
1545    /// Must end with a dot. Example: "ns1.example.com."
1546    pub nameserver: String,
1547
1548    /// Time To Live in seconds.
1549    #[serde(default)]
1550    #[schemars(range(min = 0, max = 2_147_483_647))]
1551    pub ttl: Option<i32>,
1552}
1553
1554/// `SRVRecord` specifies the location of services.
1555///
1556/// SRV (Service) records specify the hostname and port of servers for specific services.
1557/// The name format is: _service._proto (e.g., _ldap._tcp, _sip._udp).
1558///
1559/// # Example
1560///
1561/// ```yaml
1562/// apiVersion: bindy.firestoned.io/v1beta1
1563/// kind: SRVRecord
1564/// metadata:
1565///   name: ldap-srv
1566///   namespace: bindy-system
1567///   labels:
1568///     zone: example.com
1569/// spec:
1570///   name: _ldap._tcp
1571///   priority: 10
1572///   weight: 60
1573///   port: 389
1574///   target: ldap.example.com.
1575///   ttl: 3600
1576/// ```
1577///
1578/// Records are associated with `DNSZones` via label selectors.
1579/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1580#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1581#[kube(
1582    group = "bindy.firestoned.io",
1583    version = "v1beta1",
1584    kind = "SRVRecord",
1585    namespaced,
1586    shortname = "srv",
1587    doc = "SRVRecord specifies the hostname and port of servers for specific services. The record name follows the format _service._proto (e.g., _ldap._tcp).",
1588    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1589    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1590    printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.target"}"#,
1591    printcolumn = r#"{"name":"Port","type":"integer","jsonPath":".spec.port"}"#,
1592    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1593    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1594)]
1595#[kube(status = "RecordStatus")]
1596#[serde(rename_all = "camelCase")]
1597pub struct SRVRecordSpec {
1598    /// Service and protocol in the format: _service._proto
1599    ///
1600    /// Example: "_ldap._tcp", "_sip._udp", "_http._tcp"
1601    pub name: String,
1602
1603    /// Priority of the target host. Lower values = higher priority.
1604    #[schemars(range(min = 0, max = 65535))]
1605    pub priority: i32,
1606
1607    /// Relative weight for records with the same priority.
1608    ///
1609    /// Higher values = higher probability of selection.
1610    #[schemars(range(min = 0, max = 65535))]
1611    pub weight: i32,
1612
1613    /// TCP or UDP port where the service is available.
1614    #[schemars(range(min = 0, max = 65535))]
1615    pub port: i32,
1616
1617    /// Fully qualified domain name of the target host.
1618    ///
1619    /// Must end with a dot. Use "." for "service not available".
1620    pub target: String,
1621
1622    /// Time To Live in seconds.
1623    #[serde(default)]
1624    #[schemars(range(min = 0, max = 2_147_483_647))]
1625    pub ttl: Option<i32>,
1626}
1627
1628/// `CAARecord` specifies Certificate Authority Authorization.
1629///
1630/// CAA (Certification Authority Authorization) records specify which certificate
1631/// authorities are allowed to issue certificates for a domain.
1632///
1633/// # Example
1634///
1635/// ```yaml
1636/// apiVersion: bindy.firestoned.io/v1beta1
1637/// kind: CAARecord
1638/// metadata:
1639///   name: caa-letsencrypt
1640///   namespace: bindy-system
1641///   labels:
1642///     zone: example.com
1643/// spec:
1644///   name: "@"
1645///   flags: 0
1646///   tag: issue
1647///   value: letsencrypt.org
1648///   ttl: 86400
1649/// ```
1650///
1651/// Records are associated with `DNSZones` via label selectors.
1652/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1653#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1654#[kube(
1655    group = "bindy.firestoned.io",
1656    version = "v1beta1",
1657    kind = "CAARecord",
1658    namespaced,
1659    shortname = "caa",
1660    doc = "CAARecord specifies which certificate authorities are authorized to issue certificates for a domain. Enhances domain security and certificate issuance control.",
1661    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1662    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1663    printcolumn = r#"{"name":"Tag","type":"string","jsonPath":".spec.tag"}"#,
1664    printcolumn = r#"{"name":"Value","type":"string","jsonPath":".spec.value"}"#,
1665    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1666    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1667)]
1668#[kube(status = "RecordStatus")]
1669#[serde(rename_all = "camelCase")]
1670pub struct CAARecordSpec {
1671    /// Record name within the zone. Use "@" for the zone apex.
1672    pub name: String,
1673
1674    /// Flags byte. Use 0 for non-critical, 128 for critical.
1675    ///
1676    /// Critical flag (128) means CAs must understand the tag.
1677    #[schemars(range(min = 0, max = 255))]
1678    pub flags: i32,
1679
1680    /// Property tag. Common values: "issue", "issuewild", "iodef".
1681    ///
1682    /// - "issue": Authorize CA to issue certificates
1683    /// - "issuewild": Authorize CA to issue wildcard certificates
1684    /// - "iodef": URL/email for violation reports
1685    pub tag: String,
1686
1687    /// Property value. Format depends on the tag.
1688    ///
1689    /// For "issue"/"issuewild": CA domain (e.g., "letsencrypt.org")
1690    /// For "iodef": mailto: or https: URL
1691    pub value: String,
1692
1693    /// Time To Live in seconds.
1694    #[serde(default)]
1695    #[schemars(range(min = 0, max = 2_147_483_647))]
1696    pub ttl: Option<i32>,
1697}
1698
1699/// Generic record status
1700#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
1701#[serde(rename_all = "camelCase")]
1702pub struct RecordStatus {
1703    #[serde(default)]
1704    pub conditions: Vec<Condition>,
1705    #[serde(skip_serializing_if = "Option::is_none")]
1706    pub observed_generation: Option<i64>,
1707    /// The FQDN of the zone that owns this record (set by `DNSZone` controller).
1708    ///
1709    /// When a `DNSZone`'s label selector matches this record, the `DNSZone` controller
1710    /// sets this field to the zone's FQDN (e.g., `"example.com"`). The record reconciler
1711    /// uses this to determine which zone to update in BIND9.
1712    ///
1713    /// If this field is empty, the record is not matched by any zone and should not
1714    /// be reconciled into BIND9.
1715    ///
1716    /// **DEPRECATED**: Use `zone_ref` instead for structured zone reference.
1717    #[deprecated(
1718        since = "0.2.0",
1719        note = "Use zone_ref instead for structured zone reference"
1720    )]
1721    #[serde(skip_serializing_if = "Option::is_none")]
1722    pub zone: Option<String>,
1723    /// Structured reference to the `DNSZone` that owns this record.
1724    ///
1725    /// Set by the `DNSZone` controller when the zone's `recordsFrom` selector matches
1726    /// this record's labels. Contains the complete Kubernetes object reference including
1727    /// apiVersion, kind, name, namespace, and zoneName.
1728    ///
1729    /// The record reconciler uses this to:
1730    /// 1. Look up the parent `DNSZone` resource
1731    /// 2. Find the zone's primary `Bind9Instance` servers
1732    /// 3. Add this record to BIND9 on primaries
1733    /// 4. Trigger zone transfer (retransfer) on secondaries
1734    ///
1735    /// If this field is None, the record is not selected by any zone and will not
1736    /// be added to BIND9.
1737    #[serde(skip_serializing_if = "Option::is_none")]
1738    pub zone_ref: Option<ZoneReference>,
1739    /// SHA-256 hash of the record's spec data.
1740    ///
1741    /// Used to detect when a record's data has actually changed, avoiding
1742    /// unnecessary BIND9 updates and zone transfers.
1743    ///
1744    /// The hash is calculated from all fields in the record's spec that affect
1745    /// the DNS record data (name, addresses, TTL, etc.).
1746    #[serde(skip_serializing_if = "Option::is_none")]
1747    pub record_hash: Option<String>,
1748    /// Timestamp of the last successful update to BIND9.
1749    ///
1750    /// This is updated after a successful nsupdate operation.
1751    /// Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z").
1752    #[serde(skip_serializing_if = "Option::is_none")]
1753    pub last_updated: Option<String>,
1754    /// Comma-separated list of addresses for display purposes.
1755    ///
1756    /// For `ARecord` and `AAAARecord` resources, this field contains the IP addresses
1757    /// from `spec.ipv4Addresses` or `spec.ipv6Addresses` joined with commas.
1758    /// This is used for prettier kubectl output instead of showing JSON arrays.
1759    ///
1760    /// Example: "192.0.2.1,192.0.2.2,192.0.2.3"
1761    ///
1762    /// For other record types, this field is not used.
1763    #[serde(skip_serializing_if = "Option::is_none")]
1764    pub addresses: Option<String>,
1765}
1766
1767/// RNDC/TSIG algorithm for authenticated communication and zone transfers.
1768///
1769/// These HMAC algorithms are supported by BIND9 for securing RNDC communication
1770/// and zone transfers (AXFR/IXFR).
1771///
1772/// HMAC-MD5 was intentionally removed: RFC 8945 §10 deprecates it and it is
1773/// cryptographically broken. CRDs specifying `hmac-md5` now fail CRD
1774/// validation rather than produce a weakly authenticated operator.
1775#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1776#[serde(rename_all = "kebab-case")]
1777pub enum RndcAlgorithm {
1778    /// HMAC-SHA1
1779    HmacSha1,
1780    /// HMAC-SHA224
1781    HmacSha224,
1782    /// HMAC-SHA256 (recommended)
1783    #[default]
1784    HmacSha256,
1785    /// HMAC-SHA384
1786    HmacSha384,
1787    /// HMAC-SHA512
1788    HmacSha512,
1789}
1790
1791impl RndcAlgorithm {
1792    /// Convert enum to string representation expected by BIND9
1793    #[must_use]
1794    pub fn as_str(&self) -> &'static str {
1795        match self {
1796            Self::HmacSha1 => "hmac-sha1",
1797            Self::HmacSha224 => "hmac-sha224",
1798            Self::HmacSha256 => "hmac-sha256",
1799            Self::HmacSha384 => "hmac-sha384",
1800            Self::HmacSha512 => "hmac-sha512",
1801        }
1802    }
1803
1804    /// Convert enum to string format expected by the rndc Rust crate.
1805    ///
1806    /// The rndc crate expects algorithm strings without the "hmac-" prefix
1807    /// (e.g., "sha256" instead of "hmac-sha256").
1808    #[must_use]
1809    pub fn as_rndc_str(&self) -> &'static str {
1810        match self {
1811            Self::HmacSha1 => "sha1",
1812            Self::HmacSha224 => "sha224",
1813            Self::HmacSha256 => "sha256",
1814            Self::HmacSha384 => "sha384",
1815            Self::HmacSha512 => "sha512",
1816        }
1817    }
1818}
1819
1820/// Reference to a Kubernetes Secret containing RNDC/TSIG credentials.
1821///
1822/// This allows you to use an existing external Secret for RNDC authentication instead
1823/// of having the operator auto-generate one. The Secret is mounted as a directory at
1824/// `/etc/bind/keys/` in the BIND9 container, and BIND9 uses the `rndc.key` file.
1825///
1826/// # External (User-Managed) Secrets
1827///
1828/// For external secrets, you ONLY need to provide the `rndc.key` field containing
1829/// the complete BIND9 key file content. The other fields (`key-name`, `algorithm`,
1830/// `secret`) are optional metadata used by operator-generated secrets.
1831///
1832/// ## Minimal External Secret Example
1833///
1834/// ```yaml
1835/// apiVersion: v1
1836/// kind: Secret
1837/// metadata:
1838///   name: my-rndc-key
1839///   namespace: bindy-system
1840/// type: Opaque
1841/// stringData:
1842///   rndc.key: |
1843///     key "bindy-operator" {
1844///         algorithm hmac-sha256;
1845///         secret "base64EncodedSecretKeyMaterial==";
1846///     };
1847/// ```
1848///
1849/// # Auto-Generated (Operator-Managed) Secrets
1850///
1851/// When the operator auto-generates a Secret (no `rndcSecretRef` specified), it
1852/// creates a Secret with all 4 fields for internal metadata tracking:
1853///
1854/// ```yaml
1855/// apiVersion: v1
1856/// kind: Secret
1857/// metadata:
1858///   name: bind9-instance-rndc
1859///   namespace: bindy-system
1860/// type: Opaque
1861/// stringData:
1862///   key-name: "bindy-operator"     # Operator metadata
1863///   algorithm: "hmac-sha256"       # Operator metadata
1864///   secret: "randomBase64Key=="    # Operator metadata
1865///   rndc.key: |                    # Used by BIND9
1866///     key "bindy-operator" {
1867///         algorithm hmac-sha256;
1868///         secret "randomBase64Key==";
1869///     };
1870/// ```
1871///
1872/// # Using with `Bind9Instance`
1873///
1874/// ```yaml
1875/// apiVersion: bindy.firestoned.io/v1beta1
1876/// kind: Bind9Instance
1877/// metadata:
1878///   name: production-dns-primary-0
1879/// spec:
1880///   clusterRef: production-dns
1881///   role: primary
1882///   rndcSecretRef:
1883///     name: my-rndc-key
1884///     algorithm: hmac-sha256
1885/// ```
1886///
1887/// # How It Works
1888///
1889/// When the Secret is mounted at `/etc/bind/keys/`, Kubernetes creates individual
1890/// files for each Secret key:
1891/// - `/etc/bind/keys/rndc.key` (the BIND9 key file) ← **This is what BIND9 uses**
1892/// - `/etc/bind/keys/key-name` (optional metadata for operator-generated secrets)
1893/// - `/etc/bind/keys/algorithm` (optional metadata for operator-generated secrets)
1894/// - `/etc/bind/keys/secret` (optional metadata for operator-generated secrets)
1895///
1896/// The `rndc.conf` file includes `/etc/bind/keys/rndc.key`, so BIND9 only needs
1897/// that one file to exist
1898#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1899#[serde(rename_all = "camelCase")]
1900pub struct RndcSecretRef {
1901    /// Name of the Kubernetes Secret containing RNDC credentials
1902    pub name: String,
1903
1904    /// HMAC algorithm for this key
1905    #[serde(default)]
1906    pub algorithm: RndcAlgorithm,
1907
1908    /// Key within the secret for the key name (default: "key-name")
1909    #[serde(default = "default_key_name_key")]
1910    pub key_name_key: String,
1911
1912    /// Key within the secret for the secret value (default: "secret")
1913    #[serde(default = "default_secret_key")]
1914    pub secret_key: String,
1915}
1916
1917fn default_key_name_key() -> String {
1918    "key-name".to_string()
1919}
1920
1921fn default_secret_key() -> String {
1922    "secret".to_string()
1923}
1924
1925fn default_rotate_after() -> String {
1926    crate::constants::DEFAULT_ROTATION_INTERVAL.to_string()
1927}
1928
1929fn default_secret_type() -> String {
1930    "Opaque".to_string()
1931}
1932
1933/// RNDC key lifecycle configuration with automatic rotation support.
1934///
1935/// Provides three configuration modes:
1936/// 1. **Auto-generated with optional rotation** (default) - Operator creates and manages keys
1937/// 2. **Reference to existing Secret** - Use pre-existing Kubernetes Secret (no rotation)
1938/// 3. **Inline Secret specification** - Define Secret inline with optional rotation
1939///
1940/// When `auto_rotate` is enabled, the operator automatically rotates keys after the
1941/// `rotate_after` duration has elapsed. Rotation timestamps are tracked in Secret annotations.
1942///
1943/// # Examples
1944///
1945/// ```yaml
1946/// # Auto-generated with 30-day rotation
1947/// rndcKeys:
1948///   autoRotate: true
1949///   rotateAfter: 720h
1950///   algorithm: hmac-sha256
1951///
1952/// # Reference existing Secret (no rotation)
1953/// rndcKeys:
1954///   secretRef:
1955///     name: my-rndc-key
1956///     algorithm: hmac-sha256
1957///
1958/// # Inline Secret with rotation
1959/// rndcKeys:
1960///   autoRotate: true
1961///   rotateAfter: 2160h  # 90 days
1962///   secret:
1963///     metadata:
1964///       name: custom-rndc-key
1965///       labels:
1966///         app: bindy
1967/// ```
1968#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1969#[serde(rename_all = "camelCase")]
1970pub struct RndcKeyConfig {
1971    /// Enable automatic key rotation (default: false for backward compatibility).
1972    ///
1973    /// When `true`, the operator automatically rotates the RNDC key after the
1974    /// `rotate_after` interval. When `false`, keys are generated once and never rotated.
1975    ///
1976    /// **Important**: Rotation only applies to operator-managed Secrets. If you
1977    /// specify `secret_ref`, that Secret will NOT be rotated automatically.
1978    ///
1979    /// Default: `false`
1980    #[serde(default)]
1981    pub auto_rotate: bool,
1982
1983    /// Duration after which to rotate the key (Go duration format: "720h", "30d").
1984    ///
1985    /// Supported units:
1986    /// - `h` (hours): "720h" = 30 days
1987    /// - `d` (days): "30d" = 30 days
1988    /// - `w` (weeks): "4w" = 28 days
1989    ///
1990    /// Constraints:
1991    /// - Minimum: 1h (1 hour)
1992    /// - Maximum: 8760h (365 days / 1 year)
1993    ///
1994    /// Only applies when `auto_rotate` is `true`.
1995    ///
1996    /// Default: `"720h"` (30 days)
1997    #[serde(default = "default_rotate_after")]
1998    pub rotate_after: String,
1999
2000    /// Reference to an existing Kubernetes Secret containing RNDC credentials.
2001    ///
2002    /// When specified, the operator uses this existing Secret instead of auto-generating
2003    /// one. The Secret must contain the `rndc.key` field with BIND9 key file content.
2004    ///
2005    /// **Mutually exclusive with `secret`** - if both are specified, `secret_ref` takes
2006    /// precedence and `secret` is ignored.
2007    ///
2008    /// **Rotation note**: User-managed Secrets (via `secret_ref`) are NOT automatically
2009    /// rotated even if `auto_rotate` is `true`. You must rotate these manually.
2010    ///
2011    /// Default: `None` (auto-generate key)
2012    #[serde(skip_serializing_if = "Option::is_none")]
2013    pub secret_ref: Option<RndcSecretRef>,
2014
2015    /// Inline Secret specification for operator-managed Secret with optional rotation.
2016    ///
2017    /// Embeds a full Kubernetes Secret specification. The operator will create and
2018    /// manage this Secret, and rotate it if `auto_rotate` is `true`.
2019    ///
2020    /// **Mutually exclusive with `secret_ref`** - if both are specified, `secret_ref`
2021    /// takes precedence and this field is ignored.
2022    ///
2023    /// Default: `None` (auto-generate key)
2024    #[serde(skip_serializing_if = "Option::is_none")]
2025    pub secret: Option<SecretSpec>,
2026
2027    /// HMAC algorithm for the RNDC key.
2028    ///
2029    /// Only used when auto-generating keys (when neither `secret_ref` nor `secret` are
2030    /// specified). If using `secret_ref`, the algorithm is specified in that reference.
2031    ///
2032    /// Default: `hmac-sha256`
2033    #[serde(default)]
2034    pub algorithm: RndcAlgorithm,
2035}
2036
2037/// Kubernetes Secret specification for inline Secret creation.
2038///
2039/// Used when the operator should create and manage the Secret (with optional rotation).
2040/// This is a subset of the Kubernetes Secret API focusing on fields relevant for
2041/// RNDC key management.
2042///
2043/// # Example
2044///
2045/// ```yaml
2046/// secret:
2047///   metadata:
2048///     name: my-rndc-key
2049///     labels:
2050///       app: bindy
2051///       tier: infrastructure
2052///   stringData:
2053///     rndc.key: |
2054///       key "bindy-operator" {
2055///           algorithm hmac-sha256;
2056///           secret "dGVzdHNlY3JldA==";
2057///       };
2058/// ```
2059#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2060#[serde(rename_all = "camelCase")]
2061pub struct SecretSpec {
2062    /// Secret metadata (name, labels, annotations).
2063    ///
2064    /// **Required**: You must specify `metadata.name` for the Secret name.
2065    pub metadata: SecretMetadata,
2066
2067    /// Secret type (default: "Opaque").
2068    ///
2069    /// For RNDC keys, use the default "Opaque" type.
2070    #[serde(default = "default_secret_type")]
2071    #[serde(rename = "type")]
2072    pub type_: String,
2073
2074    /// String data (keys and values as strings).
2075    ///
2076    /// For RNDC keys, you should provide:
2077    /// - `rndc.key`: Full BIND9 key file content (required by BIND9)
2078    ///
2079    /// Optional metadata (auto-populated by operator if omitted):
2080    /// - `key-name`: Name of the TSIG key
2081    /// - `algorithm`: HMAC algorithm
2082    /// - `secret`: Base64-encoded key material
2083    ///
2084    /// Kubernetes automatically base64-encodes string data when creating the Secret.
2085    #[serde(skip_serializing_if = "Option::is_none")]
2086    pub string_data: Option<std::collections::BTreeMap<String, String>>,
2087
2088    /// Binary data (keys and values as base64 strings).
2089    ///
2090    /// Alternative to `string_data` if you want to provide already-base64-encoded values.
2091    /// Most users should use `string_data` instead for readability.
2092    #[serde(skip_serializing_if = "Option::is_none")]
2093    pub data: Option<std::collections::BTreeMap<String, String>>,
2094}
2095
2096/// Minimal Secret metadata for inline Secret specifications.
2097///
2098/// This is a subset of Kubernetes `ObjectMeta` focusing on commonly-used fields.
2099#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2100#[serde(rename_all = "camelCase")]
2101pub struct SecretMetadata {
2102    /// Secret name (required).
2103    ///
2104    /// Must be a valid Kubernetes resource name (lowercase alphanumeric, hyphens, dots).
2105    pub name: String,
2106
2107    /// Labels to apply to the Secret.
2108    ///
2109    /// Useful for organizing and selecting Secrets via label selectors.
2110    ///
2111    /// Example:
2112    /// ```yaml
2113    /// labels:
2114    ///   app: bindy
2115    ///   tier: infrastructure
2116    ///   environment: production
2117    /// ```
2118    #[serde(skip_serializing_if = "Option::is_none")]
2119    pub labels: Option<std::collections::BTreeMap<String, String>>,
2120
2121    /// Annotations to apply to the Secret.
2122    ///
2123    /// **Note**: The operator will add rotation tracking annotations:
2124    /// - `bindy.firestoned.io/rndc-created-at` - Key creation timestamp
2125    /// - `bindy.firestoned.io/rndc-rotate-at` - Next rotation timestamp
2126    /// - `bindy.firestoned.io/rndc-rotation-count` - Number of rotations
2127    ///
2128    /// Do not manually set these rotation tracking annotations.
2129    #[serde(skip_serializing_if = "Option::is_none")]
2130    pub annotations: Option<std::collections::BTreeMap<String, String>>,
2131}
2132
2133/// Default BIND9 version for clusters when not specified
2134#[allow(clippy::unnecessary_wraps)]
2135fn default_bind9_version() -> Option<String> {
2136    Some(crate::constants::DEFAULT_BIND9_VERSION.to_string())
2137}
2138
2139/// TSIG Key configuration for authenticated zone transfers (deprecated in favor of `RndcSecretRef`)
2140#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2141#[serde(rename_all = "camelCase")]
2142pub struct TSIGKey {
2143    /// Name of the TSIG key
2144    pub name: String,
2145    /// Algorithm for HMAC-based authentication
2146    pub algorithm: RndcAlgorithm,
2147    /// Secret key (base64 encoded) - should reference a Secret
2148    pub secret: String,
2149}
2150
2151/// BIND9 server configuration options
2152///
2153/// These settings configure the BIND9 DNS server behavior including recursion,
2154/// access control lists, DNSSEC, and network listeners.
2155#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2156#[serde(rename_all = "camelCase")]
2157pub struct Bind9Config {
2158    /// Enable or disable recursive DNS queries
2159    ///
2160    /// When enabled (`true`), the DNS server will recursively resolve queries by
2161    /// contacting other authoritative nameservers. When disabled (`false`), the
2162    /// server only answers for zones it is authoritative for.
2163    ///
2164    /// Default: `false` (authoritative-only mode)
2165    ///
2166    /// **Important**: Recursive resolvers should not be publicly accessible due to
2167    /// security risks (DNS amplification attacks, cache poisoning).
2168    #[serde(default)]
2169    pub recursion: Option<bool>,
2170
2171    /// Access control list for DNS queries
2172    ///
2173    /// Specifies which IP addresses or networks are allowed to query this DNS server.
2174    /// Supports CIDR notation and special keywords.
2175    ///
2176    /// Default: Not set (BIND9 defaults to localhost only)
2177    ///
2178    /// Examples:
2179    /// - `["0.0.0.0/0"]` - Allow queries from any IPv4 address
2180    /// - `["10.0.0.0/8", "172.16.0.0/12"]` - Allow queries from private networks
2181    /// - `["any"]` - Allow queries from any IP (IPv4 and IPv6)
2182    /// - `["none"]` - Deny all queries
2183    /// - `["localhost"]` - Allow only from localhost
2184    #[serde(default)]
2185    pub allow_query: Option<Vec<String>>,
2186
2187    /// Access control list for zone transfers (AXFR/IXFR)
2188    ///
2189    /// Specifies which IP addresses or networks are allowed to perform zone transfers
2190    /// from this server. Zone transfers are used for replication between primary and
2191    /// secondary DNS servers.
2192    ///
2193    /// Default: Auto-detected cluster Pod CIDRs (e.g., `["10.42.0.0/16"]`)
2194    ///
2195    /// Examples:
2196    /// - `["10.42.0.0/16"]` - Allow transfers from specific Pod network
2197    /// - `["10.0.0.0/8"]` - Allow transfers from entire private network
2198    /// - `[]` - Deny all zone transfers (empty list means "none")
2199    /// - `["any"]` - Allow transfers from any IP (not recommended for production)
2200    ///
2201    /// Can be overridden at cluster level via `spec.primary.allowTransfer` or
2202    /// `spec.secondary.allowTransfer` for role-specific ACLs.
2203    #[serde(default)]
2204    pub allow_transfer: Option<Vec<String>>,
2205
2206    /// DNSSEC (DNS Security Extensions) configuration
2207    ///
2208    /// Configures DNSSEC signing and validation. DNSSEC provides cryptographic
2209    /// authentication of DNS data to prevent spoofing and cache poisoning attacks.
2210    ///
2211    /// See `DNSSECConfig` for detailed options.
2212    #[serde(default)]
2213    pub dnssec: Option<DNSSECConfig>,
2214
2215    /// DNS forwarders for recursive resolution
2216    ///
2217    /// List of upstream DNS servers to forward queries to when recursion is enabled.
2218    /// Used for hybrid authoritative/recursive configurations.
2219    ///
2220    /// Only relevant when `recursion: true`.
2221    ///
2222    /// Examples:
2223    /// - `["8.8.8.8", "8.8.4.4"]` - Google Public DNS
2224    /// - `["1.1.1.1", "1.0.0.1"]` - Cloudflare DNS
2225    /// - `["10.0.0.53"]` - Internal corporate DNS resolver
2226    #[serde(default)]
2227    pub forwarders: Option<Vec<String>>,
2228
2229    /// IPv4 addresses to listen on for DNS queries
2230    ///
2231    /// Specifies which IPv4 interfaces and ports the DNS server should bind to.
2232    ///
2233    /// Default: All IPv4 interfaces on port 53
2234    ///
2235    /// Examples:
2236    /// - `["any"]` - Listen on all IPv4 interfaces
2237    /// - `["127.0.0.1"]` - Listen only on localhost
2238    /// - `["10.0.0.1"]` - Listen on specific IP address
2239    #[serde(default)]
2240    pub listen_on: Option<Vec<String>>,
2241
2242    /// IPv6 addresses to listen on for DNS queries
2243    ///
2244    /// Specifies which IPv6 interfaces and ports the DNS server should bind to.
2245    ///
2246    /// Default: All IPv6 interfaces on port 53 (if IPv6 is available)
2247    ///
2248    /// Examples:
2249    /// - `["any"]` - Listen on all IPv6 interfaces
2250    /// - `["::1"]` - Listen only on IPv6 localhost
2251    /// - `["none"]` - Disable IPv6 listening
2252    #[serde(default)]
2253    pub listen_on_v6: Option<Vec<String>>,
2254
2255    /// Reference to an existing Kubernetes Secret containing RNDC key.
2256    ///
2257    /// If specified at the global config level, all instances in the cluster will use
2258    /// this existing Secret instead of auto-generating individual secrets, unless
2259    /// overridden at the role (primary/secondary) or instance level.
2260    ///
2261    /// This allows centralized RNDC key management for the entire cluster.
2262    ///
2263    /// Precedence order (highest to lowest):
2264    /// 1. Instance level (`spec.rndcSecretRef`)
2265    /// 2. Role level (`spec.primary.rndcSecretRef` or `spec.secondary.rndcSecretRef`)
2266    /// 3. Global level (`spec.global.rndcSecretRef`)
2267    /// 4. Auto-generated (default)
2268    #[serde(default)]
2269    pub rndc_secret_ref: Option<RndcSecretRef>,
2270
2271    /// Bindcar RNDC API sidecar container configuration.
2272    ///
2273    /// The API container provides an HTTP interface for managing zones via rndc.
2274    /// This configuration is inherited by all instances unless overridden.
2275    #[serde(default)]
2276    pub bindcar_config: Option<BindcarConfig>,
2277}
2278
2279/// DNSSEC (DNS Security Extensions) configuration
2280///
2281/// DNSSEC adds cryptographic signatures to DNS records to ensure authenticity and integrity.
2282/// This configuration supports both DNSSEC validation (verifying signatures from upstream)
2283/// and DNSSEC signing (cryptographically signing your own zones).
2284///
2285/// # Example
2286///
2287/// ```yaml
2288/// dnssec:
2289///   validation: true  # Validate upstream DNSSEC responses
2290///   signing:
2291///     enabled: true
2292///     policy: "default"
2293///     algorithm: "ECDSAP256SHA256"
2294///     kskLifetime: "365d"
2295///     zskLifetime: "90d"
2296/// ```
2297#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2298#[serde(rename_all = "camelCase")]
2299pub struct DNSSECConfig {
2300    /// Enable DNSSEC validation of responses
2301    ///
2302    /// When enabled, BIND will validate DNSSEC signatures on responses from other
2303    /// nameservers. Invalid or missing signatures will cause queries to fail.
2304    ///
2305    /// Default: `false`
2306    ///
2307    /// **Important**: Requires valid DNSSEC trust anchors and proper network connectivity
2308    /// to root DNS servers. May cause resolution failures if DNSSEC is broken upstream.
2309    #[serde(default)]
2310    pub validation: Option<bool>,
2311
2312    /// Enable DNSSEC zone signing configuration
2313    ///
2314    /// Configures automatic DNSSEC signing for zones served by this cluster.
2315    /// When enabled, BIND9 will automatically generate keys, sign zones, and
2316    /// rotate keys based on the configured policy.
2317    ///
2318    /// **Important**: Requires BIND 9.16+ for modern `dnssec-policy` support.
2319    #[serde(default)]
2320    pub signing: Option<DNSSECSigningConfig>,
2321}
2322
2323/// DNSSEC zone signing configuration
2324///
2325/// Configures automatic DNSSEC key generation, zone signing, and key rotation.
2326/// Uses BIND9's modern `dnssec-policy` for declarative key management.
2327///
2328/// # Key Management Options
2329///
2330/// 1. **User-Supplied Keys** (Production): Keys managed externally via Secrets
2331/// 2. **Auto-Generated Keys** (Dev/Test): BIND9 generates keys, operator backs up to Secrets
2332/// 3. **Persistent Storage** (Legacy): Keys stored in `PersistentVolume`
2333///
2334/// # Example
2335///
2336/// ```yaml
2337/// signing:
2338///   enabled: true
2339///   policy: "default"
2340///   algorithm: "ECDSAP256SHA256"
2341///   kskLifetime: "365d"
2342///   zskLifetime: "90d"
2343///   nsec3: true
2344///   nsec3Iterations: 0
2345/// ```
2346#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2347#[serde(rename_all = "camelCase")]
2348pub struct DNSSECSigningConfig {
2349    /// Enable DNSSEC signing for zones
2350    ///
2351    /// When true, zones will be automatically signed with DNSSEC.
2352    /// Keys are generated and managed according to the configured policy.
2353    ///
2354    /// Default: `false`
2355    #[serde(default)]
2356    pub enabled: bool,
2357
2358    /// DNSSEC policy name
2359    ///
2360    /// Name of the DNSSEC policy to apply. Built-in policies:
2361    /// - `"default"` - Standard policy with ECDSA P-256, 365d KSK, 90d ZSK
2362    ///
2363    /// Custom policies can be defined in future enhancements.
2364    ///
2365    /// Default: `"default"`
2366    #[serde(default)]
2367    pub policy: Option<String>,
2368
2369    /// DNSSEC algorithm
2370    ///
2371    /// Cryptographic algorithm for DNSSEC signing. Supported algorithms:
2372    /// - `"ECDSAP256SHA256"` (13) - ECDSA P-256 with SHA-256 (recommended, fast)
2373    /// - `"ECDSAP384SHA384"` (14) - ECDSA P-384 with SHA-384 (higher security)
2374    /// - `"RSASHA256"` (8) - RSA with SHA-256 (widely compatible)
2375    ///
2376    /// ECDSA algorithms are recommended for performance and smaller key sizes.
2377    ///
2378    /// Default: `"ECDSAP256SHA256"`
2379    #[serde(default)]
2380    pub algorithm: Option<String>,
2381
2382    /// Key Signing Key (KSK) lifetime
2383    ///
2384    /// Duration before KSK is rotated. Format: "365d", "1y", "8760h"
2385    ///
2386    /// KSK signs the `DNSKEY` `RRset` and is published in the parent zone as a `DS` record.
2387    /// Longer lifetimes reduce `DS` update frequency but increase impact of key compromise.
2388    ///
2389    /// Default: `"365d"` (1 year)
2390    #[serde(default)]
2391    pub ksk_lifetime: Option<String>,
2392
2393    /// Zone Signing Key (ZSK) lifetime
2394    ///
2395    /// Duration before ZSK is rotated. Format: "90d", "3m", "2160h"
2396    ///
2397    /// ZSK signs all other records in the zone. Shorter lifetimes improve security
2398    /// but increase signing overhead.
2399    ///
2400    /// Default: `"90d"` (3 months)
2401    #[serde(default)]
2402    pub zsk_lifetime: Option<String>,
2403
2404    /// Use NSEC3 instead of NSEC for authenticated denial of existence
2405    ///
2406    /// NSEC3 hashes zone names to prevent zone enumeration attacks.
2407    /// Recommended for privacy-sensitive zones.
2408    ///
2409    /// Default: `false` (use NSEC)
2410    #[serde(default)]
2411    pub nsec3: Option<bool>,
2412
2413    /// NSEC3 salt (hex string)
2414    ///
2415    /// Salt value for NSEC3 hashing. If not specified, BIND9 auto-generates.
2416    /// Format: hex string (e.g., "AABBCCDD")
2417    ///
2418    /// Default: Auto-generated by BIND9
2419    #[serde(default)]
2420    pub nsec3_salt: Option<String>,
2421
2422    /// NSEC3 iterations
2423    ///
2424    /// Number of hash iterations for NSEC3. RFC 9276 recommends 0 for performance.
2425    ///
2426    /// **Important**: Higher values significantly impact query performance.
2427    ///
2428    /// Default: `0` (per RFC 9276 recommendation)
2429    #[serde(default)]
2430    pub nsec3_iterations: Option<u32>,
2431
2432    /// DNSSEC key source configuration
2433    ///
2434    /// Specifies where DNSSEC keys come from:
2435    /// - User-supplied Secret (recommended for production)
2436    /// - Persistent storage (legacy)
2437    ///
2438    /// If not specified and `auto_generate` is true, keys are generated in emptyDir
2439    /// and optionally backed up to Secrets.
2440    #[serde(default)]
2441    pub keys_from: Option<DNSSECKeySource>,
2442
2443    /// Auto-generate DNSSEC keys if no `keys_from` specified
2444    ///
2445    /// When true, BIND9 generates keys automatically using the configured policy.
2446    /// Recommended for development and testing.
2447    ///
2448    /// Default: `true`
2449    #[serde(default)]
2450    pub auto_generate: Option<bool>,
2451
2452    /// Export auto-generated keys to Secret for backup/restore
2453    ///
2454    /// When true, operator exports BIND9-generated keys to a Kubernetes Secret.
2455    /// Enables self-healing: keys are restored from Secret on pod restart.
2456    ///
2457    /// Secret name format: `dnssec-keys-<zone-name>-generated`
2458    ///
2459    /// Default: `true`
2460    #[serde(default)]
2461    pub export_to_secret: Option<bool>,
2462}
2463
2464/// DNSSEC key source configuration
2465///
2466/// Defines where DNSSEC keys are loaded from. Supports multiple patterns:
2467///
2468/// 1. **User-Supplied Secret** (Production):
2469///    - Keys managed externally (`Vault`, `ExternalSecrets`, `sealed-secrets`)
2470///    - User controls rotation timing
2471///    - `GitOps` friendly
2472///
2473/// 2. **Persistent Storage** (Legacy):
2474///    - Keys stored in `PersistentVolume`
2475///    - Traditional BIND9 pattern
2476///
2477/// # Example: User-Supplied Keys
2478///
2479/// ```yaml
2480/// keysFrom:
2481///   secretRef:
2482///     name: "dnssec-keys-example-com"
2483/// ```
2484///
2485/// # Example: Persistent Storage
2486///
2487/// ```yaml
2488/// keysFrom:
2489///   persistentVolume:
2490///     accessModes:
2491///       - ReadWriteOnce
2492///     resources:
2493///       requests:
2494///         storage: 100Mi
2495/// ```
2496#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2497#[serde(rename_all = "camelCase")]
2498pub struct DNSSECKeySource {
2499    /// Secret containing DNSSEC keys
2500    ///
2501    /// Reference to a Kubernetes Secret with DNSSEC key files.
2502    ///
2503    /// Secret data format:
2504    /// - `K<zone>.+<alg>+<tag>.key` - Public key file
2505    /// - `K<zone>.+<alg>+<tag>.private` - Private key file
2506    ///
2507    /// Example: `Kexample.com.+013+12345.key`
2508    #[serde(default)]
2509    pub secret_ref: Option<SecretReference>,
2510
2511    /// Persistent volume for DNSSEC keys (legacy/compatibility)
2512    ///
2513    /// **Note**: Not cloud-native. Use `secret_ref` for production.
2514    #[serde(default)]
2515    pub persistent_volume: Option<k8s_openapi::api::core::v1::PersistentVolumeClaimSpec>,
2516}
2517
2518/// Reference to a Kubernetes Secret
2519///
2520/// Used for referencing external Secrets containing DNSSEC keys,
2521/// certificates, or other sensitive data.
2522#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2523#[serde(rename_all = "camelCase")]
2524pub struct SecretReference {
2525    /// Secret name
2526    pub name: String,
2527
2528    /// Optional namespace (defaults to same namespace as the resource)
2529    #[serde(default)]
2530    pub namespace: Option<String>,
2531}
2532
2533/// DNSSEC status information for a signed zone
2534///
2535/// Tracks DNSSEC signing status, DS records for parent zones,
2536/// and key rotation timestamps.
2537///
2538/// This status is populated by the `DNSZone` controller after
2539/// successful zone signing.
2540#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2541#[serde(rename_all = "camelCase")]
2542pub struct DNSSECStatus {
2543    /// Zone is signed with DNSSEC
2544    pub signed: bool,
2545
2546    /// DS (Delegation Signer) records for parent zone delegation
2547    ///
2548    /// These records must be published in the parent zone to complete
2549    /// the DNSSEC chain of trust.
2550    ///
2551    /// Format: `<zone> IN DS <keytag> <algorithm> <digesttype> <digest>`
2552    ///
2553    /// Example: `["example.com. IN DS 12345 13 2 ABC123..."]`
2554    #[serde(default)]
2555    pub ds_records: Vec<String>,
2556
2557    /// KSK key tag (numeric identifier)
2558    ///
2559    /// Identifies the Key Signing Key used to sign the DNSKEY `RRset`.
2560    /// This value appears in the DS record.
2561    #[serde(default)]
2562    pub key_tag: Option<u32>,
2563
2564    /// DNSSEC algorithm name
2565    ///
2566    /// Example: `"ECDSAP256SHA256"`, `"RSASHA256"`
2567    #[serde(default)]
2568    pub algorithm: Option<String>,
2569
2570    /// Next scheduled key rollover timestamp (ISO 8601)
2571    ///
2572    /// When the next automatic key rotation will occur.
2573    ///
2574    /// Example: `"2026-04-02T00:00:00Z"`
2575    #[serde(default)]
2576    pub next_key_rollover: Option<String>,
2577
2578    /// Last key rollover timestamp (ISO 8601)
2579    ///
2580    /// When the most recent key rotation occurred.
2581    ///
2582    /// Example: `"2025-04-02T00:00:00Z"`
2583    #[serde(default)]
2584    pub last_key_rollover: Option<String>,
2585}
2586
2587/// Container image configuration for BIND9 instances
2588#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2589#[serde(rename_all = "camelCase")]
2590pub struct ImageConfig {
2591    /// Container image repository and tag for BIND9
2592    ///
2593    /// Example: "internetsystemsconsortium/bind9:9.18"
2594    #[serde(default)]
2595    pub image: Option<String>,
2596
2597    /// Image pull policy
2598    ///
2599    /// Example: `IfNotPresent`, `Always`, `Never`
2600    #[serde(default)]
2601    pub image_pull_policy: Option<String>,
2602
2603    /// Reference to image pull secrets for private registries
2604    #[serde(default)]
2605    pub image_pull_secrets: Option<Vec<String>>,
2606}
2607
2608/// `ConfigMap` references for BIND9 configuration files
2609#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2610#[serde(rename_all = "camelCase")]
2611pub struct ConfigMapRefs {
2612    /// `ConfigMap` containing named.conf file
2613    ///
2614    /// If not specified, a default configuration will be generated
2615    #[serde(default)]
2616    pub named_conf: Option<String>,
2617
2618    /// `ConfigMap` containing named.conf.options file
2619    ///
2620    /// If not specified, a default configuration will be generated
2621    #[serde(default)]
2622    pub named_conf_options: Option<String>,
2623
2624    /// `ConfigMap` containing named.conf.zones file
2625    ///
2626    /// Optional. If specified, the zones file from this `ConfigMap` will be included in named.conf.
2627    /// If not specified, no zones file will be included (zones can be added dynamically via RNDC).
2628    /// Use this for pre-configured zones or to import existing BIND9 zone configurations.
2629    #[serde(default)]
2630    pub named_conf_zones: Option<String>,
2631}
2632
2633/// Service configuration including spec and annotations
2634///
2635/// Allows customization of both the Kubernetes Service spec and metadata annotations.
2636#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2637#[serde(rename_all = "camelCase")]
2638pub struct ServiceConfig {
2639    /// Annotations to apply to the Service metadata
2640    ///
2641    /// Common use cases:
2642    /// - `MetalLB` address pool selection: `metallb.universe.tf/address-pool: my-ip-pool`
2643    /// - AWS load balancer configuration: `service.beta.kubernetes.io/aws-load-balancer-type: nlb`
2644    /// - External DNS hostname: `external-dns.alpha.kubernetes.io/hostname: dns.example.com`
2645    ///
2646    /// Example:
2647    /// ```yaml
2648    /// annotations:
2649    ///   metallb.universe.tf/address-pool: my-ip-pool
2650    ///   external-dns.alpha.kubernetes.io/hostname: ns1.example.com
2651    /// ```
2652    #[serde(skip_serializing_if = "Option::is_none")]
2653    pub annotations: Option<BTreeMap<String, String>>,
2654
2655    /// Custom Kubernetes Service spec
2656    ///
2657    /// Allows full customization of the Kubernetes Service created for DNS servers.
2658    /// This accepts the same fields as the standard Kubernetes Service `spec`.
2659    ///
2660    /// Common fields:
2661    /// - `type`: Service type (`ClusterIP`, `NodePort`, `LoadBalancer`)
2662    /// - `loadBalancerIP`: Specific IP for `LoadBalancer` type
2663    /// - `externalTrafficPolicy`: `Local` or `Cluster`
2664    /// - `sessionAffinity`: `ClientIP` or `None`
2665    /// - `clusterIP`: Specific cluster IP (use with caution)
2666    ///
2667    /// Fields specified here are merged with defaults. Unspecified fields use safe defaults:
2668    /// - `type: ClusterIP` (if not specified)
2669    /// - Ports 53/TCP and 53/UDP (always set)
2670    /// - Selector matching the instance labels (always set)
2671    #[serde(skip_serializing_if = "Option::is_none")]
2672    pub spec: Option<ServiceSpec>,
2673}
2674
2675/// Primary instance configuration
2676///
2677/// Groups all configuration specific to primary (authoritative) DNS instances.
2678#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2679#[serde(rename_all = "camelCase")]
2680pub struct PrimaryConfig {
2681    /// Number of primary instance replicas (default: 1)
2682    ///
2683    /// This controls how many replicas each primary instance in this cluster should have.
2684    /// Can be overridden at the instance level.
2685    #[serde(skip_serializing_if = "Option::is_none")]
2686    #[schemars(range(min = 0, max = 100))]
2687    pub replicas: Option<i32>,
2688
2689    /// Additional labels to apply to primary `Bind9Instance` resources
2690    ///
2691    /// These labels are propagated from the cluster/provider to all primary instances.
2692    /// They are merged with standard labels (app.kubernetes.io/*) and can be used for:
2693    /// - Instance selection via `DNSZone.spec.bind9InstancesFrom` label selectors
2694    /// - Pod selectors in network policies
2695    /// - Monitoring and alerting label filters
2696    /// - Custom organizational taxonomy
2697    ///
2698    /// Example:
2699    /// ```yaml
2700    /// primary:
2701    ///   labels:
2702    ///     environment: production
2703    ///     tier: frontend
2704    ///     region: us-east-1
2705    /// ```
2706    ///
2707    /// These labels will appear on the `Bind9Instance` metadata and can be referenced
2708    /// by `DNSZone` resources using `bind9InstancesFrom.selector.matchLabels`.
2709    #[serde(default, skip_serializing_if = "Option::is_none")]
2710    pub labels: Option<BTreeMap<String, String>>,
2711
2712    /// Custom Kubernetes Service configuration for primary instances
2713    ///
2714    /// Allows full customization of the Kubernetes Service created for primary DNS servers,
2715    /// including both Service spec fields and metadata annotations.
2716    ///
2717    /// Annotations are commonly used for:
2718    /// - `MetalLB` address pool selection
2719    /// - Cloud provider load balancer configuration
2720    /// - External DNS integration
2721    /// - Linkerd service mesh annotations
2722    ///
2723    /// Fields specified here are merged with defaults. Unspecified fields use safe defaults:
2724    /// - `type: ClusterIP` (if not specified)
2725    /// - Ports 53/TCP and 53/UDP (always set)
2726    /// - Selector matching the instance labels (always set)
2727    #[serde(skip_serializing_if = "Option::is_none")]
2728    pub service: Option<ServiceConfig>,
2729
2730    /// Allow-transfer ACL for primary instances
2731    ///
2732    /// Overrides the default auto-detected Pod CIDR allow-transfer configuration
2733    /// for all primary instances in this cluster. Use this to restrict or expand
2734    /// which IP addresses can perform zone transfers from primary servers.
2735    ///
2736    /// If not specified, defaults to cluster Pod CIDRs (auto-detected from Kubernetes Nodes).
2737    ///
2738    /// Examples:
2739    /// - `["10.0.0.0/8"]` - Allow transfers from entire 10.x network
2740    /// - `["any"]` - Allow transfers from any IP (public internet)
2741    /// - `[]` - Deny all zone transfers (empty list means "none")
2742    ///
2743    /// Can be overridden at the instance level via `spec.config.allowTransfer`.
2744    #[serde(default, skip_serializing_if = "Option::is_none")]
2745    pub allow_transfer: Option<Vec<String>>,
2746
2747    /// Reference to an existing Kubernetes Secret containing RNDC key for all primary instances.
2748    ///
2749    /// If specified, all primary instances in this cluster will use this existing Secret
2750    /// instead of auto-generating individual secrets. This allows sharing the same RNDC key
2751    /// across all primary instances.
2752    ///
2753    /// Can be overridden at the instance level via `spec.rndcSecretRef`.
2754    #[serde(default, skip_serializing_if = "Option::is_none")]
2755    #[deprecated(
2756        since = "0.6.0",
2757        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
2758    )]
2759    pub rndc_secret_ref: Option<RndcSecretRef>,
2760
2761    /// RNDC key configuration for all primary instances with lifecycle management.
2762    ///
2763    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
2764    /// Overrides global RNDC configuration for primary instances.
2765    ///
2766    /// **Precedence order**:
2767    /// 1. Instance level (`spec.rndcKey`)
2768    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
2769    /// 3. Global level (cluster-wide RNDC configuration)
2770    /// 4. Auto-generated (default)
2771    ///
2772    /// Can be overridden at the instance level via `spec.rndcKey`.
2773    ///
2774    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
2775    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
2776    /// to work but is deprecated.
2777    ///
2778    /// # Example
2779    ///
2780    /// ```yaml
2781    /// primary:
2782    ///   replicas: 1
2783    ///   rndcKey:
2784    ///     autoRotate: true
2785    ///     rotateAfter: 720h  # 30 days
2786    ///     algorithm: hmac-sha256
2787    /// ```
2788    #[serde(skip_serializing_if = "Option::is_none")]
2789    pub rndc_key: Option<RndcKeyConfig>,
2790}
2791
2792/// Secondary instance configuration
2793///
2794/// Groups all configuration specific to secondary (replica) DNS instances.
2795#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2796#[serde(rename_all = "camelCase")]
2797pub struct SecondaryConfig {
2798    /// Number of secondary instance replicas (default: 1)
2799    ///
2800    /// This controls how many replicas each secondary instance in this cluster should have.
2801    /// Can be overridden at the instance level.
2802    #[serde(skip_serializing_if = "Option::is_none")]
2803    #[schemars(range(min = 0, max = 100))]
2804    pub replicas: Option<i32>,
2805
2806    /// Additional labels to apply to secondary `Bind9Instance` resources
2807    ///
2808    /// These labels are propagated from the cluster/provider to all secondary instances.
2809    /// They are merged with standard labels (app.kubernetes.io/*) and can be used for:
2810    /// - Instance selection via `DNSZone.spec.bind9InstancesFrom` label selectors
2811    /// - Pod selectors in network policies
2812    /// - Monitoring and alerting label filters
2813    /// - Custom organizational taxonomy
2814    ///
2815    /// Example:
2816    /// ```yaml
2817    /// secondary:
2818    ///   labels:
2819    ///     environment: production
2820    ///     tier: backend
2821    ///     region: us-west-2
2822    /// ```
2823    ///
2824    /// These labels will appear on the `Bind9Instance` metadata and can be referenced
2825    /// by `DNSZone` resources using `bind9InstancesFrom.selector.matchLabels`.
2826    #[serde(default, skip_serializing_if = "Option::is_none")]
2827    pub labels: Option<BTreeMap<String, String>>,
2828
2829    /// Custom Kubernetes Service configuration for secondary instances
2830    ///
2831    /// Allows full customization of the Kubernetes Service created for secondary DNS servers,
2832    /// including both Service spec fields and metadata annotations.
2833    ///
2834    /// Annotations are commonly used for:
2835    /// - `MetalLB` address pool selection
2836    /// - Cloud provider load balancer configuration
2837    /// - External DNS integration
2838    /// - Linkerd service mesh annotations
2839    ///
2840    /// Allows different service configurations for primary vs secondary instances.
2841    /// Example: Primaries use `LoadBalancer` with specific annotations, secondaries use `ClusterIP`
2842    ///
2843    /// See `PrimaryConfig.service` for detailed field documentation.
2844    #[serde(skip_serializing_if = "Option::is_none")]
2845    pub service: Option<ServiceConfig>,
2846
2847    /// Allow-transfer ACL for secondary instances
2848    ///
2849    /// Overrides the default auto-detected Pod CIDR allow-transfer configuration
2850    /// for all secondary instances in this cluster. Use this to restrict or expand
2851    /// which IP addresses can perform zone transfers from secondary servers.
2852    ///
2853    /// If not specified, defaults to cluster Pod CIDRs (auto-detected from Kubernetes Nodes).
2854    ///
2855    /// Examples:
2856    /// - `["10.0.0.0/8"]` - Allow transfers from entire 10.x network
2857    /// - `["any"]` - Allow transfers from any IP (public internet)
2858    /// - `[]` - Deny all zone transfers (empty list means "none")
2859    ///
2860    /// Can be overridden at the instance level via `spec.config.allowTransfer`.
2861    #[serde(default, skip_serializing_if = "Option::is_none")]
2862    pub allow_transfer: Option<Vec<String>>,
2863
2864    /// Reference to an existing Kubernetes Secret containing RNDC key for all secondary instances.
2865    ///
2866    /// If specified, all secondary instances in this cluster will use this existing Secret
2867    /// instead of auto-generating individual secrets. This allows sharing the same RNDC key
2868    /// across all secondary instances.
2869    ///
2870    /// Can be overridden at the instance level via `spec.rndcSecretRef`.
2871    #[serde(default, skip_serializing_if = "Option::is_none")]
2872    #[deprecated(
2873        since = "0.6.0",
2874        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
2875    )]
2876    pub rndc_secret_ref: Option<RndcSecretRef>,
2877
2878    /// RNDC key configuration for all secondary instances with lifecycle management.
2879    ///
2880    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
2881    /// Overrides global RNDC configuration for secondary instances.
2882    ///
2883    /// **Precedence order**:
2884    /// 1. Instance level (`spec.rndcKey`)
2885    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
2886    /// 3. Global level (cluster-wide RNDC configuration)
2887    /// 4. Auto-generated (default)
2888    ///
2889    /// Can be overridden at the instance level via `spec.rndcKey`.
2890    ///
2891    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
2892    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
2893    /// to work but is deprecated.
2894    ///
2895    /// # Example
2896    ///
2897    /// ```yaml
2898    /// secondary:
2899    ///   replicas: 2
2900    ///   rndcKey:
2901    ///     autoRotate: true
2902    ///     rotateAfter: 720h  # 30 days
2903    ///     algorithm: hmac-sha256
2904    /// ```
2905    #[serde(skip_serializing_if = "Option::is_none")]
2906    pub rndc_key: Option<RndcKeyConfig>,
2907}
2908
2909/// Common specification fields shared between namespace-scoped and cluster-scoped BIND9 clusters.
2910///
2911/// This struct contains all configuration that is common to both `Bind9Cluster` (namespace-scoped)
2912/// and `ClusterBind9Provider` (cluster-scoped). By using this shared struct, we avoid code duplication
2913/// and ensure consistency between the two cluster types.
2914#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2915#[serde(rename_all = "camelCase")]
2916pub struct Bind9ClusterCommonSpec {
2917    /// Shared BIND9 version for the cluster
2918    ///
2919    /// If not specified, defaults to "9.18".
2920    #[serde(default = "default_bind9_version")]
2921    #[schemars(default = "default_bind9_version")]
2922    pub version: Option<String>,
2923
2924    /// Primary instance configuration
2925    ///
2926    /// Configuration specific to primary (authoritative) DNS instances,
2927    /// including replica count and service specifications.
2928    #[serde(default, skip_serializing_if = "Option::is_none")]
2929    pub primary: Option<PrimaryConfig>,
2930
2931    /// Secondary instance configuration
2932    ///
2933    /// Configuration specific to secondary (replica) DNS instances,
2934    /// including replica count and service specifications.
2935    #[serde(default, skip_serializing_if = "Option::is_none")]
2936    pub secondary: Option<SecondaryConfig>,
2937
2938    /// Container image configuration
2939    #[serde(default)]
2940    pub image: Option<ImageConfig>,
2941
2942    /// `ConfigMap` references for BIND9 configuration files
2943    #[serde(default)]
2944    pub config_map_refs: Option<ConfigMapRefs>,
2945
2946    /// Global configuration shared by all instances in the cluster
2947    ///
2948    /// This configuration applies to all instances (both primary and secondary)
2949    /// unless overridden at the instance level or by role-specific configuration.
2950    #[serde(default)]
2951    pub global: Option<Bind9Config>,
2952
2953    /// References to Kubernetes Secrets containing RNDC/TSIG keys for authenticated zone transfers.
2954    ///
2955    /// Each secret should contain the key name, algorithm, and base64-encoded secret value.
2956    /// These secrets are used for secure communication with BIND9 instances via RNDC and
2957    /// for authenticated zone transfers (AXFR/IXFR) between primary and secondary servers.
2958    #[serde(default)]
2959    pub rndc_secret_refs: Option<Vec<RndcSecretRef>>,
2960
2961    /// ACLs that can be referenced by instances
2962    #[serde(default)]
2963    pub acls: Option<BTreeMap<String, Vec<String>>>,
2964
2965    /// Volumes that can be mounted by instances in this cluster
2966    ///
2967    /// These volumes are inherited by all instances unless overridden.
2968    /// Common use cases include `PersistentVolumeClaims` for zone data storage.
2969    #[serde(default)]
2970    pub volumes: Option<Vec<Volume>>,
2971
2972    /// Volume mounts that specify where volumes should be mounted in containers
2973    ///
2974    /// These mounts are inherited by all instances unless overridden.
2975    #[serde(default)]
2976    pub volume_mounts: Option<Vec<VolumeMount>>,
2977}
2978
2979/// `Bind9Cluster` - Namespace-scoped DNS cluster for tenant-managed infrastructure.
2980///
2981/// A namespace-scoped cluster allows development teams to run their own isolated BIND9
2982/// DNS infrastructure within their namespace. Each team can manage their own cluster
2983/// independently, with RBAC controlling who can create and manage resources.
2984///
2985/// For platform-managed, cluster-wide DNS infrastructure, use `ClusterBind9Provider` instead.
2986///
2987/// # Use Cases
2988///
2989/// - Development teams need isolated DNS infrastructure for testing
2990/// - Multi-tenant environments where each team manages their own DNS
2991/// - Namespaced DNS services that don't need cluster-wide visibility
2992///
2993/// # Example
2994///
2995/// ```yaml
2996/// apiVersion: bindy.firestoned.io/v1beta1
2997/// kind: Bind9Cluster
2998/// metadata:
2999///   name: dev-team-dns
3000///   namespace: dev-team-alpha
3001/// spec:
3002///   version: "9.18"
3003///   primary:
3004///     replicas: 1
3005///   secondary:
3006///     replicas: 1
3007/// ```
3008#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3009#[kube(
3010    group = "bindy.firestoned.io",
3011    version = "v1beta1",
3012    kind = "Bind9Cluster",
3013    namespaced,
3014    shortname = "b9c",
3015    shortname = "b9cs",
3016    doc = "Bind9Cluster defines a namespace-scoped logical grouping of BIND9 DNS server instances. Use this for tenant-managed DNS infrastructure isolated to a specific namespace. For platform-managed cluster-wide DNS, use ClusterBind9Provider instead.",
3017    printcolumn = r#"{"name":"Version","type":"string","jsonPath":".spec.version"}"#,
3018    printcolumn = r#"{"name":"Primary","type":"integer","jsonPath":".spec.primary.replicas"}"#,
3019    printcolumn = r#"{"name":"Secondary","type":"integer","jsonPath":".spec.secondary.replicas"}"#,
3020    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3021)]
3022#[kube(status = "Bind9ClusterStatus")]
3023#[serde(rename_all = "camelCase")]
3024pub struct Bind9ClusterSpec {
3025    /// All cluster configuration is flattened from the common spec
3026    #[serde(flatten)]
3027    pub common: Bind9ClusterCommonSpec,
3028}
3029
3030/// `ClusterBind9Provider` - Cluster-scoped BIND9 DNS provider for platform teams.
3031///
3032/// A cluster-scoped provider allows platform teams to provision shared BIND9 DNS infrastructure
3033/// that is accessible from any namespace. This is ideal for shared services, production DNS,
3034/// or platform-managed infrastructure that multiple teams use.
3035///
3036/// `DNSZones` in any namespace can reference a `ClusterBind9Provider` using the `clusterProviderRef` field.
3037///
3038/// # Use Cases
3039///
3040/// - Platform team provides shared DNS infrastructure for all namespaces
3041/// - Production DNS services that serve multiple applications
3042/// - Centrally managed DNS with governance and compliance requirements
3043///
3044/// # Example
3045///
3046/// ```yaml
3047/// apiVersion: bindy.firestoned.io/v1beta1
3048/// kind: ClusterBind9Provider
3049/// metadata:
3050///   name: shared-production-dns
3051///   # No namespace - cluster-scoped
3052/// spec:
3053///   version: "9.18"
3054///   primary:
3055///     replicas: 3
3056///     service:
3057///       type: LoadBalancer
3058///   secondary:
3059///     replicas: 2
3060/// ```
3061#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3062#[kube(
3063    group = "bindy.firestoned.io",
3064    version = "v1beta1",
3065    kind = "ClusterBind9Provider",
3066    // NOTE: No 'namespaced' attribute = cluster-scoped
3067    shortname = "cb9p",
3068    shortname = "cb9ps",
3069    doc = "ClusterBind9Provider defines a cluster-scoped BIND9 DNS provider that manages DNS infrastructure accessible from all namespaces. Use this for platform-managed DNS infrastructure. For tenant-managed namespace-scoped DNS, use Bind9Cluster instead.",
3070    printcolumn = r#"{"name":"Version","type":"string","jsonPath":".spec.version"}"#,
3071    printcolumn = r#"{"name":"Primary","type":"integer","jsonPath":".spec.primary.replicas"}"#,
3072    printcolumn = r#"{"name":"Secondary","type":"integer","jsonPath":".spec.secondary.replicas"}"#,
3073    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3074)]
3075#[kube(status = "Bind9ClusterStatus")]
3076#[serde(rename_all = "camelCase")]
3077pub struct ClusterBind9ProviderSpec {
3078    /// Namespace where `Bind9Instance` resources will be created
3079    ///
3080    /// Since `ClusterBind9Provider` is cluster-scoped, instances need to be created in a specific namespace.
3081    /// Typically this would be a platform-managed namespace like `bindy-system`.
3082    ///
3083    /// All managed instances (primary and secondary) will be created in this namespace.
3084    /// `DNSZones` from any namespace can reference this provider via `clusterProviderRef`.
3085    ///
3086    /// **Default:** If not specified, instances will be created in the same namespace where the
3087    /// Bindy operator is running (from `POD_NAMESPACE` environment variable).
3088    ///
3089    /// Example: `bindy-system` for platform DNS infrastructure
3090    #[serde(skip_serializing_if = "Option::is_none")]
3091    pub namespace: Option<String>,
3092
3093    /// All cluster configuration is flattened from the common spec
3094    #[serde(flatten)]
3095    pub common: Bind9ClusterCommonSpec,
3096}
3097
3098/// `Bind9Cluster` status
3099#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
3100#[serde(rename_all = "camelCase")]
3101pub struct Bind9ClusterStatus {
3102    /// Status conditions for this cluster
3103    #[serde(default)]
3104    pub conditions: Vec<Condition>,
3105
3106    /// Observed generation for optimistic concurrency
3107    #[serde(skip_serializing_if = "Option::is_none")]
3108    pub observed_generation: Option<i64>,
3109
3110    /// Number of instances in this cluster
3111    #[serde(skip_serializing_if = "Option::is_none")]
3112    pub instance_count: Option<i32>,
3113
3114    /// Number of ready instances
3115    #[serde(skip_serializing_if = "Option::is_none")]
3116    pub ready_instances: Option<i32>,
3117
3118    /// Names of `Bind9Instance` resources created for this cluster
3119    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3120    pub instances: Vec<String>,
3121}
3122
3123/// Server role in the DNS cluster.
3124///
3125/// Determines whether the instance is authoritative (primary) or replicates from primaries (secondary).
3126#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3127#[serde(rename_all = "lowercase")]
3128pub enum ServerRole {
3129    /// Primary DNS server - authoritative source for zones.
3130    ///
3131    /// Primary servers hold the original zone data and process dynamic updates.
3132    /// Changes to zones are made on primaries and transferred to secondaries.
3133    Primary,
3134
3135    /// Secondary DNS server - replicates zones from primary servers.
3136    ///
3137    /// Secondary servers receive zone data via AXFR (full) or IXFR (incremental)
3138    /// zone transfers. They provide redundancy and geographic distribution.
3139    Secondary,
3140}
3141
3142impl ServerRole {
3143    /// Convert `ServerRole` to its string representation.
3144    ///
3145    /// Returns the lowercase zone type string used in BIND9 configuration
3146    /// and bindcar API calls.
3147    ///
3148    /// # Returns
3149    /// * `"primary"` for `ServerRole::Primary`
3150    /// * `"secondary"` for `ServerRole::Secondary`
3151    ///
3152    /// # Examples
3153    ///
3154    /// ```
3155    /// use bindy::crd::ServerRole;
3156    ///
3157    /// assert_eq!(ServerRole::Primary.as_str(), "primary");
3158    /// assert_eq!(ServerRole::Secondary.as_str(), "secondary");
3159    /// ```
3160    #[must_use]
3161    pub const fn as_str(&self) -> &'static str {
3162        match self {
3163            Self::Primary => "primary",
3164            Self::Secondary => "secondary",
3165        }
3166    }
3167}
3168
3169/// `Bind9Instance` represents a BIND9 DNS server deployment in Kubernetes.
3170///
3171/// Each `Bind9Instance` creates a Deployment, Service, `ConfigMap`, and Secret for managing
3172/// a BIND9 server. The instance communicates with the controller via RNDC protocol.
3173///
3174/// # Example
3175///
3176/// ```yaml
3177/// apiVersion: bindy.firestoned.io/v1beta1
3178/// kind: Bind9Instance
3179/// metadata:
3180///   name: dns-primary
3181///   namespace: bindy-system
3182/// spec:
3183///   clusterRef: my-dns-cluster
3184///   role: primary
3185///   replicas: 2
3186///   version: "9.18"
3187/// ```
3188#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3189#[kube(
3190    group = "bindy.firestoned.io",
3191    version = "v1beta1",
3192    kind = "Bind9Instance",
3193    namespaced,
3194    shortname = "b9",
3195    shortname = "b9s",
3196    doc = "Bind9Instance represents a BIND9 DNS server deployment in Kubernetes. Each instance creates a Deployment, Service, ConfigMap, and Secret for managing a BIND9 server with RNDC protocol communication.",
3197    printcolumn = r#"{"name":"Cluster","type":"string","jsonPath":".spec.clusterRef"}"#,
3198    printcolumn = r#"{"name":"Role","type":"string","jsonPath":".spec.role"}"#,
3199    printcolumn = r#"{"name":"Replicas","type":"integer","jsonPath":".spec.replicas"}"#,
3200    printcolumn = r#"{"name":"Zones","type":"integer","jsonPath":".status.zonesCount"}"#,
3201    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3202)]
3203#[kube(status = "Bind9InstanceStatus")]
3204#[serde(rename_all = "camelCase")]
3205pub struct Bind9InstanceSpec {
3206    /// Reference to the cluster this instance belongs to.
3207    ///
3208    /// Can reference either:
3209    /// - A namespace-scoped `Bind9Cluster` (must be in the same namespace as this instance)
3210    /// - A cluster-scoped `ClusterBind9Provider` (cluster-wide, accessible from any namespace)
3211    ///
3212    /// The cluster provides shared configuration and defines the logical grouping.
3213    /// The controller will automatically detect whether this references a namespace-scoped
3214    /// or cluster-scoped cluster resource.
3215    pub cluster_ref: String,
3216
3217    /// Role of this instance (primary or secondary).
3218    ///
3219    /// Primary instances are authoritative for zones. Secondary instances
3220    /// replicate zones from primaries via AXFR/IXFR.
3221    pub role: ServerRole,
3222
3223    /// Number of pod replicas for high availability.
3224    ///
3225    /// Defaults to 1 if not specified. For production, use 2+ replicas.
3226    #[serde(default)]
3227    #[schemars(range(min = 0, max = 100))]
3228    pub replicas: Option<i32>,
3229
3230    /// BIND9 version override. Inherits from cluster if not specified.
3231    ///
3232    /// Example: "9.18", "9.16"
3233    #[serde(default)]
3234    pub version: Option<String>,
3235
3236    /// Container image configuration override. Inherits from cluster if not specified.
3237    #[serde(default)]
3238    pub image: Option<ImageConfig>,
3239
3240    /// `ConfigMap` references override. Inherits from cluster if not specified.
3241    #[serde(default)]
3242    pub config_map_refs: Option<ConfigMapRefs>,
3243
3244    /// Instance-specific BIND9 configuration overrides.
3245    ///
3246    /// Overrides cluster-level configuration for this instance only.
3247    #[serde(default)]
3248    pub config: Option<Bind9Config>,
3249
3250    /// Primary server addresses for zone transfers (required for secondary instances).
3251    ///
3252    /// List of IP addresses or hostnames of primary servers to transfer zones from.
3253    /// Example: `["10.0.1.10", "primary.example.com"]`
3254    #[serde(default)]
3255    pub primary_servers: Option<Vec<String>>,
3256
3257    /// Volumes override for this instance. Inherits from cluster if not specified.
3258    ///
3259    /// These volumes override cluster-level volumes. Common use cases include
3260    /// instance-specific `PersistentVolumeClaims` for zone data storage.
3261    #[serde(default)]
3262    pub volumes: Option<Vec<Volume>>,
3263
3264    /// Volume mounts override for this instance. Inherits from cluster if not specified.
3265    ///
3266    /// These mounts override cluster-level volume mounts.
3267    #[serde(default)]
3268    pub volume_mounts: Option<Vec<VolumeMount>>,
3269
3270    /// Reference to an existing Kubernetes Secret containing RNDC key.
3271    ///
3272    /// If specified, uses this existing Secret instead of auto-generating one.
3273    /// The Secret must contain the keys specified in the reference (defaults: "key-name", "algorithm", "secret", "rndc.key").
3274    /// This allows sharing RNDC keys across instances or using externally managed secrets.
3275    ///
3276    /// If not specified, a Secret will be auto-generated for this instance.
3277    #[serde(default)]
3278    #[deprecated(
3279        since = "0.6.0",
3280        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
3281    )]
3282    pub rndc_secret_ref: Option<RndcSecretRef>,
3283
3284    /// Instance-level RNDC key configuration with lifecycle management.
3285    ///
3286    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
3287    /// Overrides role-level and global RNDC configuration for this specific instance.
3288    ///
3289    /// **Precedence order**:
3290    /// 1. **Instance level** (`spec.rndcKey`) - Highest priority
3291    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
3292    /// 3. Global level (cluster-wide RNDC configuration)
3293    /// 4. Auto-generated (default)
3294    ///
3295    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
3296    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
3297    /// to work but is deprecated.
3298    ///
3299    /// # Example
3300    ///
3301    /// ```yaml
3302    /// apiVersion: bindy.firestoned.io/v1beta1
3303    /// kind: Bind9Instance
3304    /// spec:
3305    ///   rndcKey:
3306    ///     autoRotate: true
3307    ///     rotateAfter: 2160h  # 90 days
3308    ///     algorithm: hmac-sha512
3309    /// ```
3310    #[serde(skip_serializing_if = "Option::is_none")]
3311    pub rndc_key: Option<RndcKeyConfig>,
3312
3313    /// Storage configuration for zone files.
3314    ///
3315    /// Specifies how zone files should be stored. Defaults to emptyDir (ephemeral storage).
3316    /// For persistent storage, use persistentVolumeClaim.
3317    #[serde(default)]
3318    pub storage: Option<StorageConfig>,
3319
3320    /// Bindcar RNDC API sidecar container configuration.
3321    ///
3322    /// The API container provides an HTTP interface for managing zones via rndc.
3323    /// If not specified, uses default configuration.
3324    #[serde(default)]
3325    pub bindcar_config: Option<BindcarConfig>,
3326}
3327
3328/// `Bind9Instance` status
3329#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
3330#[serde(rename_all = "camelCase")]
3331pub struct Bind9InstanceStatus {
3332    #[serde(default)]
3333    pub conditions: Vec<Condition>,
3334    #[serde(skip_serializing_if = "Option::is_none")]
3335    pub observed_generation: Option<i64>,
3336    /// IP or hostname of this instance's service
3337    #[serde(skip_serializing_if = "Option::is_none")]
3338    pub service_address: Option<String>,
3339    /// Resolved cluster reference with full object details.
3340    ///
3341    /// This field is populated by the instance reconciler and contains the full Kubernetes
3342    /// object reference (kind, apiVersion, namespace, name) of the cluster this instance
3343    /// belongs to. This provides backward compatibility with `spec.clusterRef` (which is
3344    /// just a string name) and enables proper Kubernetes object references.
3345    ///
3346    /// For namespace-scoped `Bind9Cluster`, includes namespace.
3347    /// For cluster-scoped `ClusterBind9Provider`, namespace will be empty.
3348    #[serde(skip_serializing_if = "Option::is_none")]
3349    pub cluster_ref: Option<ClusterReference>,
3350    /// List of DNS zones that have selected this instance.
3351    ///
3352    /// This field is automatically populated by a status-only watcher on `DNSZones`.
3353    /// When a `DNSZone`'s `status.bind9Instances` includes this instance, the zone
3354    /// is added to this list. This provides a reverse lookup: instance → zones.
3355    ///
3356    /// Updated by: `DNSZone` status watcher (not by instance reconciler)
3357    /// Used for: Observability, debugging zone assignments
3358    #[serde(default)]
3359    #[serde(skip_serializing_if = "Vec::is_empty")]
3360    pub zones: Vec<ZoneReference>,
3361
3362    /// Number of zones in the `zones` list.
3363    ///
3364    /// This field is automatically updated whenever the `zones` list changes.
3365    /// It provides a quick way to see how many zones are selecting this instance
3366    /// without having to count the array elements.
3367    #[serde(skip_serializing_if = "Option::is_none")]
3368    pub zones_count: Option<i32>,
3369
3370    /// RNDC key rotation status and tracking information.
3371    ///
3372    /// Populated when `auto_rotate` is enabled in the RNDC configuration. Provides
3373    /// visibility into key lifecycle: creation time, next rotation time, and rotation count.
3374    ///
3375    /// This field is automatically updated by the instance reconciler whenever:
3376    /// - A new RNDC key is generated
3377    /// - An RNDC key is rotated
3378    /// - The rotation configuration changes
3379    ///
3380    /// **Note**: Only present when using operator-managed RNDC keys. If you specify
3381    /// `secret_ref` to use an external Secret, this field will be empty.
3382    #[serde(skip_serializing_if = "Option::is_none")]
3383    pub rndc_key_rotation: Option<RndcKeyRotationStatus>,
3384}
3385
3386/// RNDC key rotation status and tracking information.
3387///
3388/// Tracks the lifecycle of operator-managed RNDC keys including creation time,
3389/// next rotation time, last rotation time, and rotation count.
3390///
3391/// This status is automatically updated by the instance reconciler whenever keys
3392/// are created or rotated. It provides visibility into key age and rotation history
3393/// for compliance and operational purposes.
3394///
3395/// # Examples
3396///
3397/// ```yaml
3398/// # Initial key creation (no rotation yet)
3399/// rndcKeyRotation:
3400///   createdAt: "2025-01-26T10:00:00Z"
3401///   rotateAt: "2025-02-25T10:00:00Z"
3402///   rotationCount: 0
3403///
3404/// # After first rotation
3405/// rndcKeyRotation:
3406///   createdAt: "2025-02-25T10:00:00Z"
3407///   rotateAt: "2025-03-27T10:00:00Z"
3408///   lastRotatedAt: "2025-02-25T10:00:00Z"
3409///   rotationCount: 1
3410/// ```
3411#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
3412#[serde(rename_all = "camelCase")]
3413pub struct RndcKeyRotationStatus {
3414    /// Timestamp when the current key was created (ISO 8601 format).
3415    ///
3416    /// This timestamp is set when:
3417    /// - A new RNDC key is generated for the first time
3418    /// - An existing key is rotated (timestamp updates to rotation time)
3419    ///
3420    /// Example: `"2025-01-26T10:00:00Z"`
3421    pub created_at: String,
3422
3423    /// Timestamp when the key will be rotated next (ISO 8601 format).
3424    ///
3425    /// Calculated as: `created_at + rotate_after`
3426    ///
3427    /// Only present if `auto_rotate` is enabled. When `auto_rotate` is disabled,
3428    /// this field will be empty as no automatic rotation is scheduled.
3429    ///
3430    /// Example: `"2025-02-25T10:00:00Z"` (30 days after creation)
3431    #[serde(skip_serializing_if = "Option::is_none")]
3432    pub rotate_at: Option<String>,
3433
3434    /// Timestamp of the last successful rotation (ISO 8601 format).
3435    ///
3436    /// Only present after at least one rotation has occurred. For newly-created
3437    /// keys that have never been rotated, this field will be empty.
3438    ///
3439    /// This is useful for tracking the actual rotation history and verifying that
3440    /// rotation is working as expected.
3441    ///
3442    /// Example: `"2025-02-25T10:00:00Z"`
3443    #[serde(skip_serializing_if = "Option::is_none")]
3444    pub last_rotated_at: Option<String>,
3445
3446    /// Number of times the key has been rotated.
3447    ///
3448    /// Starts at `0` for newly-created keys and increments by 1 each time the
3449    /// key is rotated. This counter persists across key rotations and provides
3450    /// a historical count for compliance and audit purposes.
3451    ///
3452    /// Example: `5` (key has been rotated 5 times)
3453    #[serde(default)]
3454    pub rotation_count: u32,
3455}
3456
3457/// Reference to a DNS zone selected by an instance.
3458///
3459/// This structure follows Kubernetes object reference conventions and stores
3460/// the complete information needed to reference a `DNSZone` resource.
3461///
3462/// **Note on Equality:** `PartialEq`, `Eq`, and `Hash` are implemented to compare only the
3463/// identity fields (`api_version`, `kind`, `name`, `namespace`, `zone_name`), ignoring `last_reconciled_at`.
3464/// This ensures that zones are correctly identified as duplicates even when their
3465/// reconciliation timestamps differ.
3466#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3467#[serde(rename_all = "camelCase")]
3468pub struct ZoneReference {
3469    /// API version of the `DNSZone` resource (e.g., "bindy.firestoned.io/v1beta1")
3470    pub api_version: String,
3471    /// Kind of the resource (always "`DNSZone`")
3472    pub kind: String,
3473    /// Name of the `DNSZone` resource
3474    pub name: String,
3475    /// Namespace of the `DNSZone` resource
3476    pub namespace: String,
3477    /// Fully qualified domain name from the zone's spec (e.g., "example.com")
3478    pub zone_name: String,
3479    /// Timestamp when this zone was last successfully configured on the instance.
3480    ///
3481    /// This field is set by the `DNSZone` controller after successfully applying zone configuration
3482    /// to the instance. It is reset to `None` when:
3483    /// - The instance's pod restarts (requiring zone reconfiguration)
3484    /// - The instance's spec changes (requiring reconfiguration)
3485    ///
3486    /// The `DNSZone` controller uses this field to determine which instances need zone configuration.
3487    /// If this field is `None`, the zone needs to be configured on the instance.
3488    #[serde(skip_serializing_if = "Option::is_none")]
3489    pub last_reconciled_at: Option<String>,
3490}
3491
3492// Implement PartialEq to compare only identity fields, ignoring last_reconciled_at
3493impl PartialEq for ZoneReference {
3494    fn eq(&self, other: &Self) -> bool {
3495        self.api_version == other.api_version
3496            && self.kind == other.kind
3497            && self.name == other.name
3498            && self.namespace == other.namespace
3499            && self.zone_name == other.zone_name
3500    }
3501}
3502
3503// Implement Eq for ZoneReference
3504impl Eq for ZoneReference {}
3505
3506// Implement Hash to hash only identity fields
3507impl std::hash::Hash for ZoneReference {
3508    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3509        self.api_version.hash(state);
3510        self.kind.hash(state);
3511        self.name.hash(state);
3512        self.namespace.hash(state);
3513        self.zone_name.hash(state);
3514        // Deliberately exclude last_reconciled_at from hash
3515    }
3516}
3517
3518/// Full Kubernetes object reference to a cluster resource.
3519///
3520/// This structure follows Kubernetes object reference conventions and stores
3521/// the complete information needed to reference either a namespace-scoped
3522/// `Bind9Cluster` or cluster-scoped `ClusterBind9Provider`.
3523///
3524/// This enables proper object references and provides backward compatibility
3525/// with `spec.clusterRef` (which stores only the name as a string).
3526#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
3527#[serde(rename_all = "camelCase")]
3528pub struct ClusterReference {
3529    /// API version of the referenced cluster (e.g., "bindy.firestoned.io/v1beta1")
3530    pub api_version: String,
3531    /// Kind of the referenced cluster ("`Bind9Cluster`" or "`ClusterBind9Provider`")
3532    pub kind: String,
3533    /// Name of the cluster resource
3534    pub name: String,
3535    /// Namespace of the cluster resource.
3536    ///
3537    /// For namespace-scoped `Bind9Cluster`, this is the cluster's namespace.
3538    /// For cluster-scoped `ClusterBind9Provider`, this field is empty/None.
3539    #[serde(skip_serializing_if = "Option::is_none")]
3540    pub namespace: Option<String>,
3541}
3542
3543/// Full Kubernetes object reference to a `Bind9Instance` resource.
3544///
3545/// This structure follows Kubernetes object reference conventions and stores
3546/// the complete information needed to reference a namespace-scoped `Bind9Instance`.
3547///
3548/// Used in `DNSZone.status.bind9Instances` for tracking instances that have claimed the zone
3549/// (via `bind9InstancesFrom` label selectors or `clusterRef`).
3550///
3551/// **Note on Equality:** `PartialEq`, `Eq`, and `Hash` are implemented to compare only the
3552/// identity fields (`api_version`, `kind`, `name`, `namespace`), ignoring `last_reconciled_at`.
3553/// This ensures that instances are correctly identified as duplicates even when their
3554/// reconciliation timestamps differ.
3555#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3556#[serde(rename_all = "camelCase")]
3557pub struct InstanceReference {
3558    /// API version of the `Bind9Instance` resource (e.g., "bindy.firestoned.io/v1beta1")
3559    pub api_version: String,
3560    /// Kind of the resource (always "`Bind9Instance`")
3561    pub kind: String,
3562    /// Name of the `Bind9Instance` resource
3563    pub name: String,
3564    /// Namespace of the `Bind9Instance` resource
3565    pub namespace: String,
3566    /// Timestamp when this instance was last successfully reconciled with zone configuration.
3567    ///
3568    /// This field is set when the zone configuration is successfully applied to the instance.
3569    /// It is reset (cleared) when:
3570    /// - The instance is deleted
3571    /// - The instance's pod IP changes (requiring zone reconfiguration)
3572    /// - The zone spec changes (requiring reconfiguration)
3573    ///
3574    /// The reconciler uses this field to determine which instances need zone configuration.
3575    /// If this field is `None` or the timestamp is before the last spec change, the instance
3576    /// will be reconfigured.
3577    #[serde(skip_serializing_if = "Option::is_none")]
3578    pub last_reconciled_at: Option<String>,
3579}
3580
3581// Implement PartialEq to compare only identity fields, ignoring last_reconciled_at
3582impl PartialEq for InstanceReference {
3583    fn eq(&self, other: &Self) -> bool {
3584        self.api_version == other.api_version
3585            && self.kind == other.kind
3586            && self.name == other.name
3587            && self.namespace == other.namespace
3588    }
3589}
3590
3591// Implement Eq for InstanceReference
3592impl Eq for InstanceReference {}
3593
3594// Implement Hash to hash only identity fields
3595impl std::hash::Hash for InstanceReference {
3596    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3597        self.api_version.hash(state);
3598        self.kind.hash(state);
3599        self.name.hash(state);
3600        self.namespace.hash(state);
3601        self.last_reconciled_at.hash(state);
3602    }
3603}
3604
3605/// Storage configuration for zone files
3606#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3607#[serde(rename_all = "camelCase")]
3608pub struct StorageConfig {
3609    /// Storage type (emptyDir or persistentVolumeClaim)
3610    #[serde(default = "default_storage_type")]
3611    pub storage_type: StorageType,
3612
3613    /// `EmptyDir` configuration (used when storageType is emptyDir)
3614    #[serde(skip_serializing_if = "Option::is_none")]
3615    pub empty_dir: Option<k8s_openapi::api::core::v1::EmptyDirVolumeSource>,
3616
3617    /// `PersistentVolumeClaim` configuration (used when storageType is persistentVolumeClaim)
3618    #[serde(skip_serializing_if = "Option::is_none")]
3619    pub persistent_volume_claim: Option<PersistentVolumeClaimConfig>,
3620}
3621
3622fn default_storage_type() -> StorageType {
3623    StorageType::EmptyDir
3624}
3625
3626/// Storage type for zone files
3627#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
3628#[serde(rename_all = "camelCase")]
3629pub enum StorageType {
3630    /// Ephemeral storage (default) - data is lost when pod restarts
3631    EmptyDir,
3632    /// Persistent storage - data survives pod restarts
3633    PersistentVolumeClaim,
3634}
3635
3636/// `PersistentVolumeClaim` configuration
3637#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3638#[serde(rename_all = "camelCase")]
3639pub struct PersistentVolumeClaimConfig {
3640    /// Name of an existing PVC to use
3641    #[serde(skip_serializing_if = "Option::is_none")]
3642    pub claim_name: Option<String>,
3643
3644    /// Storage class name for dynamic provisioning
3645    #[serde(skip_serializing_if = "Option::is_none")]
3646    pub storage_class_name: Option<String>,
3647
3648    /// Storage size (e.g., "10Gi", "1Ti")
3649    #[serde(skip_serializing_if = "Option::is_none")]
3650    pub size: Option<String>,
3651
3652    /// Access modes (`ReadWriteOnce`, `ReadOnlyMany`, `ReadWriteMany`)
3653    #[serde(skip_serializing_if = "Option::is_none")]
3654    pub access_modes: Option<Vec<String>>,
3655}
3656
3657/// Bindcar container configuration
3658#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3659#[serde(rename_all = "camelCase")]
3660pub struct BindcarConfig {
3661    /// Container image for the RNDC API sidecar
3662    ///
3663    /// Example: "ghcr.io/firestoned/bindcar:v0.6.0"
3664    #[serde(skip_serializing_if = "Option::is_none")]
3665    pub image: Option<String>,
3666
3667    /// Image pull policy (`Always`, `IfNotPresent`, `Never`)
3668    #[serde(skip_serializing_if = "Option::is_none")]
3669    pub image_pull_policy: Option<String>,
3670
3671    /// Resource requirements for the Bindcar container
3672    #[serde(skip_serializing_if = "Option::is_none")]
3673    pub resources: Option<k8s_openapi::api::core::v1::ResourceRequirements>,
3674
3675    /// API server container port (default: 8080)
3676    #[serde(skip_serializing_if = "Option::is_none")]
3677    pub port: Option<i32>,
3678
3679    /// Custom Kubernetes Service spec for the bindcar HTTP API
3680    ///
3681    /// Allows full customization of the Service that exposes the bindcar API.
3682    /// This is merged with the default Service spec, allowing overrides of ports,
3683    /// type, sessionAffinity, and other Service configurations.
3684    ///
3685    /// Example:
3686    /// ```yaml
3687    /// serviceSpec:
3688    ///   type: NodePort
3689    ///   ports:
3690    ///     - name: http
3691    ///       port: 8000
3692    ///       targetPort: 8080
3693    ///       nodePort: 30080
3694    /// ```
3695    #[serde(skip_serializing_if = "Option::is_none")]
3696    pub service_spec: Option<k8s_openapi::api::core::v1::ServiceSpec>,
3697
3698    /// Log level for the Bindcar container (`debug`, `info`, `warn`, `error`)
3699    #[serde(skip_serializing_if = "Option::is_none")]
3700    pub log_level: Option<String>,
3701
3702    /// Environment variables for the Bindcar container
3703    #[serde(skip_serializing_if = "Option::is_none")]
3704    pub env_vars: Option<Vec<EnvVar>>,
3705    // NOTE: `volumes` and `volume_mounts` were removed in v0.5.1 (audit
3706    // finding F-001 mitigation). They were declared on `BindcarConfig` but
3707    // never plumbed into `build_api_sidecar_container`, so removing them is
3708    // a no-op for the runtime and prevents a future "wire these through"
3709    // change from re-introducing the unfiltered Volume / VolumeMount
3710    // priv-esc primitive. Use `Bind9Instance.spec.volumes` /
3711    // `volumeMounts` (validated by `crate::safe_volume`) for any genuine
3712    // need to mount additional storage into the BIND9 pod.
3713}