1pub mod status_helpers;
12pub mod types;
13
14use status_helpers::update_record_status;
16
17use crate::crd::{
19 AAAARecord, ARecord, CAARecord, CNAMERecord, DNSZone, MXRecord, NSRecord, SRVRecord, TXTRecord,
20};
21use anyhow::{Context, Result};
22use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
23
24use kube::{
25 api::{Patch, PatchParams},
26 client::Client,
27 Api, Resource, ResourceExt,
28};
29use serde_json::json;
30use tracing::{debug, info, warn};
31
32async fn get_zone_from_ref(
50 client: &Client,
51 zone_ref: &crate::crd::ZoneReference,
52) -> Result<DNSZone> {
53 let dns_zones_api: Api<DNSZone> = Api::namespaced(client.clone(), &zone_ref.namespace);
54
55 dns_zones_api.get(&zone_ref.name).await.context(format!(
56 "Failed to get DNSZone {}/{}",
57 zone_ref.namespace, zone_ref.name
58 ))
59}
60
61struct RecordReconciliationContext {
65 zone_ref: crate::crd::ZoneReference,
67 primary_refs: Vec<crate::crd::InstanceReference>,
69 current_hash: String,
71}
72
73#[allow(clippy::too_many_lines)]
99async fn prepare_record_reconciliation<T, S>(
100 client: &Client,
101 record: &T,
102 record_type: &str,
103 spec_hashable: &S,
104 bind9_instances_store: &kube::runtime::reflector::Store<crate::crd::Bind9Instance>,
105) -> Result<Option<RecordReconciliationContext>>
106where
107 T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
108 + ResourceExt
109 + Clone
110 + std::fmt::Debug
111 + serde::Serialize
112 + for<'de> serde::Deserialize<'de>,
113 S: serde::Serialize,
114{
115 let namespace = record.namespace().unwrap_or_default();
116 let name = record.name_any();
117
118 let record_json = serde_json::to_value(record)?;
120 let status = record_json.get("status");
121
122 let zone_ref = status
123 .and_then(|s| s.get("zoneRef"))
124 .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z.clone()).ok());
125
126 let observed_generation = status
127 .and_then(|s| s.get("observedGeneration"))
128 .and_then(serde_json::Value::as_i64);
129
130 let current_generation = record.meta().generation;
131
132 let Some(zone_ref) = zone_ref else {
134 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
136 debug!("Spec unchanged and no zoneRef, skipping reconciliation");
137 return Ok(None);
138 }
139
140 info!(
141 "{} record {}/{} not selected by any DNSZone (no zoneRef in status)",
142 record_type, namespace, name
143 );
144 update_record_status(
145 client,
146 record,
147 "Ready",
148 "False",
149 "NotSelected",
150 "Record not selected by any DNSZone recordsFrom selector",
151 current_generation,
152 None, None, None, None, )
157 .await?;
158 return Ok(None);
159 };
160
161 let current_hash = crate::ddns::calculate_record_hash(spec_hashable);
163
164 let dnszone = match get_zone_from_ref(client, &zone_ref).await {
166 Ok(zone) => zone,
167 Err(e) => {
168 warn!(
169 "Failed to get DNSZone {}/{} for {} record {}/{}: {}",
170 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
171 );
172 update_record_status(
173 client,
174 record,
175 "Ready",
176 "False",
177 "ZoneNotFound",
178 &format!(
179 "Referenced DNSZone {}/{} not found: {e}",
180 zone_ref.namespace, zone_ref.name
181 ),
182 current_generation,
183 None, None, None, None, )
188 .await?;
189 return Ok(None);
190 }
191 };
192
193 let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
195 &dnszone,
196 bind9_instances_store,
197 ) {
198 Ok(refs) => refs,
199 Err(e) => {
200 warn!(
201 "DNSZone {}/{} has no instances assigned for {} record {}/{}: {}",
202 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
203 );
204 update_record_status(
205 client,
206 record,
207 "Ready",
208 "False",
209 "ZoneNotConfigured",
210 &format!("DNSZone has no instances: {e}"),
211 current_generation,
212 None, None, None, None, )
217 .await?;
218 return Ok(None);
219 }
220 };
221
222 let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
224 client,
225 &instance_refs,
226 )
227 .await
228 {
229 Ok(refs) => refs,
230 Err(e) => {
231 warn!(
232 "Failed to filter primary instances for {} record {}/{}: {}",
233 record_type, namespace, name, e
234 );
235 update_record_status(
236 client,
237 record,
238 "Ready",
239 "False",
240 "InstanceFilterError",
241 &format!("Failed to filter primary instances: {e}"),
242 current_generation,
243 None, None, None, None, )
248 .await?;
249 return Ok(None);
250 }
251 };
252
253 if primary_refs.is_empty() {
254 warn!(
255 "DNSZone {}/{} has no primary instances for {} record {}/{}",
256 zone_ref.namespace, zone_ref.name, record_type, namespace, name
257 );
258 update_record_status(
259 client,
260 record,
261 "Ready",
262 "False",
263 "NoPrimaryInstances",
264 "DNSZone has no primary instances configured",
265 current_generation,
266 None, None, None, None, )
271 .await?;
272 return Ok(None);
273 }
274
275 Ok(Some(RecordReconciliationContext {
276 zone_ref,
277 primary_refs,
278 current_hash,
279 }))
280}
281
282trait RecordOperation: Clone + Send + Sync {
313 fn record_type_name(&self) -> &'static str;
315
316 fn add_to_bind9(
331 &self,
332 zone_manager: &crate::bind9::Bind9Manager,
333 zone_name: &str,
334 record_name: &str,
335 ttl: Option<i32>,
336 server: &str,
337 key_data: &crate::bind9::RndcKeyData,
338 ) -> impl std::future::Future<Output = Result<()>> + Send;
339}
340
341trait ReconcilableRecord:
379 Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
380 + ResourceExt
381 + Clone
382 + std::fmt::Debug
383 + serde::Serialize
384 + for<'de> serde::Deserialize<'de>
385 + Send
386 + Sync
387{
388 type Spec: serde::Serialize + Clone;
390
391 type Operation: RecordOperation;
393
394 fn get_spec(&self) -> &Self::Spec;
396
397 fn get_status(&self) -> Option<&crate::crd::RecordStatus>;
399
400 fn record_type_name() -> &'static str;
402
403 fn record_type_hickory() -> hickory_proto::rr::RecordType;
405
406 fn create_operation(spec: &Self::Spec) -> Self::Operation;
408
409 fn get_record_name(spec: &Self::Spec) -> &str;
411
412 fn get_ttl(spec: &Self::Spec) -> Option<i32>;
414
415 fn get_display_addresses(_spec: &Self::Spec) -> Option<String> {
419 None
420 }
421}
422
423async fn add_record_to_instances_generic<R>(
447 client: &Client,
448 stores: &crate::context::Stores,
449 instance_refs: &[crate::crd::InstanceReference],
450 zone_name: &str,
451 record_name: &str,
452 ttl: Option<i32>,
453 record_op: R,
454) -> Result<()>
455where
456 R: RecordOperation,
457{
458 use crate::reconcilers::dnszone::helpers::for_each_instance_endpoint;
459
460 let instance_map: std::collections::HashMap<String, String> = instance_refs
462 .iter()
463 .map(|inst| (inst.name.clone(), inst.namespace.clone()))
464 .collect();
465
466 let (_first, _total) = for_each_instance_endpoint(
467 client,
468 instance_refs,
469 true, "dns-tcp", |pod_endpoint, instance_name, rndc_key| {
472 let zone_name = zone_name.to_string();
473 let record_name = record_name.to_string();
474
475 let instance_namespace = instance_map
477 .get(&instance_name)
478 .expect("Instance should be in map")
479 .clone();
480
481 let zone_manager =
483 stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
484
485 let record_op_clone = record_op.clone();
487
488 async move {
489 let key_data = rndc_key.expect("RNDC key should be loaded");
490
491 record_op_clone
492 .add_to_bind9(&zone_manager, &zone_name, &record_name, ttl, &pod_endpoint, &key_data)
493 .await
494 .context(format!(
495 "Failed to add {} record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})",
496 record_op_clone.record_type_name()
497 ))?;
498
499 Ok(())
500 }
501 },
502 )
503 .await?;
504
505 Ok(())
506}
507
508#[derive(Clone)]
512struct ARecordOp {
513 ipv4_addresses: Vec<String>,
514}
515
516impl RecordOperation for ARecordOp {
517 fn record_type_name(&self) -> &'static str {
518 "A"
519 }
520
521 async fn add_to_bind9(
522 &self,
523 zone_manager: &crate::bind9::Bind9Manager,
524 zone_name: &str,
525 record_name: &str,
526 ttl: Option<i32>,
527 server: &str,
528 key_data: &crate::bind9::RndcKeyData,
529 ) -> Result<()> {
530 zone_manager
531 .add_a_record(
532 zone_name,
533 record_name,
534 &self.ipv4_addresses,
535 ttl,
536 server,
537 key_data,
538 )
539 .await
540 }
541}
542
543impl ReconcilableRecord for ARecord {
545 type Spec = crate::crd::ARecordSpec;
546 type Operation = ARecordOp;
547
548 fn get_spec(&self) -> &Self::Spec {
549 &self.spec
550 }
551
552 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
553 self.status.as_ref()
554 }
555
556 fn record_type_name() -> &'static str {
557 "A"
558 }
559
560 fn record_type_hickory() -> hickory_proto::rr::RecordType {
561 hickory_proto::rr::RecordType::A
562 }
563
564 fn create_operation(spec: &Self::Spec) -> Self::Operation {
565 ARecordOp {
566 ipv4_addresses: spec.ipv4_addresses.clone(),
567 }
568 }
569
570 fn get_record_name(spec: &Self::Spec) -> &str {
571 &spec.name
572 }
573
574 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
575 spec.ttl
576 }
577
578 fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
579 Some(spec.ipv4_addresses.join(","))
580 }
581}
582
583#[derive(Clone)]
585struct AAAARecordOp {
586 ipv6_addresses: Vec<String>,
587}
588
589impl RecordOperation for AAAARecordOp {
590 fn record_type_name(&self) -> &'static str {
591 "AAAA"
592 }
593
594 async fn add_to_bind9(
595 &self,
596 zone_manager: &crate::bind9::Bind9Manager,
597 zone_name: &str,
598 record_name: &str,
599 ttl: Option<i32>,
600 server: &str,
601 key_data: &crate::bind9::RndcKeyData,
602 ) -> Result<()> {
603 zone_manager
604 .add_aaaa_record(
605 zone_name,
606 record_name,
607 &self.ipv6_addresses,
608 ttl,
609 server,
610 key_data,
611 )
612 .await
613 }
614}
615
616impl ReconcilableRecord for AAAARecord {
618 type Spec = crate::crd::AAAARecordSpec;
619 type Operation = AAAARecordOp;
620
621 fn get_spec(&self) -> &Self::Spec {
622 &self.spec
623 }
624
625 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
626 self.status.as_ref()
627 }
628
629 fn record_type_name() -> &'static str {
630 "AAAA"
631 }
632
633 fn record_type_hickory() -> hickory_proto::rr::RecordType {
634 hickory_proto::rr::RecordType::AAAA
635 }
636
637 fn create_operation(spec: &Self::Spec) -> Self::Operation {
638 AAAARecordOp {
639 ipv6_addresses: spec.ipv6_addresses.clone(),
640 }
641 }
642
643 fn get_record_name(spec: &Self::Spec) -> &str {
644 &spec.name
645 }
646
647 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
648 spec.ttl
649 }
650
651 fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
652 Some(spec.ipv6_addresses.join(","))
653 }
654}
655
656#[derive(Clone)]
658struct CNAMERecordOp {
659 target: String,
660}
661
662impl RecordOperation for CNAMERecordOp {
663 fn record_type_name(&self) -> &'static str {
664 "CNAME"
665 }
666
667 async fn add_to_bind9(
668 &self,
669 zone_manager: &crate::bind9::Bind9Manager,
670 zone_name: &str,
671 record_name: &str,
672 ttl: Option<i32>,
673 server: &str,
674 key_data: &crate::bind9::RndcKeyData,
675 ) -> Result<()> {
676 zone_manager
677 .add_cname_record(zone_name, record_name, &self.target, ttl, server, key_data)
678 .await
679 }
680}
681
682impl ReconcilableRecord for CNAMERecord {
684 type Spec = crate::crd::CNAMERecordSpec;
685 type Operation = CNAMERecordOp;
686
687 fn get_spec(&self) -> &Self::Spec {
688 &self.spec
689 }
690
691 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
692 self.status.as_ref()
693 }
694
695 fn record_type_name() -> &'static str {
696 "CNAME"
697 }
698
699 fn record_type_hickory() -> hickory_proto::rr::RecordType {
700 hickory_proto::rr::RecordType::CNAME
701 }
702
703 fn create_operation(spec: &Self::Spec) -> Self::Operation {
704 CNAMERecordOp {
705 target: spec.target.clone(),
706 }
707 }
708
709 fn get_record_name(spec: &Self::Spec) -> &str {
710 &spec.name
711 }
712
713 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
714 spec.ttl
715 }
716}
717
718#[derive(Clone)]
720struct TXTRecordOp {
721 texts: Vec<String>,
722}
723
724impl RecordOperation for TXTRecordOp {
725 fn record_type_name(&self) -> &'static str {
726 "TXT"
727 }
728
729 async fn add_to_bind9(
730 &self,
731 zone_manager: &crate::bind9::Bind9Manager,
732 zone_name: &str,
733 record_name: &str,
734 ttl: Option<i32>,
735 server: &str,
736 key_data: &crate::bind9::RndcKeyData,
737 ) -> Result<()> {
738 zone_manager
739 .add_txt_record(zone_name, record_name, &self.texts, ttl, server, key_data)
740 .await
741 }
742}
743
744impl ReconcilableRecord for TXTRecord {
746 type Spec = crate::crd::TXTRecordSpec;
747 type Operation = TXTRecordOp;
748
749 fn get_spec(&self) -> &Self::Spec {
750 &self.spec
751 }
752
753 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
754 self.status.as_ref()
755 }
756
757 fn record_type_name() -> &'static str {
758 "TXT"
759 }
760
761 fn record_type_hickory() -> hickory_proto::rr::RecordType {
762 hickory_proto::rr::RecordType::TXT
763 }
764
765 fn create_operation(spec: &Self::Spec) -> Self::Operation {
766 TXTRecordOp {
767 texts: spec.text.clone(),
768 }
769 }
770
771 fn get_record_name(spec: &Self::Spec) -> &str {
772 &spec.name
773 }
774
775 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
776 spec.ttl
777 }
778}
779
780#[derive(Clone)]
782struct MXRecordOp {
783 priority: i32,
784 mail_server: String,
785}
786
787impl RecordOperation for MXRecordOp {
788 fn record_type_name(&self) -> &'static str {
789 "MX"
790 }
791
792 async fn add_to_bind9(
793 &self,
794 zone_manager: &crate::bind9::Bind9Manager,
795 zone_name: &str,
796 record_name: &str,
797 ttl: Option<i32>,
798 server: &str,
799 key_data: &crate::bind9::RndcKeyData,
800 ) -> Result<()> {
801 zone_manager
802 .add_mx_record(
803 zone_name,
804 record_name,
805 self.priority,
806 &self.mail_server,
807 ttl,
808 server,
809 key_data,
810 )
811 .await
812 }
813}
814
815impl ReconcilableRecord for MXRecord {
817 type Spec = crate::crd::MXRecordSpec;
818 type Operation = MXRecordOp;
819
820 fn get_spec(&self) -> &Self::Spec {
821 &self.spec
822 }
823
824 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
825 self.status.as_ref()
826 }
827
828 fn record_type_name() -> &'static str {
829 "MX"
830 }
831
832 fn record_type_hickory() -> hickory_proto::rr::RecordType {
833 hickory_proto::rr::RecordType::MX
834 }
835
836 fn create_operation(spec: &Self::Spec) -> Self::Operation {
837 MXRecordOp {
838 priority: spec.priority,
839 mail_server: spec.mail_server.clone(),
840 }
841 }
842
843 fn get_record_name(spec: &Self::Spec) -> &str {
844 &spec.name
845 }
846
847 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
848 spec.ttl
849 }
850}
851
852#[derive(Clone)]
854struct NSRecordOp {
855 nameserver: String,
856}
857
858impl RecordOperation for NSRecordOp {
859 fn record_type_name(&self) -> &'static str {
860 "NS"
861 }
862
863 async fn add_to_bind9(
864 &self,
865 zone_manager: &crate::bind9::Bind9Manager,
866 zone_name: &str,
867 record_name: &str,
868 ttl: Option<i32>,
869 server: &str,
870 key_data: &crate::bind9::RndcKeyData,
871 ) -> Result<()> {
872 zone_manager
873 .add_ns_record(
874 zone_name,
875 record_name,
876 &self.nameserver,
877 ttl,
878 server,
879 key_data,
880 )
881 .await
882 }
883}
884
885impl ReconcilableRecord for NSRecord {
887 type Spec = crate::crd::NSRecordSpec;
888 type Operation = NSRecordOp;
889
890 fn get_spec(&self) -> &Self::Spec {
891 &self.spec
892 }
893
894 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
895 self.status.as_ref()
896 }
897
898 fn record_type_name() -> &'static str {
899 "NS"
900 }
901
902 fn record_type_hickory() -> hickory_proto::rr::RecordType {
903 hickory_proto::rr::RecordType::NS
904 }
905
906 fn create_operation(spec: &Self::Spec) -> Self::Operation {
907 NSRecordOp {
908 nameserver: spec.nameserver.clone(),
909 }
910 }
911
912 fn get_record_name(spec: &Self::Spec) -> &str {
913 &spec.name
914 }
915
916 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
917 spec.ttl
918 }
919}
920
921#[derive(Clone)]
923struct SRVRecordOp {
924 priority: i32,
925 weight: i32,
926 port: i32,
927 target: String,
928}
929
930impl RecordOperation for SRVRecordOp {
931 fn record_type_name(&self) -> &'static str {
932 "SRV"
933 }
934
935 async fn add_to_bind9(
936 &self,
937 zone_manager: &crate::bind9::Bind9Manager,
938 zone_name: &str,
939 record_name: &str,
940 ttl: Option<i32>,
941 server: &str,
942 key_data: &crate::bind9::RndcKeyData,
943 ) -> Result<()> {
944 let srv_data = crate::bind9::SRVRecordData {
945 priority: self.priority,
946 weight: self.weight,
947 port: self.port,
948 target: self.target.clone(),
949 ttl,
950 };
951 zone_manager
952 .add_srv_record(zone_name, record_name, &srv_data, server, key_data)
953 .await
954 }
955}
956
957impl ReconcilableRecord for SRVRecord {
959 type Spec = crate::crd::SRVRecordSpec;
960 type Operation = SRVRecordOp;
961
962 fn get_spec(&self) -> &Self::Spec {
963 &self.spec
964 }
965
966 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
967 self.status.as_ref()
968 }
969
970 fn record_type_name() -> &'static str {
971 "SRV"
972 }
973
974 fn record_type_hickory() -> hickory_proto::rr::RecordType {
975 hickory_proto::rr::RecordType::SRV
976 }
977
978 fn create_operation(spec: &Self::Spec) -> Self::Operation {
979 SRVRecordOp {
980 priority: spec.priority,
981 weight: spec.weight,
982 port: spec.port,
983 target: spec.target.clone(),
984 }
985 }
986
987 fn get_record_name(spec: &Self::Spec) -> &str {
988 &spec.name
989 }
990
991 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
992 spec.ttl
993 }
994}
995
996#[derive(Clone)]
998struct CAARecordOp {
999 flags: i32,
1000 tag: String,
1001 value: String,
1002}
1003
1004impl RecordOperation for CAARecordOp {
1005 fn record_type_name(&self) -> &'static str {
1006 "CAA"
1007 }
1008
1009 async fn add_to_bind9(
1010 &self,
1011 zone_manager: &crate::bind9::Bind9Manager,
1012 zone_name: &str,
1013 record_name: &str,
1014 ttl: Option<i32>,
1015 server: &str,
1016 key_data: &crate::bind9::RndcKeyData,
1017 ) -> Result<()> {
1018 zone_manager
1019 .add_caa_record(
1020 zone_name,
1021 record_name,
1022 self.flags,
1023 &self.tag,
1024 &self.value,
1025 ttl,
1026 server,
1027 key_data,
1028 )
1029 .await
1030 }
1031}
1032
1033impl ReconcilableRecord for CAARecord {
1035 type Spec = crate::crd::CAARecordSpec;
1036 type Operation = CAARecordOp;
1037
1038 fn get_spec(&self) -> &Self::Spec {
1039 &self.spec
1040 }
1041
1042 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
1043 self.status.as_ref()
1044 }
1045
1046 fn record_type_name() -> &'static str {
1047 "CAA"
1048 }
1049
1050 fn record_type_hickory() -> hickory_proto::rr::RecordType {
1051 hickory_proto::rr::RecordType::CAA
1052 }
1053
1054 fn create_operation(spec: &Self::Spec) -> Self::Operation {
1055 CAARecordOp {
1056 flags: spec.flags,
1057 tag: spec.tag.clone(),
1058 value: spec.value.clone(),
1059 }
1060 }
1061
1062 fn get_record_name(spec: &Self::Spec) -> &str {
1063 &spec.name
1064 }
1065
1066 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
1067 spec.ttl
1068 }
1069}
1070
1071async fn reconcile_record<T>(ctx: std::sync::Arc<crate::context::Context>, record: T) -> Result<()>
1104where
1105 T: ReconcilableRecord,
1106{
1107 let client = ctx.client.clone();
1108 let bind9_instances_store = &ctx.stores.bind9_instances;
1109 let namespace = record.namespace().unwrap_or_default();
1110 let name = record.name_any();
1111
1112 info!(
1113 "Reconciling {}Record: {}/{}",
1114 T::record_type_name(),
1115 namespace,
1116 name
1117 );
1118
1119 let spec = record.get_spec();
1120 let current_generation = record.meta().generation;
1121
1122 let Some(rec_ctx) = prepare_record_reconciliation(
1124 &client,
1125 &record,
1126 T::record_type_name(),
1127 spec,
1128 bind9_instances_store,
1129 )
1130 .await?
1131 else {
1132 return Ok(()); };
1134
1135 if let Some(old_name) = detect_renamed_record(record.get_status(), T::get_record_name(spec)) {
1139 info!(
1140 "{} record {}/{} renamed from '{}' to '{}' - deleting old name from zone {}",
1141 T::record_type_name(),
1142 namespace,
1143 name,
1144 old_name,
1145 T::get_record_name(spec),
1146 rec_ctx.zone_ref.zone_name
1147 );
1148
1149 if let Err(e) = delete_record_from_primaries(
1150 &client,
1151 &ctx.stores,
1152 &rec_ctx.primary_refs,
1153 &rec_ctx.zone_ref.zone_name,
1154 &old_name,
1155 T::record_type_hickory(),
1156 true, )
1158 .await
1159 {
1160 warn!(
1161 "Failed to delete renamed {} record '{}' from zone {}: {}",
1162 T::record_type_name(),
1163 old_name,
1164 rec_ctx.zone_ref.zone_name,
1165 e
1166 );
1167 update_record_status(
1168 &client,
1169 &record,
1170 "Ready",
1171 "False",
1172 "ReconcileFailed",
1173 &format!("Failed to delete renamed record '{old_name}' from zone: {e}"),
1174 current_generation,
1175 None, None, None, None, )
1180 .await?;
1181 return Ok(());
1182 }
1183 }
1184
1185 let record_op = T::create_operation(spec);
1187
1188 match add_record_to_instances_generic(
1190 &client,
1191 &ctx.stores,
1192 &rec_ctx.primary_refs,
1193 &rec_ctx.zone_ref.zone_name,
1194 T::get_record_name(spec),
1195 T::get_ttl(spec),
1196 record_op,
1197 )
1198 .await
1199 {
1200 Ok(()) => {
1201 info!(
1202 "Successfully added {} record {}.{} via {} primary instance(s)",
1203 T::record_type_name(),
1204 T::get_record_name(spec),
1205 rec_ctx.zone_ref.zone_name,
1206 rec_ctx.primary_refs.len()
1207 );
1208
1209 update_record_reconciled_timestamp(
1211 &client,
1212 &rec_ctx.zone_ref.namespace,
1213 &rec_ctx.zone_ref.name,
1214 &format!("{}Record", T::record_type_name()),
1215 &name,
1216 &namespace,
1217 )
1218 .await?;
1219
1220 update_record_status(
1223 &client,
1224 &record,
1225 "Ready",
1226 "True",
1227 "ReconcileSucceeded",
1228 &format!(
1229 "{} record added to zone {}",
1230 T::record_type_name(),
1231 rec_ctx.zone_ref.zone_name
1232 ),
1233 current_generation,
1234 Some(rec_ctx.current_hash),
1235 Some(chrono::Utc::now().to_rfc3339()),
1236 T::get_display_addresses(spec),
1237 Some(T::get_record_name(spec).to_string()),
1238 )
1239 .await?;
1240 }
1241 Err(e) => {
1242 warn!(
1243 "Failed to add {} record {}.{}: {}",
1244 T::record_type_name(),
1245 T::get_record_name(spec),
1246 rec_ctx.zone_ref.zone_name,
1247 e
1248 );
1249 update_record_status(
1250 &client,
1251 &record,
1252 "Ready",
1253 "False",
1254 "ReconcileFailed",
1255 &format!("Failed to add record to zone: {e}"),
1256 current_generation,
1257 None, None, None, None, )
1262 .await?;
1263 }
1264 }
1265
1266 Ok(())
1267}
1268
1269pub(crate) fn detect_renamed_record(
1284 status: Option<&crate::crd::RecordStatus>,
1285 current_name: &str,
1286) -> Option<String> {
1287 let published = status?.published_name.as_deref()?;
1288 if published == current_name {
1289 return None;
1290 }
1291 Some(published.to_string())
1292}
1293
1294pub async fn reconcile_a_record(
1307 ctx: std::sync::Arc<crate::context::Context>,
1308 record: ARecord,
1309) -> Result<()> {
1310 reconcile_record(ctx, record).await
1311}
1312
1313pub async fn reconcile_txt_record(
1323 ctx: std::sync::Arc<crate::context::Context>,
1324 record: TXTRecord,
1325) -> Result<()> {
1326 reconcile_record(ctx, record).await
1327}
1328
1329pub async fn reconcile_aaaa_record(
1338 ctx: std::sync::Arc<crate::context::Context>,
1339 record: AAAARecord,
1340) -> Result<()> {
1341 reconcile_record(ctx, record).await
1342}
1343
1344pub async fn reconcile_cname_record(
1354 ctx: std::sync::Arc<crate::context::Context>,
1355 record: CNAMERecord,
1356) -> Result<()> {
1357 reconcile_record(ctx, record).await
1358}
1359
1360pub async fn reconcile_mx_record(
1370 ctx: std::sync::Arc<crate::context::Context>,
1371 record: MXRecord,
1372) -> Result<()> {
1373 reconcile_record(ctx, record).await
1374}
1375
1376pub async fn reconcile_ns_record(
1386 ctx: std::sync::Arc<crate::context::Context>,
1387 record: NSRecord,
1388) -> Result<()> {
1389 reconcile_record(ctx, record).await
1390}
1391
1392pub async fn reconcile_srv_record(
1402 ctx: std::sync::Arc<crate::context::Context>,
1403 record: SRVRecord,
1404) -> Result<()> {
1405 reconcile_record(ctx, record).await
1406}
1407
1408pub async fn reconcile_caa_record(
1419 ctx: std::sync::Arc<crate::context::Context>,
1420 record: CAARecord,
1421) -> Result<()> {
1422 reconcile_record(ctx, record).await
1423}
1424
1425#[allow(clippy::too_many_lines)]
1455pub async fn delete_record<T>(
1456 client: &Client,
1457 record: &T,
1458 record_type: &str,
1459 record_type_hickory: hickory_proto::rr::RecordType,
1460 stores: &crate::context::Stores,
1461) -> Result<()>
1462where
1463 T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
1464 + ResourceExt
1465 + Clone
1466 + std::fmt::Debug
1467 + serde::Serialize
1468 + for<'de> serde::Deserialize<'de>,
1469{
1470 let namespace = record.namespace().unwrap_or_default();
1471 let name = record.name_any();
1472
1473 info!("Deleting {} record: {}/{}", record_type, namespace, name);
1474
1475 let record_json = serde_json::to_value(record).ok();
1477 let status = record_json.as_ref().and_then(|v| v.get("status").cloned());
1478
1479 let zone_ref = status
1480 .as_ref()
1481 .and_then(|s| s.get("zoneRef"))
1482 .cloned()
1483 .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z).ok());
1484
1485 let Some(zone_ref) = zone_ref else {
1487 info!(
1488 "{} record {}/{} has no zoneRef - was never added to DNS or already cleaned up",
1489 record_type, namespace, name
1490 );
1491 return Ok(());
1492 };
1493
1494 let dnszone = match get_zone_from_ref(client, &zone_ref).await {
1496 Ok(zone) => zone,
1497 Err(e) => {
1498 warn!(
1499 "DNSZone {}/{} not found for {} record {}/{}: {}. Allowing deletion anyway.",
1500 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1501 );
1502 return Ok(());
1503 }
1504 };
1505
1506 let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
1508 &dnszone,
1509 &stores.bind9_instances,
1510 ) {
1511 Ok(refs) => refs,
1512 Err(e) => {
1513 warn!(
1514 "DNSZone {}/{} has no instances for {} record {}/{}: {}. Allowing deletion anyway.",
1515 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1516 );
1517 return Ok(());
1518 }
1519 };
1520
1521 let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
1523 client,
1524 &instance_refs,
1525 )
1526 .await
1527 {
1528 Ok(refs) => refs,
1529 Err(e) => {
1530 warn!(
1531 "Failed to filter primary instances for {} record {}/{}: {}. Allowing deletion anyway.",
1532 record_type, namespace, name, e
1533 );
1534 return Ok(());
1535 }
1536 };
1537
1538 if primary_refs.is_empty() {
1539 warn!(
1540 "No primary instances found for {} record {}/{}. Allowing deletion anyway.",
1541 record_type, namespace, name
1542 );
1543 return Ok(());
1544 }
1545
1546 let record_name_str = status
1550 .as_ref()
1551 .and_then(|s| s.get("publishedName"))
1552 .and_then(|p| p.as_str())
1553 .map(ToString::to_string)
1554 .or_else(|| {
1555 record_json
1556 .as_ref()
1557 .and_then(|v| v.get("spec"))
1558 .and_then(|s| s.get("name"))
1559 .and_then(|n| n.as_str())
1560 .map(ToString::to_string)
1561 })
1562 .unwrap_or_else(|| name.clone());
1563
1564 delete_record_from_primaries(
1567 client,
1568 stores,
1569 &primary_refs,
1570 &zone_ref.zone_name,
1571 &record_name_str,
1572 record_type_hickory,
1573 false, )
1575 .await?;
1576
1577 info!(
1578 "Successfully deleted {} record {}/{} from {} primary instance(s)",
1579 record_type,
1580 namespace,
1581 name,
1582 primary_refs.len()
1583 );
1584
1585 Ok(())
1586}
1587
1588pub(crate) async fn delete_record_from_primaries(
1611 client: &Client,
1612 stores: &crate::context::Stores,
1613 primary_refs: &[crate::crd::InstanceReference],
1614 zone_name: &str,
1615 record_name: &str,
1616 record_type_hickory: hickory_proto::rr::RecordType,
1617 fail_on_error: bool,
1618) -> Result<()> {
1619 let instance_map: std::collections::HashMap<String, String> = primary_refs
1621 .iter()
1622 .map(|inst| (inst.name.clone(), inst.namespace.clone()))
1623 .collect();
1624
1625 let failures: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
1630 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1631
1632 let failure_policy = if fail_on_error {
1637 crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::Strict
1638 } else {
1639 crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::SkipUnavailable
1640 };
1641
1642 let (_first_endpoint, _total_endpoints) =
1643 crate::reconcilers::dnszone::helpers::for_each_instance_endpoint_with_policy(
1644 client,
1645 primary_refs,
1646 true, "dns-tcp", failure_policy,
1649 |pod_endpoint, instance_name, rndc_key| {
1650 let zone_name = zone_name.to_string();
1651 let record_name_str = record_name.to_string();
1652 let instance_namespace = instance_map
1653 .get(&instance_name)
1654 .expect("Instance should be in map")
1655 .clone();
1656 let failures = std::sync::Arc::clone(&failures);
1657
1658 let zone_manager =
1660 stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
1661
1662 async move {
1663 let key_data = rndc_key.expect("RNDC key should be loaded");
1664
1665 let delete_result = zone_manager
1666 .delete_record(
1667 &zone_name,
1668 &record_name_str,
1669 record_type_hickory,
1670 &pod_endpoint,
1671 &key_data,
1672 )
1673 .await;
1674
1675 match delete_result {
1676 Ok(()) => {
1677 info!(
1678 "Successfully deleted {} record {}.{} from endpoint {} (instance: {})",
1679 record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name
1680 );
1681 }
1682 Err(e) => {
1683 warn!(
1684 "Failed to delete {} record {}.{} from endpoint {} (instance: {}): {}",
1685 record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name, e
1686 );
1687 failures
1688 .lock()
1689 .expect("delete failures mutex should not be poisoned")
1690 .push(format!(
1691 "endpoint {pod_endpoint} (instance: {instance_name}): {e}"
1692 ));
1693 }
1694 }
1695
1696 Ok(())
1697 }
1698 },
1699 )
1700 .await?;
1701
1702 let failures = failures
1703 .lock()
1704 .expect("delete failures mutex should not be poisoned");
1705
1706 if fail_on_error && !failures.is_empty() {
1707 return Err(anyhow::anyhow!(
1708 "Failed to delete {} record {}.{} from {} endpoint(s): {}",
1709 record_type_hickory,
1710 record_name,
1711 zone_name,
1712 failures.len(),
1713 failures.join("; ")
1714 ));
1715 }
1716
1717 if !failures.is_empty() {
1718 warn!(
1719 "Failed to delete {} record {}.{} from {} endpoint(s); continuing anyway (best-effort)",
1720 record_type_hickory,
1721 record_name,
1722 zone_name,
1723 failures.len()
1724 );
1725 }
1726
1727 Ok(())
1728}
1729
1730#[must_use]
1736pub(crate) fn build_records_timestamp_patch(
1737 records: &[crate::crd::RecordReferenceWithTimestamp],
1738) -> serde_json::Value {
1739 json!({
1740 "status": {
1741 "records": records
1742 }
1743 })
1744}
1745
1746pub async fn update_record_reconciled_timestamp(
1766 client: &Client,
1767 zone_namespace: &str,
1768 zone_name: &str,
1769 record_kind: &str,
1770 record_name: &str,
1771 record_namespace: &str,
1772) -> Result<()> {
1773 let api: Api<DNSZone> = Api::namespaced(client.clone(), zone_namespace);
1774
1775 let mut zone = api.get(zone_name).await?;
1777
1778 let mut found = false;
1780 if let Some(status) = &mut zone.status {
1781 for record_ref in &mut status.records {
1782 if record_ref.kind == record_kind
1783 && record_ref.name == record_name
1784 && record_ref.namespace == record_namespace
1785 {
1786 record_ref.last_reconciled_at = Some(Time(k8s_openapi::jiff::Timestamp::now()));
1787 found = true;
1788 break;
1789 }
1790 }
1791 }
1792
1793 if !found {
1794 warn!(
1795 "Record {} {}/{} not found in DNSZone {}/{} status.records[] - cannot update timestamp",
1796 record_kind, record_namespace, record_name, zone_namespace, zone_name
1797 );
1798 return Ok(());
1799 }
1800
1801 let status_patch = zone
1804 .status
1805 .as_ref()
1806 .map(|s| build_records_timestamp_patch(&s.records))
1807 .unwrap_or_else(|| build_records_timestamp_patch(&[]));
1808
1809 api.patch_status(
1810 zone_name,
1811 &PatchParams::default(),
1812 &Patch::Merge(status_patch),
1813 )
1814 .await?;
1815
1816 info!(
1817 "Updated lastReconciledAt for {} record {}/{} in zone {}/{}",
1818 record_kind, record_namespace, record_name, zone_namespace, zone_name
1819 );
1820
1821 Ok(())
1822}