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