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