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    ///
1197    /// The name is restricted to a safe identifier set (`[A-Za-z0-9_-]`, starting
1198    /// alphanumeric, max 63 chars) because it is interpolated into a quoted BIND9
1199    /// configuration literal — `"`, `;`, `{`, `}` would allow config injection (B-6).
1200    #[serde(default, skip_serializing_if = "Option::is_none")]
1201    #[schemars(regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$"))]
1202    pub dnssec_policy: Option<String>,
1203}
1204
1205/// `ARecord` maps a DNS name to an IPv4 address.
1206///
1207/// A records are the most common DNS record type, mapping hostnames to IPv4 addresses.
1208/// Multiple A records can exist for the same name (round-robin DNS).
1209///
1210/// # Example
1211///
1212/// ```yaml
1213/// apiVersion: bindy.firestoned.io/v1beta1
1214/// kind: ARecord
1215/// metadata:
1216///   name: www-example-com
1217///   namespace: bindy-system
1218///   labels:
1219///     zone: example.com
1220/// spec:
1221///   name: www
1222///   ipv4Address: 192.0.2.1
1223///   ttl: 300
1224/// ```
1225///
1226/// Records are associated with `DNSZones` via label selectors.
1227/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1228#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1229#[kube(
1230    group = "bindy.firestoned.io",
1231    version = "v1beta1",
1232    kind = "ARecord",
1233    namespaced,
1234    shortname = "a",
1235    doc = "ARecord maps a DNS hostname to an IPv4 address. Multiple A records for the same name enable round-robin DNS load balancing.",
1236    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1237    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1238    printcolumn = r#"{"name":"Addresses","type":"string","jsonPath":".status.addresses"}"#,
1239    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1240    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1241)]
1242#[kube(status = "RecordStatus")]
1243#[serde(rename_all = "camelCase")]
1244pub struct ARecordSpec {
1245    /// Record name within the zone. Use "@" for the zone apex.
1246    ///
1247    /// Examples: "www", "mail", "ftp", "@"
1248    /// The full DNS name will be: {name}.{zone}
1249    pub name: String,
1250
1251    /// List of IPv4 addresses for this DNS record.
1252    ///
1253    /// Multiple addresses create round-robin DNS (load balancing).
1254    /// All addresses in the list belong to the same DNS name.
1255    ///
1256    /// Must contain at least one valid IPv4 address in dotted-decimal notation.
1257    ///
1258    /// Examples: `["192.0.2.1"]`, `["192.0.2.1", "192.0.2.2", "192.0.2.3"]`
1259    #[schemars(length(min = 1))]
1260    pub ipv4_addresses: Vec<String>,
1261
1262    /// Time To Live in seconds. Overrides zone default TTL if specified.
1263    ///
1264    /// Typical values: 60-86400 (1 minute to 1 day).
1265    #[serde(default)]
1266    #[schemars(range(min = 0, max = 2_147_483_647))]
1267    pub ttl: Option<i32>,
1268}
1269
1270/// `AAAARecord` maps a DNS name to an IPv6 address.
1271///
1272/// AAAA records are the IPv6 equivalent of A records, mapping hostnames to IPv6 addresses.
1273///
1274/// # Example
1275///
1276/// ```yaml
1277/// apiVersion: bindy.firestoned.io/v1beta1
1278/// kind: AAAARecord
1279/// metadata:
1280///   name: www-example-com-ipv6
1281///   namespace: bindy-system
1282///   labels:
1283///     zone: example.com
1284/// spec:
1285///   name: www
1286///   ipv6Address: "2001:db8::1"
1287///   ttl: 300
1288/// ```
1289///
1290/// Records are associated with `DNSZones` via label selectors.
1291/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1292#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1293#[kube(
1294    group = "bindy.firestoned.io",
1295    version = "v1beta1",
1296    kind = "AAAARecord",
1297    namespaced,
1298    shortname = "aaaa",
1299    doc = "AAAARecord maps a DNS hostname to an IPv6 address. This is the IPv6 equivalent of an A record.",
1300    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1301    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1302    printcolumn = r#"{"name":"Addresses","type":"string","jsonPath":".status.addresses"}"#,
1303    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1304    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1305)]
1306#[kube(status = "RecordStatus")]
1307#[serde(rename_all = "camelCase")]
1308pub struct AAAARecordSpec {
1309    /// Record name within the zone.
1310    pub name: String,
1311
1312    /// List of IPv6 addresses for this DNS record.
1313    ///
1314    /// Multiple addresses create round-robin DNS (load balancing).
1315    /// All addresses in the list belong to the same DNS name.
1316    ///
1317    /// Must contain at least one valid IPv6 address in standard notation.
1318    ///
1319    /// Examples: `["2001:db8::1"]`, `["2001:db8::1", "2001:db8::2"]`
1320    #[schemars(length(min = 1))]
1321    pub ipv6_addresses: Vec<String>,
1322
1323    /// Time To Live in seconds.
1324    #[serde(default)]
1325    #[schemars(range(min = 0, max = 2_147_483_647))]
1326    pub ttl: Option<i32>,
1327}
1328
1329/// `TXTRecord` holds arbitrary text data.
1330///
1331/// TXT records are commonly used for SPF, DKIM, DMARC, domain verification,
1332/// and other text-based metadata.
1333///
1334/// # Example
1335///
1336/// ```yaml
1337/// apiVersion: bindy.firestoned.io/v1beta1
1338/// kind: TXTRecord
1339/// metadata:
1340///   name: spf-example-com
1341///   namespace: bindy-system
1342///   labels:
1343///     zone: example.com
1344/// spec:
1345///   name: "@"
1346///   text:
1347///     - "v=spf1 include:_spf.google.com ~all"
1348///   ttl: 3600
1349/// ```
1350///
1351/// Records are associated with `DNSZones` via label selectors.
1352/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1353#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1354#[kube(
1355    group = "bindy.firestoned.io",
1356    version = "v1beta1",
1357    kind = "TXTRecord",
1358    namespaced,
1359    shortname = "txt",
1360    doc = "TXTRecord stores arbitrary text data in DNS. Commonly used for SPF, DKIM, DMARC policies, and domain verification.",
1361    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1362    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1363    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1364    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1365)]
1366#[kube(status = "RecordStatus")]
1367#[serde(rename_all = "camelCase")]
1368pub struct TXTRecordSpec {
1369    /// Record name within the zone.
1370    pub name: String,
1371
1372    /// Array of text strings. Each string can be up to 255 characters.
1373    ///
1374    /// Multiple strings are concatenated by DNS resolvers.
1375    /// For long text, split into multiple strings.
1376    pub text: Vec<String>,
1377
1378    /// Time To Live in seconds.
1379    #[serde(default)]
1380    #[schemars(range(min = 0, max = 2_147_483_647))]
1381    pub ttl: Option<i32>,
1382}
1383
1384/// `CNAMERecord` creates an alias from one name to another.
1385///
1386/// CNAME (Canonical Name) records create an alias from one DNS name to another.
1387/// The target can be in the same zone or a different zone.
1388///
1389/// **Important**: A CNAME cannot coexist with other record types for the same name.
1390///
1391/// # Example
1392///
1393/// ```yaml
1394/// apiVersion: bindy.firestoned.io/v1beta1
1395/// kind: CNAMERecord
1396/// metadata:
1397///   name: blog-example-com
1398///   namespace: bindy-system
1399///   labels:
1400///     zone: example.com
1401/// spec:
1402///   name: blog
1403///   target: example.github.io.
1404///   ttl: 3600
1405/// ```
1406///
1407/// Records are associated with `DNSZones` via label selectors.
1408/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1409#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1410#[kube(
1411    group = "bindy.firestoned.io",
1412    version = "v1beta1",
1413    kind = "CNAMERecord",
1414    namespaced,
1415    shortname = "cname",
1416    doc = "CNAMERecord creates a DNS alias from one hostname to another. A CNAME cannot coexist with other record types for the same name.",
1417    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1418    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1419    printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.target"}"#,
1420    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1421    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1422)]
1423#[kube(status = "RecordStatus")]
1424#[serde(rename_all = "camelCase")]
1425pub struct CNAMERecordSpec {
1426    /// Record name within the zone.
1427    ///
1428    /// Note: CNAME records cannot be created at the zone apex (@).
1429    pub name: String,
1430
1431    /// Target hostname (canonical name).
1432    ///
1433    /// Should be a fully qualified domain name ending with a dot.
1434    /// Example: "example.com." or "www.example.com."
1435    pub target: String,
1436
1437    /// Time To Live in seconds.
1438    #[serde(default)]
1439    #[schemars(range(min = 0, max = 2_147_483_647))]
1440    pub ttl: Option<i32>,
1441}
1442
1443/// `MXRecord` specifies mail servers for a domain.
1444///
1445/// MX (Mail Exchange) records specify the mail servers responsible for accepting email
1446/// for a domain. Lower priority values indicate higher preference.
1447///
1448/// # Example
1449///
1450/// ```yaml
1451/// apiVersion: bindy.firestoned.io/v1beta1
1452/// kind: MXRecord
1453/// metadata:
1454///   name: mail-example-com
1455///   namespace: bindy-system
1456///   labels:
1457///     zone: example.com
1458/// spec:
1459///   name: "@"
1460///   priority: 10
1461///   mailServer: mail.example.com.
1462///   ttl: 3600
1463/// ```
1464///
1465/// Records are associated with `DNSZones` via label selectors.
1466/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1467#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1468#[kube(
1469    group = "bindy.firestoned.io",
1470    version = "v1beta1",
1471    kind = "MXRecord",
1472    namespaced,
1473    shortname = "mx",
1474    doc = "MXRecord specifies mail exchange servers for a domain. Lower priority values indicate higher preference for mail delivery.",
1475    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1476    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1477    printcolumn = r#"{"name":"Priority","type":"integer","jsonPath":".spec.priority"}"#,
1478    printcolumn = r#"{"name":"Mail Server","type":"string","jsonPath":".spec.mailServer"}"#,
1479    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1480    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1481)]
1482#[kube(status = "RecordStatus")]
1483#[serde(rename_all = "camelCase")]
1484pub struct MXRecordSpec {
1485    /// Record name within the zone. Use "@" for the zone apex.
1486    pub name: String,
1487
1488    /// Priority (preference) of this mail server. Lower values = higher priority.
1489    ///
1490    /// Common values: 0-100. Multiple MX records can exist with different priorities.
1491    #[schemars(range(min = 0, max = 65535))]
1492    pub priority: i32,
1493
1494    /// Fully qualified domain name of the mail server.
1495    ///
1496    /// Must end with a dot. Example: "mail.example.com."
1497    pub mail_server: String,
1498
1499    /// Time To Live in seconds.
1500    #[serde(default)]
1501    #[schemars(range(min = 0, max = 2_147_483_647))]
1502    pub ttl: Option<i32>,
1503}
1504
1505/// `NSRecord` delegates a subdomain to other nameservers.
1506///
1507/// NS (Nameserver) records specify which DNS servers are authoritative for a subdomain.
1508/// They are used for delegating subdomains to different nameservers.
1509///
1510/// # Example
1511///
1512/// ```yaml
1513/// apiVersion: bindy.firestoned.io/v1beta1
1514/// kind: NSRecord
1515/// metadata:
1516///   name: subdomain-ns
1517///   namespace: bindy-system
1518///   labels:
1519///     zone: example.com
1520/// spec:
1521///   name: subdomain
1522///   nameserver: ns1.other-provider.com.
1523///   ttl: 86400
1524/// ```
1525///
1526/// Records are associated with `DNSZones` via label selectors.
1527/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1528#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1529#[kube(
1530    group = "bindy.firestoned.io",
1531    version = "v1beta1",
1532    kind = "NSRecord",
1533    namespaced,
1534    shortname = "ns",
1535    doc = "NSRecord delegates a subdomain to authoritative nameservers. Used for subdomain delegation to different DNS providers or servers.",
1536    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1537    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1538    printcolumn = r#"{"name":"Nameserver","type":"string","jsonPath":".spec.nameserver"}"#,
1539    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1540    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1541)]
1542#[kube(status = "RecordStatus")]
1543#[serde(rename_all = "camelCase")]
1544pub struct NSRecordSpec {
1545    /// Subdomain to delegate. For zone apex, use "@".
1546    pub name: String,
1547
1548    /// Fully qualified domain name of the nameserver.
1549    ///
1550    /// Must end with a dot. Example: "ns1.example.com."
1551    pub nameserver: String,
1552
1553    /// Time To Live in seconds.
1554    #[serde(default)]
1555    #[schemars(range(min = 0, max = 2_147_483_647))]
1556    pub ttl: Option<i32>,
1557}
1558
1559/// `SRVRecord` specifies the location of services.
1560///
1561/// SRV (Service) records specify the hostname and port of servers for specific services.
1562/// The name format is: _service._proto (e.g., _ldap._tcp, _sip._udp).
1563///
1564/// # Example
1565///
1566/// ```yaml
1567/// apiVersion: bindy.firestoned.io/v1beta1
1568/// kind: SRVRecord
1569/// metadata:
1570///   name: ldap-srv
1571///   namespace: bindy-system
1572///   labels:
1573///     zone: example.com
1574/// spec:
1575///   name: _ldap._tcp
1576///   priority: 10
1577///   weight: 60
1578///   port: 389
1579///   target: ldap.example.com.
1580///   ttl: 3600
1581/// ```
1582///
1583/// Records are associated with `DNSZones` via label selectors.
1584/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1585#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1586#[kube(
1587    group = "bindy.firestoned.io",
1588    version = "v1beta1",
1589    kind = "SRVRecord",
1590    namespaced,
1591    shortname = "srv",
1592    doc = "SRVRecord specifies the hostname and port of servers for specific services. The record name follows the format _service._proto (e.g., _ldap._tcp).",
1593    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1594    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1595    printcolumn = r#"{"name":"Target","type":"string","jsonPath":".spec.target"}"#,
1596    printcolumn = r#"{"name":"Port","type":"integer","jsonPath":".spec.port"}"#,
1597    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1598    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1599)]
1600#[kube(status = "RecordStatus")]
1601#[serde(rename_all = "camelCase")]
1602pub struct SRVRecordSpec {
1603    /// Service and protocol in the format: _service._proto
1604    ///
1605    /// Example: "_ldap._tcp", "_sip._udp", "_http._tcp"
1606    pub name: String,
1607
1608    /// Priority of the target host. Lower values = higher priority.
1609    #[schemars(range(min = 0, max = 65535))]
1610    pub priority: i32,
1611
1612    /// Relative weight for records with the same priority.
1613    ///
1614    /// Higher values = higher probability of selection.
1615    #[schemars(range(min = 0, max = 65535))]
1616    pub weight: i32,
1617
1618    /// TCP or UDP port where the service is available.
1619    #[schemars(range(min = 0, max = 65535))]
1620    pub port: i32,
1621
1622    /// Fully qualified domain name of the target host.
1623    ///
1624    /// Must end with a dot. Use "." for "service not available".
1625    pub target: String,
1626
1627    /// Time To Live in seconds.
1628    #[serde(default)]
1629    #[schemars(range(min = 0, max = 2_147_483_647))]
1630    pub ttl: Option<i32>,
1631}
1632
1633/// `CAARecord` specifies Certificate Authority Authorization.
1634///
1635/// CAA (Certification Authority Authorization) records specify which certificate
1636/// authorities are allowed to issue certificates for a domain.
1637///
1638/// # Example
1639///
1640/// ```yaml
1641/// apiVersion: bindy.firestoned.io/v1beta1
1642/// kind: CAARecord
1643/// metadata:
1644///   name: caa-letsencrypt
1645///   namespace: bindy-system
1646///   labels:
1647///     zone: example.com
1648/// spec:
1649///   name: "@"
1650///   flags: 0
1651///   tag: issue
1652///   value: letsencrypt.org
1653///   ttl: 86400
1654/// ```
1655///
1656/// Records are associated with `DNSZones` via label selectors.
1657/// The `DNSZone` must have a `recordsFrom` selector that matches this record's labels.
1658#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
1659#[kube(
1660    group = "bindy.firestoned.io",
1661    version = "v1beta1",
1662    kind = "CAARecord",
1663    namespaced,
1664    shortname = "caa",
1665    doc = "CAARecord specifies which certificate authorities are authorized to issue certificates for a domain. Enhances domain security and certificate issuance control.",
1666    printcolumn = r#"{"name":"Name","type":"string","jsonPath":".spec.name"}"#,
1667    printcolumn = r#"{"name":"Zone","type":"string","jsonPath":".status.zoneRef.zoneName"}"#,
1668    printcolumn = r#"{"name":"Tag","type":"string","jsonPath":".spec.tag"}"#,
1669    printcolumn = r#"{"name":"Value","type":"string","jsonPath":".spec.value"}"#,
1670    printcolumn = r#"{"name":"TTL","type":"integer","jsonPath":".spec.ttl"}"#,
1671    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
1672)]
1673#[kube(status = "RecordStatus")]
1674#[serde(rename_all = "camelCase")]
1675pub struct CAARecordSpec {
1676    /// Record name within the zone. Use "@" for the zone apex.
1677    pub name: String,
1678
1679    /// Flags byte. Use 0 for non-critical, 128 for critical.
1680    ///
1681    /// Critical flag (128) means CAs must understand the tag.
1682    #[schemars(range(min = 0, max = 255))]
1683    pub flags: i32,
1684
1685    /// Property tag. Common values: "issue", "issuewild", "iodef".
1686    ///
1687    /// - "issue": Authorize CA to issue certificates
1688    /// - "issuewild": Authorize CA to issue wildcard certificates
1689    /// - "iodef": URL/email for violation reports
1690    pub tag: String,
1691
1692    /// Property value. Format depends on the tag.
1693    ///
1694    /// For "issue"/"issuewild": CA domain (e.g., "letsencrypt.org")
1695    /// For "iodef": mailto: or https: URL
1696    pub value: String,
1697
1698    /// Time To Live in seconds.
1699    #[serde(default)]
1700    #[schemars(range(min = 0, max = 2_147_483_647))]
1701    pub ttl: Option<i32>,
1702}
1703
1704/// Generic record status
1705#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
1706#[serde(rename_all = "camelCase")]
1707pub struct RecordStatus {
1708    #[serde(default)]
1709    pub conditions: Vec<Condition>,
1710    #[serde(skip_serializing_if = "Option::is_none")]
1711    pub observed_generation: Option<i64>,
1712    /// The FQDN of the zone that owns this record (set by `DNSZone` controller).
1713    ///
1714    /// When a `DNSZone`'s label selector matches this record, the `DNSZone` controller
1715    /// sets this field to the zone's FQDN (e.g., `"example.com"`). The record reconciler
1716    /// uses this to determine which zone to update in BIND9.
1717    ///
1718    /// If this field is empty, the record is not matched by any zone and should not
1719    /// be reconciled into BIND9.
1720    ///
1721    /// **DEPRECATED**: Use `zone_ref` instead for structured zone reference.
1722    #[deprecated(
1723        since = "0.2.0",
1724        note = "Use zone_ref instead for structured zone reference"
1725    )]
1726    #[serde(skip_serializing_if = "Option::is_none")]
1727    pub zone: Option<String>,
1728    /// Structured reference to the `DNSZone` that owns this record.
1729    ///
1730    /// Set by the `DNSZone` controller when the zone's `recordsFrom` selector matches
1731    /// this record's labels. Contains the complete Kubernetes object reference including
1732    /// apiVersion, kind, name, namespace, and zoneName.
1733    ///
1734    /// The record reconciler uses this to:
1735    /// 1. Look up the parent `DNSZone` resource
1736    /// 2. Find the zone's primary `Bind9Instance` servers
1737    /// 3. Add this record to BIND9 on primaries
1738    /// 4. Trigger zone transfer (retransfer) on secondaries
1739    ///
1740    /// If this field is None, the record is not selected by any zone and will not
1741    /// be added to BIND9.
1742    #[serde(skip_serializing_if = "Option::is_none")]
1743    pub zone_ref: Option<ZoneReference>,
1744    /// SHA-256 hash of the record's spec data.
1745    ///
1746    /// Used to detect when a record's data has actually changed, avoiding
1747    /// unnecessary BIND9 updates and zone transfers.
1748    ///
1749    /// The hash is calculated from all fields in the record's spec that affect
1750    /// the DNS record data (name, addresses, TTL, etc.).
1751    #[serde(skip_serializing_if = "Option::is_none")]
1752    pub record_hash: Option<String>,
1753    /// Timestamp of the last successful update to BIND9.
1754    ///
1755    /// This is updated after a successful nsupdate operation.
1756    /// Uses RFC 3339 format (e.g., "2025-12-26T10:30:00Z").
1757    #[serde(skip_serializing_if = "Option::is_none")]
1758    pub last_updated: Option<String>,
1759    /// Comma-separated list of addresses for display purposes.
1760    ///
1761    /// For `ARecord` and `AAAARecord` resources, this field contains the IP addresses
1762    /// from `spec.ipv4Addresses` or `spec.ipv6Addresses` joined with commas.
1763    /// This is used for prettier kubectl output instead of showing JSON arrays.
1764    ///
1765    /// Example: "192.0.2.1,192.0.2.2,192.0.2.3"
1766    ///
1767    /// For other record types, this field is not used.
1768    #[serde(skip_serializing_if = "Option::is_none")]
1769    pub addresses: Option<String>,
1770    /// DNS record name (from `spec.name`) most recently published to BIND9.
1771    ///
1772    /// Set by the record reconciler after a successful dynamic DNS update.
1773    /// When `spec.name` changes (a rename), the reconciler compares it against
1774    /// this field, deletes the old FQDN from the zone, publishes the new name,
1775    /// and then updates this field. Without it, renamed records would leave
1776    /// their old FQDN orphaned in BIND9.
1777    #[serde(skip_serializing_if = "Option::is_none")]
1778    pub published_name: Option<String>,
1779}
1780
1781/// RNDC/TSIG algorithm for authenticated communication and zone transfers.
1782///
1783/// These HMAC algorithms are supported by BIND9 for securing RNDC communication
1784/// and zone transfers (AXFR/IXFR).
1785///
1786/// HMAC-MD5 was intentionally removed: RFC 8945 §10 deprecates it and it is
1787/// cryptographically broken. CRDs specifying `hmac-md5` now fail CRD
1788/// validation rather than produce a weakly authenticated operator.
1789#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
1790#[serde(rename_all = "kebab-case")]
1791pub enum RndcAlgorithm {
1792    /// HMAC-SHA1
1793    HmacSha1,
1794    /// HMAC-SHA224
1795    HmacSha224,
1796    /// HMAC-SHA256 (recommended)
1797    #[default]
1798    HmacSha256,
1799    /// HMAC-SHA384
1800    HmacSha384,
1801    /// HMAC-SHA512
1802    HmacSha512,
1803}
1804
1805impl RndcAlgorithm {
1806    /// Convert enum to string representation expected by BIND9
1807    #[must_use]
1808    pub fn as_str(&self) -> &'static str {
1809        match self {
1810            Self::HmacSha1 => "hmac-sha1",
1811            Self::HmacSha224 => "hmac-sha224",
1812            Self::HmacSha256 => "hmac-sha256",
1813            Self::HmacSha384 => "hmac-sha384",
1814            Self::HmacSha512 => "hmac-sha512",
1815        }
1816    }
1817
1818    /// Convert enum to string format expected by the rndc Rust crate.
1819    ///
1820    /// The rndc crate expects algorithm strings without the "hmac-" prefix
1821    /// (e.g., "sha256" instead of "hmac-sha256").
1822    #[must_use]
1823    pub fn as_rndc_str(&self) -> &'static str {
1824        match self {
1825            Self::HmacSha1 => "sha1",
1826            Self::HmacSha224 => "sha224",
1827            Self::HmacSha256 => "sha256",
1828            Self::HmacSha384 => "sha384",
1829            Self::HmacSha512 => "sha512",
1830        }
1831    }
1832}
1833
1834/// Reference to a Kubernetes Secret containing RNDC/TSIG credentials.
1835///
1836/// This allows you to use an existing external Secret for RNDC authentication instead
1837/// of having the operator auto-generate one. The Secret is mounted as a directory at
1838/// `/etc/bind/keys/` in the BIND9 container, and BIND9 uses the `rndc.key` file.
1839///
1840/// # External (User-Managed) Secrets
1841///
1842/// For external secrets, you ONLY need to provide the `rndc.key` field containing
1843/// the complete BIND9 key file content. The other fields (`key-name`, `algorithm`,
1844/// `secret`) are optional metadata used by operator-generated secrets.
1845///
1846/// ## Minimal External Secret Example
1847///
1848/// ```yaml
1849/// apiVersion: v1
1850/// kind: Secret
1851/// metadata:
1852///   name: my-rndc-key
1853///   namespace: bindy-system
1854/// type: Opaque
1855/// stringData:
1856///   rndc.key: |
1857///     key "bindy-operator" {
1858///         algorithm hmac-sha256;
1859///         secret "base64EncodedSecretKeyMaterial==";
1860///     };
1861/// ```
1862///
1863/// # Auto-Generated (Operator-Managed) Secrets
1864///
1865/// When the operator auto-generates a Secret (no `rndcSecretRef` specified), it
1866/// creates a Secret with all 4 fields for internal metadata tracking:
1867///
1868/// ```yaml
1869/// apiVersion: v1
1870/// kind: Secret
1871/// metadata:
1872///   name: bind9-instance-rndc
1873///   namespace: bindy-system
1874/// type: Opaque
1875/// stringData:
1876///   key-name: "bindy-operator"     # Operator metadata
1877///   algorithm: "hmac-sha256"       # Operator metadata
1878///   secret: "randomBase64Key=="    # Operator metadata
1879///   rndc.key: |                    # Used by BIND9
1880///     key "bindy-operator" {
1881///         algorithm hmac-sha256;
1882///         secret "randomBase64Key==";
1883///     };
1884/// ```
1885///
1886/// # Using with `Bind9Instance`
1887///
1888/// ```yaml
1889/// apiVersion: bindy.firestoned.io/v1beta1
1890/// kind: Bind9Instance
1891/// metadata:
1892///   name: production-dns-primary-0
1893/// spec:
1894///   clusterRef: production-dns
1895///   role: primary
1896///   rndcSecretRef:
1897///     name: my-rndc-key
1898///     algorithm: hmac-sha256
1899/// ```
1900///
1901/// # How It Works
1902///
1903/// When the Secret is mounted at `/etc/bind/keys/`, Kubernetes creates individual
1904/// files for each Secret key:
1905/// - `/etc/bind/keys/rndc.key` (the BIND9 key file) ← **This is what BIND9 uses**
1906/// - `/etc/bind/keys/key-name` (optional metadata for operator-generated secrets)
1907/// - `/etc/bind/keys/algorithm` (optional metadata for operator-generated secrets)
1908/// - `/etc/bind/keys/secret` (optional metadata for operator-generated secrets)
1909///
1910/// The `rndc.conf` file includes `/etc/bind/keys/rndc.key`, so BIND9 only needs
1911/// that one file to exist
1912#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
1913#[serde(rename_all = "camelCase")]
1914pub struct RndcSecretRef {
1915    /// Name of the Kubernetes Secret containing RNDC credentials
1916    pub name: String,
1917
1918    /// HMAC algorithm for this key
1919    #[serde(default)]
1920    pub algorithm: RndcAlgorithm,
1921
1922    /// Key within the secret for the key name (default: "key-name")
1923    #[serde(default = "default_key_name_key")]
1924    pub key_name_key: String,
1925
1926    /// Key within the secret for the secret value (default: "secret")
1927    #[serde(default = "default_secret_key")]
1928    pub secret_key: String,
1929}
1930
1931fn default_key_name_key() -> String {
1932    "key-name".to_string()
1933}
1934
1935fn default_secret_key() -> String {
1936    "secret".to_string()
1937}
1938
1939fn default_rotate_after() -> String {
1940    crate::constants::DEFAULT_ROTATION_INTERVAL.to_string()
1941}
1942
1943fn default_secret_type() -> String {
1944    "Opaque".to_string()
1945}
1946
1947/// RNDC key lifecycle configuration with automatic rotation support.
1948///
1949/// Provides three configuration modes:
1950/// 1. **Auto-generated with optional rotation** (default) - Operator creates and manages keys
1951/// 2. **Reference to existing Secret** - Use pre-existing Kubernetes Secret (no rotation)
1952/// 3. **Inline Secret specification** - Define Secret inline with optional rotation
1953///
1954/// When `auto_rotate` is enabled, the operator automatically rotates keys after the
1955/// `rotate_after` duration has elapsed. Rotation timestamps are tracked in Secret annotations.
1956///
1957/// # Examples
1958///
1959/// ```yaml
1960/// # Auto-generated with 30-day rotation
1961/// rndcKeys:
1962///   autoRotate: true
1963///   rotateAfter: 720h
1964///   algorithm: hmac-sha256
1965///
1966/// # Reference existing Secret (no rotation)
1967/// rndcKeys:
1968///   secretRef:
1969///     name: my-rndc-key
1970///     algorithm: hmac-sha256
1971///
1972/// # Inline Secret with rotation
1973/// rndcKeys:
1974///   autoRotate: true
1975///   rotateAfter: 2160h  # 90 days
1976///   secret:
1977///     metadata:
1978///       name: custom-rndc-key
1979///       labels:
1980///         app: bindy
1981/// ```
1982#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
1983#[serde(rename_all = "camelCase")]
1984pub struct RndcKeyConfig {
1985    /// Enable automatic key rotation (default: false for backward compatibility).
1986    ///
1987    /// When `true`, the operator automatically rotates the RNDC key after the
1988    /// `rotate_after` interval. When `false`, keys are generated once and never rotated.
1989    ///
1990    /// **Important**: Rotation only applies to operator-managed Secrets. If you
1991    /// specify `secret_ref`, that Secret will NOT be rotated automatically.
1992    ///
1993    /// Default: `false`
1994    #[serde(default)]
1995    pub auto_rotate: bool,
1996
1997    /// Duration after which to rotate the key (Go duration format: "720h", "30d").
1998    ///
1999    /// Supported units:
2000    /// - `h` (hours): "720h" = 30 days
2001    /// - `d` (days): "30d" = 30 days
2002    /// - `w` (weeks): "4w" = 28 days
2003    ///
2004    /// Constraints:
2005    /// - Minimum: 1h (1 hour)
2006    /// - Maximum: 8760h (365 days / 1 year)
2007    ///
2008    /// Only applies when `auto_rotate` is `true`.
2009    ///
2010    /// Default: `"720h"` (30 days)
2011    #[serde(default = "default_rotate_after")]
2012    pub rotate_after: String,
2013
2014    /// Reference to an existing Kubernetes Secret containing RNDC credentials.
2015    ///
2016    /// When specified, the operator uses this existing Secret instead of auto-generating
2017    /// one. The Secret must contain the `rndc.key` field with BIND9 key file content.
2018    ///
2019    /// **Mutually exclusive with `secret`** - if both are specified, `secret_ref` takes
2020    /// precedence and `secret` is ignored.
2021    ///
2022    /// **Rotation note**: User-managed Secrets (via `secret_ref`) are NOT automatically
2023    /// rotated even if `auto_rotate` is `true`. You must rotate these manually.
2024    ///
2025    /// Default: `None` (auto-generate key)
2026    #[serde(skip_serializing_if = "Option::is_none")]
2027    pub secret_ref: Option<RndcSecretRef>,
2028
2029    /// Inline Secret specification for operator-managed Secret with optional rotation.
2030    ///
2031    /// Embeds a full Kubernetes Secret specification. The operator will create and
2032    /// manage this Secret, and rotate it if `auto_rotate` is `true`.
2033    ///
2034    /// **Mutually exclusive with `secret_ref`** - if both are specified, `secret_ref`
2035    /// takes precedence and this field is ignored.
2036    ///
2037    /// Default: `None` (auto-generate key)
2038    #[serde(skip_serializing_if = "Option::is_none")]
2039    pub secret: Option<SecretSpec>,
2040
2041    /// HMAC algorithm for the RNDC key.
2042    ///
2043    /// Only used when auto-generating keys (when neither `secret_ref` nor `secret` are
2044    /// specified). If using `secret_ref`, the algorithm is specified in that reference.
2045    ///
2046    /// Default: `hmac-sha256`
2047    #[serde(default)]
2048    pub algorithm: RndcAlgorithm,
2049}
2050
2051/// Kubernetes Secret specification for inline Secret creation.
2052///
2053/// Used when the operator should create and manage the Secret (with optional rotation).
2054/// This is a subset of the Kubernetes Secret API focusing on fields relevant for
2055/// RNDC key management.
2056///
2057/// # Example
2058///
2059/// ```yaml
2060/// secret:
2061///   metadata:
2062///     name: my-rndc-key
2063///     labels:
2064///       app: bindy
2065///       tier: infrastructure
2066///   stringData:
2067///     rndc.key: |
2068///       key "bindy-operator" {
2069///           algorithm hmac-sha256;
2070///           secret "dGVzdHNlY3JldA==";
2071///       };
2072/// ```
2073#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2074#[serde(rename_all = "camelCase")]
2075pub struct SecretSpec {
2076    /// Secret metadata (name, labels, annotations).
2077    ///
2078    /// **Required**: You must specify `metadata.name` for the Secret name.
2079    pub metadata: SecretMetadata,
2080
2081    /// Secret type (default: "Opaque").
2082    ///
2083    /// For RNDC keys, use the default "Opaque" type.
2084    #[serde(default = "default_secret_type")]
2085    #[serde(rename = "type")]
2086    pub type_: String,
2087
2088    /// String data (keys and values as strings).
2089    ///
2090    /// For RNDC keys, you should provide:
2091    /// - `rndc.key`: Full BIND9 key file content (required by BIND9)
2092    ///
2093    /// Optional metadata (auto-populated by operator if omitted):
2094    /// - `key-name`: Name of the TSIG key
2095    /// - `algorithm`: HMAC algorithm
2096    /// - `secret`: Base64-encoded key material
2097    ///
2098    /// Kubernetes automatically base64-encodes string data when creating the Secret.
2099    #[serde(skip_serializing_if = "Option::is_none")]
2100    pub string_data: Option<std::collections::BTreeMap<String, String>>,
2101
2102    /// Binary data (keys and values as base64 strings).
2103    ///
2104    /// Alternative to `string_data` if you want to provide already-base64-encoded values.
2105    /// Most users should use `string_data` instead for readability.
2106    #[serde(skip_serializing_if = "Option::is_none")]
2107    pub data: Option<std::collections::BTreeMap<String, String>>,
2108}
2109
2110/// Minimal Secret metadata for inline Secret specifications.
2111///
2112/// This is a subset of Kubernetes `ObjectMeta` focusing on commonly-used fields.
2113#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2114#[serde(rename_all = "camelCase")]
2115pub struct SecretMetadata {
2116    /// Secret name (required).
2117    ///
2118    /// Must be a valid Kubernetes resource name (lowercase alphanumeric, hyphens, dots).
2119    pub name: String,
2120
2121    /// Labels to apply to the Secret.
2122    ///
2123    /// Useful for organizing and selecting Secrets via label selectors.
2124    ///
2125    /// Example:
2126    /// ```yaml
2127    /// labels:
2128    ///   app: bindy
2129    ///   tier: infrastructure
2130    ///   environment: production
2131    /// ```
2132    #[serde(skip_serializing_if = "Option::is_none")]
2133    pub labels: Option<std::collections::BTreeMap<String, String>>,
2134
2135    /// Annotations to apply to the Secret.
2136    ///
2137    /// **Note**: The operator will add rotation tracking annotations:
2138    /// - `bindy.firestoned.io/rndc-created-at` - Key creation timestamp
2139    /// - `bindy.firestoned.io/rndc-rotate-at` - Next rotation timestamp
2140    /// - `bindy.firestoned.io/rndc-rotation-count` - Number of rotations
2141    ///
2142    /// Do not manually set these rotation tracking annotations.
2143    #[serde(skip_serializing_if = "Option::is_none")]
2144    pub annotations: Option<std::collections::BTreeMap<String, String>>,
2145}
2146
2147/// Default BIND9 version for clusters when not specified
2148#[allow(clippy::unnecessary_wraps)]
2149fn default_bind9_version() -> Option<String> {
2150    Some(crate::constants::DEFAULT_BIND9_VERSION.to_string())
2151}
2152
2153/// TSIG Key configuration for authenticated zone transfers (deprecated in favor of `RndcSecretRef`)
2154#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
2155#[serde(rename_all = "camelCase")]
2156pub struct TSIGKey {
2157    /// Name of the TSIG key
2158    pub name: String,
2159    /// Algorithm for HMAC-based authentication
2160    pub algorithm: RndcAlgorithm,
2161    /// Secret key (base64 encoded) - should reference a Secret
2162    pub secret: String,
2163}
2164
2165/// BIND9 server configuration options
2166///
2167/// These settings configure the BIND9 DNS server behavior including recursion,
2168/// access control lists, DNSSEC, and network listeners.
2169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2170#[serde(rename_all = "camelCase")]
2171pub struct Bind9Config {
2172    /// Enable or disable recursive DNS queries
2173    ///
2174    /// When enabled (`true`), the DNS server will recursively resolve queries by
2175    /// contacting other authoritative nameservers. When disabled (`false`), the
2176    /// server only answers for zones it is authoritative for.
2177    ///
2178    /// Default: `false` (authoritative-only mode)
2179    ///
2180    /// **Important**: Recursive resolvers should not be publicly accessible due to
2181    /// security risks (DNS amplification attacks, cache poisoning).
2182    #[serde(default)]
2183    pub recursion: Option<bool>,
2184
2185    /// Access control list for DNS queries
2186    ///
2187    /// Specifies which IP addresses or networks are allowed to query this DNS server.
2188    /// Supports CIDR notation and special keywords.
2189    ///
2190    /// Default: Not set (BIND9 defaults to localhost only)
2191    ///
2192    /// Examples:
2193    /// - `["0.0.0.0/0"]` - Allow queries from any IPv4 address
2194    /// - `["10.0.0.0/8", "172.16.0.0/12"]` - Allow queries from private networks
2195    /// - `["any"]` - Allow queries from any IP (IPv4 and IPv6)
2196    /// - `["none"]` - Deny all queries
2197    /// - `["localhost"]` - Allow only from localhost
2198    #[serde(default)]
2199    pub allow_query: Option<Vec<String>>,
2200
2201    /// Access control list for zone transfers (AXFR/IXFR)
2202    ///
2203    /// Specifies which IP addresses or networks are allowed to perform zone transfers
2204    /// from this server. Zone transfers are used for replication between primary and
2205    /// secondary DNS servers.
2206    ///
2207    /// Default: Auto-detected cluster Pod CIDRs (e.g., `["10.42.0.0/16"]`)
2208    ///
2209    /// Examples:
2210    /// - `["10.42.0.0/16"]` - Allow transfers from specific Pod network
2211    /// - `["10.0.0.0/8"]` - Allow transfers from entire private network
2212    /// - `[]` - Deny all zone transfers (empty list means "none")
2213    /// - `["any"]` - Allow transfers from any IP (not recommended for production)
2214    ///
2215    /// Can be overridden at cluster level via `spec.primary.allowTransfer` or
2216    /// `spec.secondary.allowTransfer` for role-specific ACLs.
2217    #[serde(default)]
2218    pub allow_transfer: Option<Vec<String>>,
2219
2220    /// DNSSEC (DNS Security Extensions) configuration
2221    ///
2222    /// Configures DNSSEC signing and validation. DNSSEC provides cryptographic
2223    /// authentication of DNS data to prevent spoofing and cache poisoning attacks.
2224    ///
2225    /// See `DNSSECConfig` for detailed options.
2226    #[serde(default)]
2227    pub dnssec: Option<DNSSECConfig>,
2228
2229    /// DNS forwarders for recursive resolution
2230    ///
2231    /// List of upstream DNS servers to forward queries to when recursion is enabled.
2232    /// Used for hybrid authoritative/recursive configurations.
2233    ///
2234    /// Only relevant when `recursion: true`.
2235    ///
2236    /// Examples:
2237    /// - `["8.8.8.8", "8.8.4.4"]` - Google Public DNS
2238    /// - `["1.1.1.1", "1.0.0.1"]` - Cloudflare DNS
2239    /// - `["10.0.0.53"]` - Internal corporate DNS resolver
2240    #[serde(default)]
2241    pub forwarders: Option<Vec<String>>,
2242
2243    /// IPv4 addresses to listen on for DNS queries
2244    ///
2245    /// Specifies which IPv4 interfaces and ports the DNS server should bind to.
2246    ///
2247    /// Default: All IPv4 interfaces on port 53
2248    ///
2249    /// Examples:
2250    /// - `["any"]` - Listen on all IPv4 interfaces
2251    /// - `["127.0.0.1"]` - Listen only on localhost
2252    /// - `["10.0.0.1"]` - Listen on specific IP address
2253    #[serde(default)]
2254    pub listen_on: Option<Vec<String>>,
2255
2256    /// IPv6 addresses to listen on for DNS queries
2257    ///
2258    /// Specifies which IPv6 interfaces and ports the DNS server should bind to.
2259    ///
2260    /// Default: All IPv6 interfaces on port 53 (if IPv6 is available)
2261    ///
2262    /// Examples:
2263    /// - `["any"]` - Listen on all IPv6 interfaces
2264    /// - `["::1"]` - Listen only on IPv6 localhost
2265    /// - `["none"]` - Disable IPv6 listening
2266    #[serde(default)]
2267    pub listen_on_v6: Option<Vec<String>>,
2268
2269    /// Reference to an existing Kubernetes Secret containing RNDC key.
2270    ///
2271    /// If specified at the global config level, all instances in the cluster will use
2272    /// this existing Secret instead of auto-generating individual secrets, unless
2273    /// overridden at the role (primary/secondary) or instance level.
2274    ///
2275    /// This allows centralized RNDC key management for the entire cluster.
2276    ///
2277    /// Precedence order (highest to lowest):
2278    /// 1. Instance level (`spec.rndcSecretRef`)
2279    /// 2. Role level (`spec.primary.rndcSecretRef` or `spec.secondary.rndcSecretRef`)
2280    /// 3. Global level (`spec.global.rndcSecretRef`)
2281    /// 4. Auto-generated (default)
2282    #[serde(default)]
2283    pub rndc_secret_ref: Option<RndcSecretRef>,
2284
2285    /// Bindcar RNDC API sidecar container configuration.
2286    ///
2287    /// The API container provides an HTTP interface for managing zones via rndc.
2288    /// This configuration is inherited by all instances unless overridden.
2289    #[serde(default)]
2290    pub bindcar_config: Option<BindcarConfig>,
2291}
2292
2293/// DNSSEC (DNS Security Extensions) configuration
2294///
2295/// DNSSEC adds cryptographic signatures to DNS records to ensure authenticity and integrity.
2296/// This configuration supports both DNSSEC validation (verifying signatures from upstream)
2297/// and DNSSEC signing (cryptographically signing your own zones).
2298///
2299/// # Example
2300///
2301/// ```yaml
2302/// dnssec:
2303///   validation: true  # Validate upstream DNSSEC responses
2304///   signing:
2305///     enabled: true
2306///     policy: "default"
2307///     algorithm: "ECDSAP256SHA256"
2308///     kskLifetime: "365d"
2309///     zskLifetime: "90d"
2310/// ```
2311#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2312#[serde(rename_all = "camelCase")]
2313pub struct DNSSECConfig {
2314    /// Enable DNSSEC validation of responses
2315    ///
2316    /// When enabled, BIND will validate DNSSEC signatures on responses from other
2317    /// nameservers. Invalid or missing signatures will cause queries to fail.
2318    ///
2319    /// Default: `false`
2320    ///
2321    /// **Important**: Requires valid DNSSEC trust anchors and proper network connectivity
2322    /// to root DNS servers. May cause resolution failures if DNSSEC is broken upstream.
2323    #[serde(default)]
2324    pub validation: Option<bool>,
2325
2326    /// Enable DNSSEC zone signing configuration
2327    ///
2328    /// Configures automatic DNSSEC signing for zones served by this cluster.
2329    /// When enabled, BIND9 will automatically generate keys, sign zones, and
2330    /// rotate keys based on the configured policy.
2331    ///
2332    /// **Important**: Requires BIND 9.16+ for modern `dnssec-policy` support.
2333    #[serde(default)]
2334    pub signing: Option<DNSSECSigningConfig>,
2335}
2336
2337/// DNSSEC zone signing configuration
2338///
2339/// Configures automatic DNSSEC key generation, zone signing, and key rotation.
2340/// Uses BIND9's modern `dnssec-policy` for declarative key management.
2341///
2342/// # Key Management Options
2343///
2344/// 1. **User-Supplied Keys** (Production): Keys managed externally via Secrets
2345/// 2. **Auto-Generated Keys** (Dev/Test): BIND9 generates keys, operator backs up to Secrets
2346/// 3. **Persistent Storage** (Legacy): Keys stored in `PersistentVolume`
2347///
2348/// # Example
2349///
2350/// ```yaml
2351/// signing:
2352///   enabled: true
2353///   policy: "default"
2354///   algorithm: "ECDSAP256SHA256"
2355///   kskLifetime: "365d"
2356///   zskLifetime: "90d"
2357///   nsec3: true
2358///   nsec3Iterations: 0
2359/// ```
2360#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2361#[serde(rename_all = "camelCase")]
2362pub struct DNSSECSigningConfig {
2363    /// Enable DNSSEC signing for zones
2364    ///
2365    /// When true, zones will be automatically signed with DNSSEC.
2366    /// Keys are generated and managed according to the configured policy.
2367    ///
2368    /// Default: `false`
2369    #[serde(default)]
2370    pub enabled: bool,
2371
2372    /// DNSSEC policy name
2373    ///
2374    /// Name of the DNSSEC policy to apply. Built-in policies:
2375    /// - `"default"` - Standard policy with ECDSA P-256, 365d KSK, 90d ZSK
2376    ///
2377    /// Custom policies can be defined in future enhancements.
2378    ///
2379    /// Default: `"default"`
2380    ///
2381    /// The name is restricted to a safe identifier set (`[A-Za-z0-9_-]`, starting
2382    /// alphanumeric, max 63 chars) because it is interpolated into a quoted BIND9
2383    /// configuration literal — `"`, `;`, `{`, `}` would allow config injection (B-6).
2384    #[serde(default)]
2385    #[schemars(regex(pattern = r"^[A-Za-z0-9][A-Za-z0-9_-]{0,62}$"))]
2386    pub policy: Option<String>,
2387
2388    /// DNSSEC algorithm
2389    ///
2390    /// Cryptographic algorithm for DNSSEC signing. Supported algorithms:
2391    /// - `"ECDSAP256SHA256"` (13) - ECDSA P-256 with SHA-256 (recommended, fast)
2392    /// - `"ECDSAP384SHA384"` (14) - ECDSA P-384 with SHA-384 (higher security)
2393    /// - `"RSASHA256"` (8) - RSA with SHA-256 (widely compatible)
2394    ///
2395    /// ECDSA algorithms are recommended for performance and smaller key sizes.
2396    ///
2397    /// Default: `"ECDSAP256SHA256"`
2398    ///
2399    /// Restricted to an alphanumeric token because it is interpolated unquoted
2400    /// into the BIND9 `dnssec-policy { ... }` block — `;`, `{`, `}`, `"`, or
2401    /// whitespace would allow config injection (B-6b).
2402    #[serde(default)]
2403    #[schemars(regex(pattern = r"^[A-Za-z0-9]{1,32}$"))]
2404    pub algorithm: Option<String>,
2405
2406    /// Key Signing Key (KSK) lifetime
2407    ///
2408    /// Duration before KSK is rotated. Format: "365d", "1y", "8760h"
2409    ///
2410    /// KSK signs the `DNSKEY` `RRset` and is published in the parent zone as a `DS` record.
2411    /// Longer lifetimes reduce `DS` update frequency but increase impact of key compromise.
2412    ///
2413    /// Default: `"365d"` (1 year)
2414    ///
2415    /// Restricted to an alphanumeric token (e.g. `365d`, `8760h`, `P1Y`,
2416    /// `unlimited`) because it is interpolated unquoted into the BIND9
2417    /// `dnssec-policy { ... }` block — metacharacters would allow config
2418    /// injection (B-6b).
2419    #[serde(default)]
2420    #[schemars(regex(pattern = r"^[A-Za-z0-9]{1,32}$"))]
2421    pub ksk_lifetime: Option<String>,
2422
2423    /// Zone Signing Key (ZSK) lifetime
2424    ///
2425    /// Duration before ZSK is rotated. Format: "90d", "3m", "2160h"
2426    ///
2427    /// ZSK signs all other records in the zone. Shorter lifetimes improve security
2428    /// but increase signing overhead.
2429    ///
2430    /// Default: `"90d"` (3 months)
2431    ///
2432    /// Restricted to an alphanumeric token (e.g. `90d`, `2160h`, `P3M`) because
2433    /// it is interpolated unquoted into the BIND9 `dnssec-policy { ... }` block —
2434    /// metacharacters would allow config injection (B-6b).
2435    #[serde(default)]
2436    #[schemars(regex(pattern = r"^[A-Za-z0-9]{1,32}$"))]
2437    pub zsk_lifetime: Option<String>,
2438
2439    /// Use NSEC3 instead of NSEC for authenticated denial of existence
2440    ///
2441    /// NSEC3 hashes zone names to prevent zone enumeration attacks.
2442    /// Recommended for privacy-sensitive zones.
2443    ///
2444    /// Default: `false` (use NSEC)
2445    #[serde(default)]
2446    pub nsec3: Option<bool>,
2447
2448    /// NSEC3 salt (hex string)
2449    ///
2450    /// Salt value for NSEC3 hashing. If not specified, BIND9 auto-generates.
2451    /// Format: hex string (e.g., "AABBCCDD")
2452    ///
2453    /// Default: Auto-generated by BIND9
2454    #[serde(default)]
2455    pub nsec3_salt: Option<String>,
2456
2457    /// NSEC3 iterations
2458    ///
2459    /// Number of hash iterations for NSEC3. RFC 9276 recommends 0 for performance.
2460    ///
2461    /// **Important**: Higher values significantly impact query performance.
2462    ///
2463    /// Default: `0` (per RFC 9276 recommendation)
2464    #[serde(default)]
2465    pub nsec3_iterations: Option<u32>,
2466
2467    /// DNSSEC key source configuration
2468    ///
2469    /// Specifies where DNSSEC keys come from:
2470    /// - User-supplied Secret (recommended for production)
2471    /// - Persistent storage (legacy)
2472    ///
2473    /// If not specified and `auto_generate` is true, keys are generated in emptyDir
2474    /// and optionally backed up to Secrets.
2475    #[serde(default)]
2476    pub keys_from: Option<DNSSECKeySource>,
2477
2478    /// Auto-generate DNSSEC keys if no `keys_from` specified
2479    ///
2480    /// When true, BIND9 generates keys automatically using the configured policy.
2481    /// Recommended for development and testing.
2482    ///
2483    /// Default: `true`
2484    #[serde(default)]
2485    pub auto_generate: Option<bool>,
2486
2487    /// Export auto-generated keys to Secret for backup/restore
2488    ///
2489    /// When true, operator exports BIND9-generated keys to a Kubernetes Secret.
2490    /// Enables self-healing: keys are restored from Secret on pod restart.
2491    ///
2492    /// Secret name format: `dnssec-keys-<zone-name>-generated`
2493    ///
2494    /// Default: `true`
2495    #[serde(default)]
2496    pub export_to_secret: Option<bool>,
2497}
2498
2499/// DNSSEC key source configuration
2500///
2501/// Defines where DNSSEC keys are loaded from. Supports multiple patterns:
2502///
2503/// 1. **User-Supplied Secret** (Production):
2504///    - Keys managed externally (`Vault`, `ExternalSecrets`, `sealed-secrets`)
2505///    - User controls rotation timing
2506///    - `GitOps` friendly
2507///
2508/// 2. **Persistent Storage** (Legacy):
2509///    - Keys stored in `PersistentVolume`
2510///    - Traditional BIND9 pattern
2511///
2512/// # Example: User-Supplied Keys
2513///
2514/// ```yaml
2515/// keysFrom:
2516///   secretRef:
2517///     name: "dnssec-keys-example-com"
2518/// ```
2519///
2520/// # Example: Persistent Storage
2521///
2522/// ```yaml
2523/// keysFrom:
2524///   persistentVolume:
2525///     accessModes:
2526///       - ReadWriteOnce
2527///     resources:
2528///       requests:
2529///         storage: 100Mi
2530/// ```
2531#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2532#[serde(rename_all = "camelCase")]
2533pub struct DNSSECKeySource {
2534    /// Secret containing DNSSEC keys
2535    ///
2536    /// Reference to a Kubernetes Secret with DNSSEC key files.
2537    ///
2538    /// Secret data format:
2539    /// - `K<zone>.+<alg>+<tag>.key` - Public key file
2540    /// - `K<zone>.+<alg>+<tag>.private` - Private key file
2541    ///
2542    /// Example: `Kexample.com.+013+12345.key`
2543    #[serde(default)]
2544    pub secret_ref: Option<SecretReference>,
2545
2546    /// Persistent volume for DNSSEC keys (legacy/compatibility)
2547    ///
2548    /// **Note**: Not cloud-native. Use `secret_ref` for production.
2549    #[serde(default)]
2550    pub persistent_volume: Option<k8s_openapi::api::core::v1::PersistentVolumeClaimSpec>,
2551}
2552
2553/// Reference to a Kubernetes Secret
2554///
2555/// Used for referencing external Secrets containing DNSSEC keys,
2556/// certificates, or other sensitive data.
2557#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2558#[serde(rename_all = "camelCase")]
2559pub struct SecretReference {
2560    /// Secret name
2561    pub name: String,
2562
2563    /// Optional namespace (defaults to same namespace as the resource)
2564    #[serde(default)]
2565    pub namespace: Option<String>,
2566}
2567
2568/// DNSSEC status information for a signed zone
2569///
2570/// Tracks DNSSEC signing status, DS records for parent zones,
2571/// and key rotation timestamps.
2572///
2573/// This status is populated by the `DNSZone` controller after
2574/// successful zone signing.
2575#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2576#[serde(rename_all = "camelCase")]
2577pub struct DNSSECStatus {
2578    /// Zone is signed with DNSSEC
2579    pub signed: bool,
2580
2581    /// DS (Delegation Signer) records for parent zone delegation
2582    ///
2583    /// These records must be published in the parent zone to complete
2584    /// the DNSSEC chain of trust.
2585    ///
2586    /// Format: `<zone> IN DS <keytag> <algorithm> <digesttype> <digest>`
2587    ///
2588    /// Example: `["example.com. IN DS 12345 13 2 ABC123..."]`
2589    #[serde(default)]
2590    pub ds_records: Vec<String>,
2591
2592    /// KSK key tag (numeric identifier)
2593    ///
2594    /// Identifies the Key Signing Key used to sign the DNSKEY `RRset`.
2595    /// This value appears in the DS record.
2596    #[serde(default)]
2597    pub key_tag: Option<u32>,
2598
2599    /// DNSSEC algorithm name
2600    ///
2601    /// Example: `"ECDSAP256SHA256"`, `"RSASHA256"`
2602    #[serde(default)]
2603    pub algorithm: Option<String>,
2604
2605    /// Next scheduled key rollover timestamp (ISO 8601)
2606    ///
2607    /// When the next automatic key rotation will occur.
2608    ///
2609    /// Example: `"2026-04-02T00:00:00Z"`
2610    #[serde(default)]
2611    pub next_key_rollover: Option<String>,
2612
2613    /// Last key rollover timestamp (ISO 8601)
2614    ///
2615    /// When the most recent key rotation occurred.
2616    ///
2617    /// Example: `"2025-04-02T00:00:00Z"`
2618    #[serde(default)]
2619    pub last_key_rollover: Option<String>,
2620}
2621
2622/// Container image configuration for BIND9 instances
2623#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2624#[serde(rename_all = "camelCase")]
2625pub struct ImageConfig {
2626    /// Container image repository and tag for BIND9
2627    ///
2628    /// Example: "internetsystemsconsortium/bind9:9.18"
2629    #[serde(default)]
2630    pub image: Option<String>,
2631
2632    /// Image pull policy
2633    ///
2634    /// Example: `IfNotPresent`, `Always`, `Never`
2635    #[serde(default)]
2636    pub image_pull_policy: Option<String>,
2637
2638    /// Reference to image pull secrets for private registries
2639    #[serde(default)]
2640    pub image_pull_secrets: Option<Vec<String>>,
2641}
2642
2643/// `ConfigMap` references for BIND9 configuration files
2644#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2645#[serde(rename_all = "camelCase")]
2646pub struct ConfigMapRefs {
2647    /// `ConfigMap` containing named.conf file
2648    ///
2649    /// If not specified, a default configuration will be generated
2650    #[serde(default)]
2651    pub named_conf: Option<String>,
2652
2653    /// `ConfigMap` containing named.conf.options file
2654    ///
2655    /// If not specified, a default configuration will be generated
2656    #[serde(default)]
2657    pub named_conf_options: Option<String>,
2658
2659    /// `ConfigMap` containing named.conf.zones file
2660    ///
2661    /// Optional. If specified, the zones file from this `ConfigMap` will be included in named.conf.
2662    /// If not specified, no zones file will be included (zones can be added dynamically via RNDC).
2663    /// Use this for pre-configured zones or to import existing BIND9 zone configurations.
2664    #[serde(default)]
2665    pub named_conf_zones: Option<String>,
2666}
2667
2668/// Service configuration including spec and annotations
2669///
2670/// Allows customization of both the Kubernetes Service spec and metadata annotations.
2671#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2672#[serde(rename_all = "camelCase")]
2673pub struct ServiceConfig {
2674    /// Annotations to apply to the Service metadata
2675    ///
2676    /// Common use cases:
2677    /// - `MetalLB` address pool selection: `metallb.universe.tf/address-pool: my-ip-pool`
2678    /// - AWS load balancer configuration: `service.beta.kubernetes.io/aws-load-balancer-type: nlb`
2679    /// - External DNS hostname: `external-dns.alpha.kubernetes.io/hostname: dns.example.com`
2680    ///
2681    /// Example:
2682    /// ```yaml
2683    /// annotations:
2684    ///   metallb.universe.tf/address-pool: my-ip-pool
2685    ///   external-dns.alpha.kubernetes.io/hostname: ns1.example.com
2686    /// ```
2687    #[serde(skip_serializing_if = "Option::is_none")]
2688    pub annotations: Option<BTreeMap<String, String>>,
2689
2690    /// Custom Kubernetes Service spec
2691    ///
2692    /// Allows full customization of the Kubernetes Service created for DNS servers.
2693    /// This accepts the same fields as the standard Kubernetes Service `spec`.
2694    ///
2695    /// Common fields:
2696    /// - `type`: Service type (`ClusterIP`, `NodePort`, `LoadBalancer`)
2697    /// - `loadBalancerIP`: Specific IP for `LoadBalancer` type
2698    /// - `externalTrafficPolicy`: `Local` or `Cluster`
2699    /// - `sessionAffinity`: `ClientIP` or `None`
2700    /// - `clusterIP`: Specific cluster IP (use with caution)
2701    ///
2702    /// Fields specified here are merged with defaults. Unspecified fields use safe defaults:
2703    /// - `type: ClusterIP` (if not specified)
2704    /// - Ports 53/TCP and 53/UDP (always set)
2705    /// - Selector matching the instance labels (always set)
2706    #[serde(skip_serializing_if = "Option::is_none")]
2707    pub spec: Option<ServiceSpec>,
2708}
2709
2710/// Primary instance configuration
2711///
2712/// Groups all configuration specific to primary (authoritative) DNS instances.
2713#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2714#[serde(rename_all = "camelCase")]
2715pub struct PrimaryConfig {
2716    /// Number of primary instance replicas (default: 1)
2717    ///
2718    /// This controls how many replicas each primary instance in this cluster should have.
2719    /// Can be overridden at the instance level.
2720    #[serde(skip_serializing_if = "Option::is_none")]
2721    #[schemars(range(min = 0, max = 100))]
2722    pub replicas: Option<i32>,
2723
2724    /// Additional labels to apply to primary `Bind9Instance` resources
2725    ///
2726    /// These labels are propagated from the cluster/provider to all primary instances.
2727    /// They are merged with standard labels (app.kubernetes.io/*) and can be used for:
2728    /// - Instance selection via `DNSZone.spec.bind9InstancesFrom` label selectors
2729    /// - Pod selectors in network policies
2730    /// - Monitoring and alerting label filters
2731    /// - Custom organizational taxonomy
2732    ///
2733    /// Example:
2734    /// ```yaml
2735    /// primary:
2736    ///   labels:
2737    ///     environment: production
2738    ///     tier: frontend
2739    ///     region: us-east-1
2740    /// ```
2741    ///
2742    /// These labels will appear on the `Bind9Instance` metadata and can be referenced
2743    /// by `DNSZone` resources using `bind9InstancesFrom.selector.matchLabels`.
2744    #[serde(default, skip_serializing_if = "Option::is_none")]
2745    pub labels: Option<BTreeMap<String, String>>,
2746
2747    /// Custom Kubernetes Service configuration for primary instances
2748    ///
2749    /// Allows full customization of the Kubernetes Service created for primary DNS servers,
2750    /// including both Service spec fields and metadata annotations.
2751    ///
2752    /// Annotations are commonly used for:
2753    /// - `MetalLB` address pool selection
2754    /// - Cloud provider load balancer configuration
2755    /// - External DNS integration
2756    /// - Linkerd service mesh annotations
2757    ///
2758    /// Fields specified here are merged with defaults. Unspecified fields use safe defaults:
2759    /// - `type: ClusterIP` (if not specified)
2760    /// - Ports 53/TCP and 53/UDP (always set)
2761    /// - Selector matching the instance labels (always set)
2762    #[serde(skip_serializing_if = "Option::is_none")]
2763    pub service: Option<ServiceConfig>,
2764
2765    /// Allow-transfer ACL for primary instances
2766    ///
2767    /// Overrides the default auto-detected Pod CIDR allow-transfer configuration
2768    /// for all primary instances in this cluster. Use this to restrict or expand
2769    /// which IP addresses can perform zone transfers from primary servers.
2770    ///
2771    /// If not specified, defaults to cluster Pod CIDRs (auto-detected from Kubernetes Nodes).
2772    ///
2773    /// Examples:
2774    /// - `["10.0.0.0/8"]` - Allow transfers from entire 10.x network
2775    /// - `["any"]` - Allow transfers from any IP (public internet)
2776    /// - `[]` - Deny all zone transfers (empty list means "none")
2777    ///
2778    /// Can be overridden at the instance level via `spec.config.allowTransfer`.
2779    #[serde(default, skip_serializing_if = "Option::is_none")]
2780    pub allow_transfer: Option<Vec<String>>,
2781
2782    /// Reference to an existing Kubernetes Secret containing RNDC key for all primary instances.
2783    ///
2784    /// If specified, all primary instances in this cluster will use this existing Secret
2785    /// instead of auto-generating individual secrets. This allows sharing the same RNDC key
2786    /// across all primary instances.
2787    ///
2788    /// Can be overridden at the instance level via `spec.rndcSecretRef`.
2789    #[serde(default, skip_serializing_if = "Option::is_none")]
2790    #[deprecated(
2791        since = "0.6.0",
2792        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
2793    )]
2794    pub rndc_secret_ref: Option<RndcSecretRef>,
2795
2796    /// RNDC key configuration for all primary instances with lifecycle management.
2797    ///
2798    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
2799    /// Overrides global RNDC configuration for primary instances.
2800    ///
2801    /// **Precedence order**:
2802    /// 1. Instance level (`spec.rndcKey`)
2803    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
2804    /// 3. Global level (cluster-wide RNDC configuration)
2805    /// 4. Auto-generated (default)
2806    ///
2807    /// Can be overridden at the instance level via `spec.rndcKey`.
2808    ///
2809    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
2810    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
2811    /// to work but is deprecated.
2812    ///
2813    /// # Example
2814    ///
2815    /// ```yaml
2816    /// primary:
2817    ///   replicas: 1
2818    ///   rndcKey:
2819    ///     autoRotate: true
2820    ///     rotateAfter: 720h  # 30 days
2821    ///     algorithm: hmac-sha256
2822    /// ```
2823    #[serde(skip_serializing_if = "Option::is_none")]
2824    pub rndc_key: Option<RndcKeyConfig>,
2825}
2826
2827/// Secondary instance configuration
2828///
2829/// Groups all configuration specific to secondary (replica) DNS instances.
2830#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
2831#[serde(rename_all = "camelCase")]
2832pub struct SecondaryConfig {
2833    /// Number of secondary instance replicas (default: 1)
2834    ///
2835    /// This controls how many replicas each secondary instance in this cluster should have.
2836    /// Can be overridden at the instance level.
2837    #[serde(skip_serializing_if = "Option::is_none")]
2838    #[schemars(range(min = 0, max = 100))]
2839    pub replicas: Option<i32>,
2840
2841    /// Additional labels to apply to secondary `Bind9Instance` resources
2842    ///
2843    /// These labels are propagated from the cluster/provider to all secondary instances.
2844    /// They are merged with standard labels (app.kubernetes.io/*) and can be used for:
2845    /// - Instance selection via `DNSZone.spec.bind9InstancesFrom` label selectors
2846    /// - Pod selectors in network policies
2847    /// - Monitoring and alerting label filters
2848    /// - Custom organizational taxonomy
2849    ///
2850    /// Example:
2851    /// ```yaml
2852    /// secondary:
2853    ///   labels:
2854    ///     environment: production
2855    ///     tier: backend
2856    ///     region: us-west-2
2857    /// ```
2858    ///
2859    /// These labels will appear on the `Bind9Instance` metadata and can be referenced
2860    /// by `DNSZone` resources using `bind9InstancesFrom.selector.matchLabels`.
2861    #[serde(default, skip_serializing_if = "Option::is_none")]
2862    pub labels: Option<BTreeMap<String, String>>,
2863
2864    /// Custom Kubernetes Service configuration for secondary instances
2865    ///
2866    /// Allows full customization of the Kubernetes Service created for secondary DNS servers,
2867    /// including both Service spec fields and metadata annotations.
2868    ///
2869    /// Annotations are commonly used for:
2870    /// - `MetalLB` address pool selection
2871    /// - Cloud provider load balancer configuration
2872    /// - External DNS integration
2873    /// - Linkerd service mesh annotations
2874    ///
2875    /// Allows different service configurations for primary vs secondary instances.
2876    /// Example: Primaries use `LoadBalancer` with specific annotations, secondaries use `ClusterIP`
2877    ///
2878    /// See `PrimaryConfig.service` for detailed field documentation.
2879    #[serde(skip_serializing_if = "Option::is_none")]
2880    pub service: Option<ServiceConfig>,
2881
2882    /// Allow-transfer ACL for secondary instances
2883    ///
2884    /// Overrides the default auto-detected Pod CIDR allow-transfer configuration
2885    /// for all secondary instances in this cluster. Use this to restrict or expand
2886    /// which IP addresses can perform zone transfers from secondary servers.
2887    ///
2888    /// If not specified, defaults to cluster Pod CIDRs (auto-detected from Kubernetes Nodes).
2889    ///
2890    /// Examples:
2891    /// - `["10.0.0.0/8"]` - Allow transfers from entire 10.x network
2892    /// - `["any"]` - Allow transfers from any IP (public internet)
2893    /// - `[]` - Deny all zone transfers (empty list means "none")
2894    ///
2895    /// Can be overridden at the instance level via `spec.config.allowTransfer`.
2896    #[serde(default, skip_serializing_if = "Option::is_none")]
2897    pub allow_transfer: Option<Vec<String>>,
2898
2899    /// Reference to an existing Kubernetes Secret containing RNDC key for all secondary instances.
2900    ///
2901    /// If specified, all secondary instances in this cluster will use this existing Secret
2902    /// instead of auto-generating individual secrets. This allows sharing the same RNDC key
2903    /// across all secondary instances.
2904    ///
2905    /// Can be overridden at the instance level via `spec.rndcSecretRef`.
2906    #[serde(default, skip_serializing_if = "Option::is_none")]
2907    #[deprecated(
2908        since = "0.6.0",
2909        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
2910    )]
2911    pub rndc_secret_ref: Option<RndcSecretRef>,
2912
2913    /// RNDC key configuration for all secondary instances with lifecycle management.
2914    ///
2915    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
2916    /// Overrides global RNDC configuration for secondary instances.
2917    ///
2918    /// **Precedence order**:
2919    /// 1. Instance level (`spec.rndcKey`)
2920    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
2921    /// 3. Global level (cluster-wide RNDC configuration)
2922    /// 4. Auto-generated (default)
2923    ///
2924    /// Can be overridden at the instance level via `spec.rndcKey`.
2925    ///
2926    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
2927    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
2928    /// to work but is deprecated.
2929    ///
2930    /// # Example
2931    ///
2932    /// ```yaml
2933    /// secondary:
2934    ///   replicas: 2
2935    ///   rndcKey:
2936    ///     autoRotate: true
2937    ///     rotateAfter: 720h  # 30 days
2938    ///     algorithm: hmac-sha256
2939    /// ```
2940    #[serde(skip_serializing_if = "Option::is_none")]
2941    pub rndc_key: Option<RndcKeyConfig>,
2942}
2943
2944/// Common specification fields shared between namespace-scoped and cluster-scoped BIND9 clusters.
2945///
2946/// This struct contains all configuration that is common to both `Bind9Cluster` (namespace-scoped)
2947/// and `ClusterBind9Provider` (cluster-scoped). By using this shared struct, we avoid code duplication
2948/// and ensure consistency between the two cluster types.
2949#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
2950#[serde(rename_all = "camelCase")]
2951pub struct Bind9ClusterCommonSpec {
2952    /// Shared BIND9 version for the cluster
2953    ///
2954    /// If not specified, defaults to "9.18".
2955    #[serde(default = "default_bind9_version")]
2956    #[schemars(default = "default_bind9_version")]
2957    pub version: Option<String>,
2958
2959    /// Primary instance configuration
2960    ///
2961    /// Configuration specific to primary (authoritative) DNS instances,
2962    /// including replica count and service specifications.
2963    #[serde(default, skip_serializing_if = "Option::is_none")]
2964    pub primary: Option<PrimaryConfig>,
2965
2966    /// Secondary instance configuration
2967    ///
2968    /// Configuration specific to secondary (replica) DNS instances,
2969    /// including replica count and service specifications.
2970    #[serde(default, skip_serializing_if = "Option::is_none")]
2971    pub secondary: Option<SecondaryConfig>,
2972
2973    /// Container image configuration
2974    #[serde(default)]
2975    pub image: Option<ImageConfig>,
2976
2977    /// `ConfigMap` references for BIND9 configuration files
2978    #[serde(default)]
2979    pub config_map_refs: Option<ConfigMapRefs>,
2980
2981    /// Global configuration shared by all instances in the cluster
2982    ///
2983    /// This configuration applies to all instances (both primary and secondary)
2984    /// unless overridden at the instance level or by role-specific configuration.
2985    #[serde(default)]
2986    pub global: Option<Bind9Config>,
2987
2988    /// References to Kubernetes Secrets containing RNDC/TSIG keys for authenticated zone transfers.
2989    ///
2990    /// Each secret should contain the key name, algorithm, and base64-encoded secret value.
2991    /// These secrets are used for secure communication with BIND9 instances via RNDC and
2992    /// for authenticated zone transfers (AXFR/IXFR) between primary and secondary servers.
2993    #[serde(default)]
2994    pub rndc_secret_refs: Option<Vec<RndcSecretRef>>,
2995
2996    /// ACLs that can be referenced by instances
2997    #[serde(default)]
2998    pub acls: Option<BTreeMap<String, Vec<String>>>,
2999
3000    /// Volumes that can be mounted by instances in this cluster
3001    ///
3002    /// These volumes are inherited by all instances unless overridden.
3003    /// Common use cases include `PersistentVolumeClaims` for zone data storage.
3004    #[serde(default)]
3005    pub volumes: Option<Vec<Volume>>,
3006
3007    /// Volume mounts that specify where volumes should be mounted in containers
3008    ///
3009    /// These mounts are inherited by all instances unless overridden.
3010    #[serde(default)]
3011    pub volume_mounts: Option<Vec<VolumeMount>>,
3012}
3013
3014/// `Bind9Cluster` - Namespace-scoped DNS cluster for tenant-managed infrastructure.
3015///
3016/// A namespace-scoped cluster allows development teams to run their own isolated BIND9
3017/// DNS infrastructure within their namespace. Each team can manage their own cluster
3018/// independently, with RBAC controlling who can create and manage resources.
3019///
3020/// For platform-managed, cluster-wide DNS infrastructure, use `ClusterBind9Provider` instead.
3021///
3022/// # Use Cases
3023///
3024/// - Development teams need isolated DNS infrastructure for testing
3025/// - Multi-tenant environments where each team manages their own DNS
3026/// - Namespaced DNS services that don't need cluster-wide visibility
3027///
3028/// # Example
3029///
3030/// ```yaml
3031/// apiVersion: bindy.firestoned.io/v1beta1
3032/// kind: Bind9Cluster
3033/// metadata:
3034///   name: dev-team-dns
3035///   namespace: dev-team-alpha
3036/// spec:
3037///   version: "9.18"
3038///   primary:
3039///     replicas: 1
3040///   secondary:
3041///     replicas: 1
3042/// ```
3043#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3044#[kube(
3045    group = "bindy.firestoned.io",
3046    version = "v1beta1",
3047    kind = "Bind9Cluster",
3048    namespaced,
3049    shortname = "b9c",
3050    shortname = "b9cs",
3051    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.",
3052    printcolumn = r#"{"name":"Version","type":"string","jsonPath":".spec.version"}"#,
3053    printcolumn = r#"{"name":"Primary","type":"integer","jsonPath":".spec.primary.replicas"}"#,
3054    printcolumn = r#"{"name":"Secondary","type":"integer","jsonPath":".spec.secondary.replicas"}"#,
3055    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3056)]
3057#[kube(status = "Bind9ClusterStatus")]
3058#[serde(rename_all = "camelCase")]
3059pub struct Bind9ClusterSpec {
3060    /// All cluster configuration is flattened from the common spec
3061    #[serde(flatten)]
3062    pub common: Bind9ClusterCommonSpec,
3063}
3064
3065/// `ClusterBind9Provider` - Cluster-scoped BIND9 DNS provider for platform teams.
3066///
3067/// A cluster-scoped provider allows platform teams to provision shared BIND9 DNS infrastructure
3068/// that is accessible from any namespace. This is ideal for shared services, production DNS,
3069/// or platform-managed infrastructure that multiple teams use.
3070///
3071/// `DNSZones` in any namespace can reference a `ClusterBind9Provider` using the `clusterProviderRef` field.
3072///
3073/// # Use Cases
3074///
3075/// - Platform team provides shared DNS infrastructure for all namespaces
3076/// - Production DNS services that serve multiple applications
3077/// - Centrally managed DNS with governance and compliance requirements
3078///
3079/// # Example
3080///
3081/// ```yaml
3082/// apiVersion: bindy.firestoned.io/v1beta1
3083/// kind: ClusterBind9Provider
3084/// metadata:
3085///   name: shared-production-dns
3086///   # No namespace - cluster-scoped
3087/// spec:
3088///   version: "9.18"
3089///   primary:
3090///     replicas: 3
3091///     service:
3092///       type: LoadBalancer
3093///   secondary:
3094///     replicas: 2
3095/// ```
3096#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3097#[kube(
3098    group = "bindy.firestoned.io",
3099    version = "v1beta1",
3100    kind = "ClusterBind9Provider",
3101    // NOTE: No 'namespaced' attribute = cluster-scoped
3102    shortname = "cb9p",
3103    shortname = "cb9ps",
3104    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.",
3105    printcolumn = r#"{"name":"Version","type":"string","jsonPath":".spec.version"}"#,
3106    printcolumn = r#"{"name":"Primary","type":"integer","jsonPath":".spec.primary.replicas"}"#,
3107    printcolumn = r#"{"name":"Secondary","type":"integer","jsonPath":".spec.secondary.replicas"}"#,
3108    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3109)]
3110#[kube(status = "Bind9ClusterStatus")]
3111#[serde(rename_all = "camelCase")]
3112pub struct ClusterBind9ProviderSpec {
3113    /// Namespace where `Bind9Instance` resources will be created
3114    ///
3115    /// Since `ClusterBind9Provider` is cluster-scoped, instances need to be created in a specific namespace.
3116    /// Typically this would be a platform-managed namespace like `bindy-system`.
3117    ///
3118    /// All managed instances (primary and secondary) will be created in this namespace.
3119    /// `DNSZones` from any namespace can reference this provider via `clusterProviderRef`.
3120    ///
3121    /// **Default:** If not specified, instances will be created in the same namespace where the
3122    /// Bindy operator is running (from `POD_NAMESPACE` environment variable).
3123    ///
3124    /// Example: `bindy-system` for platform DNS infrastructure
3125    #[serde(skip_serializing_if = "Option::is_none")]
3126    pub namespace: Option<String>,
3127
3128    /// All cluster configuration is flattened from the common spec
3129    #[serde(flatten)]
3130    pub common: Bind9ClusterCommonSpec,
3131}
3132
3133/// `Bind9Cluster` status
3134#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
3135#[serde(rename_all = "camelCase")]
3136pub struct Bind9ClusterStatus {
3137    /// Status conditions for this cluster
3138    #[serde(default)]
3139    pub conditions: Vec<Condition>,
3140
3141    /// Observed generation for optimistic concurrency
3142    #[serde(skip_serializing_if = "Option::is_none")]
3143    pub observed_generation: Option<i64>,
3144
3145    /// Number of instances in this cluster
3146    #[serde(skip_serializing_if = "Option::is_none")]
3147    pub instance_count: Option<i32>,
3148
3149    /// Number of ready instances
3150    #[serde(skip_serializing_if = "Option::is_none")]
3151    pub ready_instances: Option<i32>,
3152
3153    /// Names of `Bind9Instance` resources created for this cluster
3154    #[serde(default, skip_serializing_if = "Vec::is_empty")]
3155    pub instances: Vec<String>,
3156}
3157
3158/// Server role in the DNS cluster.
3159///
3160/// Determines whether the instance is authoritative (primary) or replicates from primaries (secondary).
3161#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
3162#[serde(rename_all = "lowercase")]
3163pub enum ServerRole {
3164    /// Primary DNS server - authoritative source for zones.
3165    ///
3166    /// Primary servers hold the original zone data and process dynamic updates.
3167    /// Changes to zones are made on primaries and transferred to secondaries.
3168    Primary,
3169
3170    /// Secondary DNS server - replicates zones from primary servers.
3171    ///
3172    /// Secondary servers receive zone data via AXFR (full) or IXFR (incremental)
3173    /// zone transfers. They provide redundancy and geographic distribution.
3174    Secondary,
3175}
3176
3177impl ServerRole {
3178    /// Convert `ServerRole` to its string representation.
3179    ///
3180    /// Returns the lowercase zone type string used in BIND9 configuration
3181    /// and bindcar API calls.
3182    ///
3183    /// # Returns
3184    /// * `"primary"` for `ServerRole::Primary`
3185    /// * `"secondary"` for `ServerRole::Secondary`
3186    ///
3187    /// # Examples
3188    ///
3189    /// ```
3190    /// use bindy::crd::ServerRole;
3191    ///
3192    /// assert_eq!(ServerRole::Primary.as_str(), "primary");
3193    /// assert_eq!(ServerRole::Secondary.as_str(), "secondary");
3194    /// ```
3195    #[must_use]
3196    pub const fn as_str(&self) -> &'static str {
3197        match self {
3198            Self::Primary => "primary",
3199            Self::Secondary => "secondary",
3200        }
3201    }
3202}
3203
3204/// `Bind9Instance` represents a BIND9 DNS server deployment in Kubernetes.
3205///
3206/// Each `Bind9Instance` creates a Deployment, Service, `ConfigMap`, and Secret for managing
3207/// a BIND9 server. The instance communicates with the controller via RNDC protocol.
3208///
3209/// # Example
3210///
3211/// ```yaml
3212/// apiVersion: bindy.firestoned.io/v1beta1
3213/// kind: Bind9Instance
3214/// metadata:
3215///   name: dns-primary
3216///   namespace: bindy-system
3217/// spec:
3218///   clusterRef: my-dns-cluster
3219///   role: primary
3220///   replicas: 2
3221///   version: "9.18"
3222/// ```
3223#[derive(CustomResource, Clone, Debug, Serialize, Deserialize, JsonSchema)]
3224#[kube(
3225    group = "bindy.firestoned.io",
3226    version = "v1beta1",
3227    kind = "Bind9Instance",
3228    namespaced,
3229    shortname = "b9",
3230    shortname = "b9s",
3231    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.",
3232    printcolumn = r#"{"name":"Cluster","type":"string","jsonPath":".spec.clusterRef"}"#,
3233    printcolumn = r#"{"name":"Role","type":"string","jsonPath":".spec.role"}"#,
3234    printcolumn = r#"{"name":"Replicas","type":"integer","jsonPath":".spec.replicas"}"#,
3235    printcolumn = r#"{"name":"Zones","type":"integer","jsonPath":".status.zonesCount"}"#,
3236    printcolumn = r#"{"name":"Ready","type":"string","jsonPath":".status.conditions[?(@.type=='Ready')].status"}"#
3237)]
3238#[kube(status = "Bind9InstanceStatus")]
3239#[serde(rename_all = "camelCase")]
3240pub struct Bind9InstanceSpec {
3241    /// Reference to the cluster this instance belongs to.
3242    ///
3243    /// Can reference either:
3244    /// - A namespace-scoped `Bind9Cluster` (must be in the same namespace as this instance)
3245    /// - A cluster-scoped `ClusterBind9Provider` (cluster-wide, accessible from any namespace)
3246    ///
3247    /// The cluster provides shared configuration and defines the logical grouping.
3248    /// The controller will automatically detect whether this references a namespace-scoped
3249    /// or cluster-scoped cluster resource.
3250    pub cluster_ref: String,
3251
3252    /// Role of this instance (primary or secondary).
3253    ///
3254    /// Primary instances are authoritative for zones. Secondary instances
3255    /// replicate zones from primaries via AXFR/IXFR.
3256    pub role: ServerRole,
3257
3258    /// Number of pod replicas for high availability.
3259    ///
3260    /// Defaults to 1 if not specified. For production, use 2+ replicas.
3261    #[serde(default)]
3262    #[schemars(range(min = 0, max = 100))]
3263    pub replicas: Option<i32>,
3264
3265    /// BIND9 version override. Inherits from cluster if not specified.
3266    ///
3267    /// Example: "9.18", "9.16"
3268    #[serde(default)]
3269    pub version: Option<String>,
3270
3271    /// Container image configuration override. Inherits from cluster if not specified.
3272    #[serde(default)]
3273    pub image: Option<ImageConfig>,
3274
3275    /// `ConfigMap` references override. Inherits from cluster if not specified.
3276    #[serde(default)]
3277    pub config_map_refs: Option<ConfigMapRefs>,
3278
3279    /// Instance-specific BIND9 configuration overrides.
3280    ///
3281    /// Overrides cluster-level configuration for this instance only.
3282    #[serde(default)]
3283    pub config: Option<Bind9Config>,
3284
3285    /// Primary server addresses for zone transfers (required for secondary instances).
3286    ///
3287    /// List of IP addresses or hostnames of primary servers to transfer zones from.
3288    /// Example: `["10.0.1.10", "primary.example.com"]`
3289    #[serde(default)]
3290    pub primary_servers: Option<Vec<String>>,
3291
3292    /// Volumes override for this instance. Inherits from cluster if not specified.
3293    ///
3294    /// These volumes override cluster-level volumes. Common use cases include
3295    /// instance-specific `PersistentVolumeClaims` for zone data storage.
3296    #[serde(default)]
3297    pub volumes: Option<Vec<Volume>>,
3298
3299    /// Volume mounts override for this instance. Inherits from cluster if not specified.
3300    ///
3301    /// These mounts override cluster-level volume mounts.
3302    #[serde(default)]
3303    pub volume_mounts: Option<Vec<VolumeMount>>,
3304
3305    /// Reference to an existing Kubernetes Secret containing RNDC key.
3306    ///
3307    /// If specified, uses this existing Secret instead of auto-generating one.
3308    /// The Secret must contain the keys specified in the reference (defaults: "key-name", "algorithm", "secret", "rndc.key").
3309    /// This allows sharing RNDC keys across instances or using externally managed secrets.
3310    ///
3311    /// If not specified, a Secret will be auto-generated for this instance.
3312    #[serde(default)]
3313    #[deprecated(
3314        since = "0.6.0",
3315        note = "Use `rndc_key` instead. This field will be removed in v1.0.0"
3316    )]
3317    pub rndc_secret_ref: Option<RndcSecretRef>,
3318
3319    /// Instance-level RNDC key configuration with lifecycle management.
3320    ///
3321    /// Supports automatic key rotation, Secret references, and inline Secret specifications.
3322    /// Overrides role-level and global RNDC configuration for this specific instance.
3323    ///
3324    /// **Precedence order**:
3325    /// 1. **Instance level** (`spec.rndcKey`) - Highest priority
3326    /// 2. Role level (`spec.primary.rndcKey` or `spec.secondary.rndcKey`)
3327    /// 3. Global level (cluster-wide RNDC configuration)
3328    /// 4. Auto-generated (default)
3329    ///
3330    /// **Backward compatibility**: If both `rndc_key` and `rndc_secret_ref` are specified,
3331    /// `rndc_key` takes precedence. For smooth migration, `rndc_secret_ref` will continue
3332    /// to work but is deprecated.
3333    ///
3334    /// # Example
3335    ///
3336    /// ```yaml
3337    /// apiVersion: bindy.firestoned.io/v1beta1
3338    /// kind: Bind9Instance
3339    /// spec:
3340    ///   rndcKey:
3341    ///     autoRotate: true
3342    ///     rotateAfter: 2160h  # 90 days
3343    ///     algorithm: hmac-sha512
3344    /// ```
3345    #[serde(skip_serializing_if = "Option::is_none")]
3346    pub rndc_key: Option<RndcKeyConfig>,
3347
3348    /// Storage configuration for zone files.
3349    ///
3350    /// Specifies how zone files should be stored. Defaults to emptyDir (ephemeral storage).
3351    /// For persistent storage, use persistentVolumeClaim.
3352    #[serde(default)]
3353    pub storage: Option<StorageConfig>,
3354
3355    /// Bindcar RNDC API sidecar container configuration.
3356    ///
3357    /// The API container provides an HTTP interface for managing zones via rndc.
3358    /// If not specified, uses default configuration.
3359    #[serde(default)]
3360    pub bindcar_config: Option<BindcarConfig>,
3361}
3362
3363/// `Bind9Instance` status
3364#[derive(Clone, Debug, Serialize, Deserialize, Default, JsonSchema)]
3365#[serde(rename_all = "camelCase")]
3366pub struct Bind9InstanceStatus {
3367    #[serde(default)]
3368    pub conditions: Vec<Condition>,
3369    #[serde(skip_serializing_if = "Option::is_none")]
3370    pub observed_generation: Option<i64>,
3371    /// Generation of the referenced parent cluster that was last reconciled.
3372    ///
3373    /// Records the `metadata.generation` of the parent `Bind9Cluster` or
3374    /// `ClusterBind9Provider` (whichever this instance references) as observed
3375    /// during the last successful reconciliation. The instance reconciler
3376    /// compares the parent's current generation against this value to detect
3377    /// parent configuration changes (e.g., RNDC config added at the cluster
3378    /// level) that must be propagated to the instance.
3379    ///
3380    /// This is intentionally separate from `observed_generation`, which tracks
3381    /// the instance's OWN spec generation - the two counters are unrelated.
3382    #[serde(skip_serializing_if = "Option::is_none")]
3383    pub observed_parent_generation: Option<i64>,
3384    /// IP or hostname of this instance's service
3385    #[serde(skip_serializing_if = "Option::is_none")]
3386    pub service_address: Option<String>,
3387    /// Resolved cluster reference with full object details.
3388    ///
3389    /// This field is populated by the instance reconciler and contains the full Kubernetes
3390    /// object reference (kind, apiVersion, namespace, name) of the cluster this instance
3391    /// belongs to. This provides backward compatibility with `spec.clusterRef` (which is
3392    /// just a string name) and enables proper Kubernetes object references.
3393    ///
3394    /// For namespace-scoped `Bind9Cluster`, includes namespace.
3395    /// For cluster-scoped `ClusterBind9Provider`, namespace will be empty.
3396    #[serde(skip_serializing_if = "Option::is_none")]
3397    pub cluster_ref: Option<ClusterReference>,
3398    /// List of DNS zones that have selected this instance.
3399    ///
3400    /// This field is automatically populated by a status-only watcher on `DNSZones`.
3401    /// When a `DNSZone`'s `status.bind9Instances` includes this instance, the zone
3402    /// is added to this list. This provides a reverse lookup: instance → zones.
3403    ///
3404    /// Updated by: `DNSZone` status watcher (not by instance reconciler)
3405    /// Used for: Observability, debugging zone assignments
3406    #[serde(default)]
3407    #[serde(skip_serializing_if = "Vec::is_empty")]
3408    pub zones: Vec<ZoneReference>,
3409
3410    /// Number of zones in the `zones` list.
3411    ///
3412    /// This field is automatically updated whenever the `zones` list changes.
3413    /// It provides a quick way to see how many zones are selecting this instance
3414    /// without having to count the array elements.
3415    #[serde(skip_serializing_if = "Option::is_none")]
3416    pub zones_count: Option<i32>,
3417
3418    /// RNDC key rotation status and tracking information.
3419    ///
3420    /// Populated when `auto_rotate` is enabled in the RNDC configuration. Provides
3421    /// visibility into key lifecycle: creation time, next rotation time, and rotation count.
3422    ///
3423    /// This field is automatically updated by the instance reconciler whenever:
3424    /// - A new RNDC key is generated
3425    /// - An RNDC key is rotated
3426    /// - The rotation configuration changes
3427    ///
3428    /// **Note**: Only present when using operator-managed RNDC keys. If you specify
3429    /// `secret_ref` to use an external Secret, this field will be empty.
3430    #[serde(skip_serializing_if = "Option::is_none")]
3431    pub rndc_key_rotation: Option<RndcKeyRotationStatus>,
3432}
3433
3434/// RNDC key rotation status and tracking information.
3435///
3436/// Tracks the lifecycle of operator-managed RNDC keys including creation time,
3437/// next rotation time, last rotation time, and rotation count.
3438///
3439/// This status is automatically updated by the instance reconciler whenever keys
3440/// are created or rotated. It provides visibility into key age and rotation history
3441/// for compliance and operational purposes.
3442///
3443/// # Examples
3444///
3445/// ```yaml
3446/// # Initial key creation (no rotation yet)
3447/// rndcKeyRotation:
3448///   createdAt: "2025-01-26T10:00:00Z"
3449///   rotateAt: "2025-02-25T10:00:00Z"
3450///   rotationCount: 0
3451///
3452/// # After first rotation
3453/// rndcKeyRotation:
3454///   createdAt: "2025-02-25T10:00:00Z"
3455///   rotateAt: "2025-03-27T10:00:00Z"
3456///   lastRotatedAt: "2025-02-25T10:00:00Z"
3457///   rotationCount: 1
3458/// ```
3459#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema, Default)]
3460#[serde(rename_all = "camelCase")]
3461pub struct RndcKeyRotationStatus {
3462    /// Timestamp when the current key was created (ISO 8601 format).
3463    ///
3464    /// This timestamp is set when:
3465    /// - A new RNDC key is generated for the first time
3466    /// - An existing key is rotated (timestamp updates to rotation time)
3467    ///
3468    /// Example: `"2025-01-26T10:00:00Z"`
3469    pub created_at: String,
3470
3471    /// Timestamp when the key will be rotated next (ISO 8601 format).
3472    ///
3473    /// Calculated as: `created_at + rotate_after`
3474    ///
3475    /// Only present if `auto_rotate` is enabled. When `auto_rotate` is disabled,
3476    /// this field will be empty as no automatic rotation is scheduled.
3477    ///
3478    /// Example: `"2025-02-25T10:00:00Z"` (30 days after creation)
3479    #[serde(skip_serializing_if = "Option::is_none")]
3480    pub rotate_at: Option<String>,
3481
3482    /// Timestamp of the last successful rotation (ISO 8601 format).
3483    ///
3484    /// Only present after at least one rotation has occurred. For newly-created
3485    /// keys that have never been rotated, this field will be empty.
3486    ///
3487    /// This is useful for tracking the actual rotation history and verifying that
3488    /// rotation is working as expected.
3489    ///
3490    /// Example: `"2025-02-25T10:00:00Z"`
3491    #[serde(skip_serializing_if = "Option::is_none")]
3492    pub last_rotated_at: Option<String>,
3493
3494    /// Number of times the key has been rotated.
3495    ///
3496    /// Starts at `0` for newly-created keys and increments by 1 each time the
3497    /// key is rotated. This counter persists across key rotations and provides
3498    /// a historical count for compliance and audit purposes.
3499    ///
3500    /// Example: `5` (key has been rotated 5 times)
3501    #[serde(default)]
3502    pub rotation_count: u32,
3503}
3504
3505/// Reference to a DNS zone selected by an instance.
3506///
3507/// This structure follows Kubernetes object reference conventions and stores
3508/// the complete information needed to reference a `DNSZone` resource.
3509///
3510/// **Note on Equality:** `PartialEq`, `Eq`, and `Hash` are implemented to compare only the
3511/// identity fields (`api_version`, `kind`, `name`, `namespace`, `zone_name`), ignoring `last_reconciled_at`.
3512/// This ensures that zones are correctly identified as duplicates even when their
3513/// reconciliation timestamps differ.
3514#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3515#[serde(rename_all = "camelCase")]
3516pub struct ZoneReference {
3517    /// API version of the `DNSZone` resource (e.g., "bindy.firestoned.io/v1beta1")
3518    pub api_version: String,
3519    /// Kind of the resource (always "`DNSZone`")
3520    pub kind: String,
3521    /// Name of the `DNSZone` resource
3522    pub name: String,
3523    /// Namespace of the `DNSZone` resource
3524    pub namespace: String,
3525    /// Fully qualified domain name from the zone's spec (e.g., "example.com")
3526    pub zone_name: String,
3527    /// Timestamp when this zone was last successfully configured on the instance.
3528    ///
3529    /// This field is set by the `DNSZone` controller after successfully applying zone configuration
3530    /// to the instance. It is reset to `None` when:
3531    /// - The instance's pod restarts (requiring zone reconfiguration)
3532    /// - The instance's spec changes (requiring reconfiguration)
3533    ///
3534    /// The `DNSZone` controller uses this field to determine which instances need zone configuration.
3535    /// If this field is `None`, the zone needs to be configured on the instance.
3536    #[serde(skip_serializing_if = "Option::is_none")]
3537    pub last_reconciled_at: Option<String>,
3538}
3539
3540// Implement PartialEq to compare only identity fields, ignoring last_reconciled_at
3541impl PartialEq for ZoneReference {
3542    fn eq(&self, other: &Self) -> bool {
3543        self.api_version == other.api_version
3544            && self.kind == other.kind
3545            && self.name == other.name
3546            && self.namespace == other.namespace
3547            && self.zone_name == other.zone_name
3548    }
3549}
3550
3551// Implement Eq for ZoneReference
3552impl Eq for ZoneReference {}
3553
3554// Implement Hash to hash only identity fields
3555impl std::hash::Hash for ZoneReference {
3556    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3557        self.api_version.hash(state);
3558        self.kind.hash(state);
3559        self.name.hash(state);
3560        self.namespace.hash(state);
3561        self.zone_name.hash(state);
3562        // Deliberately exclude last_reconciled_at from hash
3563    }
3564}
3565
3566/// Full Kubernetes object reference to a cluster resource.
3567///
3568/// This structure follows Kubernetes object reference conventions and stores
3569/// the complete information needed to reference either a namespace-scoped
3570/// `Bind9Cluster` or cluster-scoped `ClusterBind9Provider`.
3571///
3572/// This enables proper object references and provides backward compatibility
3573/// with `spec.clusterRef` (which stores only the name as a string).
3574#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)]
3575#[serde(rename_all = "camelCase")]
3576pub struct ClusterReference {
3577    /// API version of the referenced cluster (e.g., "bindy.firestoned.io/v1beta1")
3578    pub api_version: String,
3579    /// Kind of the referenced cluster ("`Bind9Cluster`" or "`ClusterBind9Provider`")
3580    pub kind: String,
3581    /// Name of the cluster resource
3582    pub name: String,
3583    /// Namespace of the cluster resource.
3584    ///
3585    /// For namespace-scoped `Bind9Cluster`, this is the cluster's namespace.
3586    /// For cluster-scoped `ClusterBind9Provider`, this field is empty/None.
3587    #[serde(skip_serializing_if = "Option::is_none")]
3588    pub namespace: Option<String>,
3589}
3590
3591/// Full Kubernetes object reference to a `Bind9Instance` resource.
3592///
3593/// This structure follows Kubernetes object reference conventions and stores
3594/// the complete information needed to reference a namespace-scoped `Bind9Instance`.
3595///
3596/// Used in `DNSZone.status.bind9Instances` for tracking instances that have claimed the zone
3597/// (via `bind9InstancesFrom` label selectors or `clusterRef`).
3598///
3599/// **Note on Equality:** `PartialEq`, `Eq`, and `Hash` are implemented to compare only the
3600/// identity fields (`api_version`, `kind`, `name`, `namespace`), ignoring `last_reconciled_at`.
3601/// This ensures that instances are correctly identified as duplicates even when their
3602/// reconciliation timestamps differ.
3603#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3604#[serde(rename_all = "camelCase")]
3605pub struct InstanceReference {
3606    /// API version of the `Bind9Instance` resource (e.g., "bindy.firestoned.io/v1beta1")
3607    pub api_version: String,
3608    /// Kind of the resource (always "`Bind9Instance`")
3609    pub kind: String,
3610    /// Name of the `Bind9Instance` resource
3611    pub name: String,
3612    /// Namespace of the `Bind9Instance` resource
3613    pub namespace: String,
3614    /// Timestamp when this instance was last successfully reconciled with zone configuration.
3615    ///
3616    /// This field is set when the zone configuration is successfully applied to the instance.
3617    /// It is reset (cleared) when:
3618    /// - The instance is deleted
3619    /// - The instance's pod IP changes (requiring zone reconfiguration)
3620    /// - The zone spec changes (requiring reconfiguration)
3621    ///
3622    /// The reconciler uses this field to determine which instances need zone configuration.
3623    /// If this field is `None` or the timestamp is before the last spec change, the instance
3624    /// will be reconfigured.
3625    #[serde(skip_serializing_if = "Option::is_none")]
3626    pub last_reconciled_at: Option<String>,
3627}
3628
3629// Implement PartialEq to compare only identity fields, ignoring last_reconciled_at
3630impl PartialEq for InstanceReference {
3631    fn eq(&self, other: &Self) -> bool {
3632        self.api_version == other.api_version
3633            && self.kind == other.kind
3634            && self.name == other.name
3635            && self.namespace == other.namespace
3636    }
3637}
3638
3639// Implement Eq for InstanceReference
3640impl Eq for InstanceReference {}
3641
3642// Implement Hash to hash only identity fields
3643impl std::hash::Hash for InstanceReference {
3644    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
3645        self.api_version.hash(state);
3646        self.kind.hash(state);
3647        self.name.hash(state);
3648        self.namespace.hash(state);
3649        self.last_reconciled_at.hash(state);
3650    }
3651}
3652
3653/// Storage configuration for zone files
3654#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3655#[serde(rename_all = "camelCase")]
3656pub struct StorageConfig {
3657    /// Storage type (emptyDir or persistentVolumeClaim)
3658    #[serde(default = "default_storage_type")]
3659    pub storage_type: StorageType,
3660
3661    /// `EmptyDir` configuration (used when storageType is emptyDir)
3662    #[serde(skip_serializing_if = "Option::is_none")]
3663    pub empty_dir: Option<k8s_openapi::api::core::v1::EmptyDirVolumeSource>,
3664
3665    /// `PersistentVolumeClaim` configuration (used when storageType is persistentVolumeClaim)
3666    #[serde(skip_serializing_if = "Option::is_none")]
3667    pub persistent_volume_claim: Option<PersistentVolumeClaimConfig>,
3668}
3669
3670fn default_storage_type() -> StorageType {
3671    StorageType::EmptyDir
3672}
3673
3674/// Storage type for zone files
3675#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)]
3676#[serde(rename_all = "camelCase")]
3677pub enum StorageType {
3678    /// Ephemeral storage (default) - data is lost when pod restarts
3679    EmptyDir,
3680    /// Persistent storage - data survives pod restarts
3681    PersistentVolumeClaim,
3682}
3683
3684/// `PersistentVolumeClaim` configuration
3685#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)]
3686#[serde(rename_all = "camelCase")]
3687pub struct PersistentVolumeClaimConfig {
3688    /// Name of an existing PVC to use
3689    #[serde(skip_serializing_if = "Option::is_none")]
3690    pub claim_name: Option<String>,
3691
3692    /// Storage class name for dynamic provisioning
3693    #[serde(skip_serializing_if = "Option::is_none")]
3694    pub storage_class_name: Option<String>,
3695
3696    /// Storage size (e.g., "10Gi", "1Ti")
3697    #[serde(skip_serializing_if = "Option::is_none")]
3698    pub size: Option<String>,
3699
3700    /// Access modes (`ReadWriteOnce`, `ReadOnlyMany`, `ReadWriteMany`)
3701    #[serde(skip_serializing_if = "Option::is_none")]
3702    pub access_modes: Option<Vec<String>>,
3703}
3704
3705/// Bindcar container configuration
3706#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, JsonSchema)]
3707#[serde(rename_all = "camelCase")]
3708pub struct BindcarConfig {
3709    /// Container image for the RNDC API sidecar
3710    ///
3711    /// Example: "ghcr.io/firestoned/bindcar:v0.7.2"
3712    #[serde(skip_serializing_if = "Option::is_none")]
3713    pub image: Option<String>,
3714
3715    /// Image pull policy (`Always`, `IfNotPresent`, `Never`)
3716    #[serde(skip_serializing_if = "Option::is_none")]
3717    pub image_pull_policy: Option<String>,
3718
3719    /// Resource requirements for the Bindcar container
3720    #[serde(skip_serializing_if = "Option::is_none")]
3721    pub resources: Option<k8s_openapi::api::core::v1::ResourceRequirements>,
3722
3723    /// API server container port (default: 8080)
3724    #[serde(skip_serializing_if = "Option::is_none")]
3725    pub port: Option<i32>,
3726
3727    /// Custom Kubernetes Service spec for the bindcar HTTP API
3728    ///
3729    /// Allows full customization of the Service that exposes the bindcar API.
3730    /// This is merged with the default Service spec, allowing overrides of ports,
3731    /// type, sessionAffinity, and other Service configurations.
3732    ///
3733    /// Example:
3734    /// ```yaml
3735    /// serviceSpec:
3736    ///   type: NodePort
3737    ///   ports:
3738    ///     - name: http
3739    ///       port: 8000
3740    ///       targetPort: 8080
3741    ///       nodePort: 30080
3742    /// ```
3743    #[serde(skip_serializing_if = "Option::is_none")]
3744    pub service_spec: Option<k8s_openapi::api::core::v1::ServiceSpec>,
3745
3746    /// Log level for the Bindcar container (`debug`, `info`, `warn`, `error`)
3747    #[serde(skip_serializing_if = "Option::is_none")]
3748    pub log_level: Option<String>,
3749
3750    /// Environment variables for the Bindcar container
3751    #[serde(skip_serializing_if = "Option::is_none")]
3752    pub env_vars: Option<Vec<EnvVar>>,
3753    // NOTE: `volumes` and `volume_mounts` were removed in v0.5.1 (audit
3754    // finding F-001 mitigation). They were declared on `BindcarConfig` but
3755    // never plumbed into `build_api_sidecar_container`, so removing them is
3756    // a no-op for the runtime and prevents a future "wire these through"
3757    // change from re-introducing the unfiltered Volume / VolumeMount
3758    // priv-esc primitive. Use `Bind9Instance.spec.volumes` /
3759    // `volumeMounts` (validated by `crate::safe_volume`) for any genuine
3760    // need to mount additional storage into the BIND9 pod.
3761}