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