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