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, PTRRecord, SRVRecord,
20 TXTRecord,
21};
22use anyhow::{Context, Result};
23use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
24
25use kube::{
26 api::{Patch, PatchParams},
27 client::Client,
28 Api, Resource, ResourceExt,
29};
30use serde_json::json;
31use tracing::{debug, info, warn};
32
33async fn get_zone_from_ref(
51 client: &Client,
52 zone_ref: &crate::crd::ZoneReference,
53) -> Result<DNSZone> {
54 let dns_zones_api: Api<DNSZone> = Api::namespaced(client.clone(), &zone_ref.namespace);
55
56 dns_zones_api.get(&zone_ref.name).await.context(format!(
57 "Failed to get DNSZone {}/{}",
58 zone_ref.namespace, zone_ref.name
59 ))
60}
61
62struct RecordReconciliationContext {
66 zone_ref: crate::crd::ZoneReference,
68 primary_refs: Vec<crate::crd::InstanceReference>,
70 current_hash: String,
72}
73
74#[allow(clippy::too_many_lines)]
100async fn prepare_record_reconciliation<T, S>(
101 client: &Client,
102 record: &T,
103 record_type: &str,
104 spec_hashable: &S,
105 bind9_instances_store: &crate::context::MultiStore<crate::crd::Bind9Instance>,
106) -> Result<Option<RecordReconciliationContext>>
107where
108 T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
109 + ResourceExt
110 + Clone
111 + std::fmt::Debug
112 + serde::Serialize
113 + for<'de> serde::Deserialize<'de>,
114 S: serde::Serialize,
115{
116 let namespace = record.namespace().unwrap_or_default();
117 let name = record.name_any();
118
119 let record_json = serde_json::to_value(record)?;
121 let status = record_json.get("status");
122
123 let zone_ref = status
124 .and_then(|s| s.get("zoneRef"))
125 .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z.clone()).ok());
126
127 let observed_generation = status
128 .and_then(|s| s.get("observedGeneration"))
129 .and_then(serde_json::Value::as_i64);
130
131 let current_generation = record.meta().generation;
132
133 let Some(zone_ref) = zone_ref else {
135 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
137 debug!("Spec unchanged and no zoneRef, skipping reconciliation");
138 return Ok(None);
139 }
140
141 info!(
142 "{} record {}/{} not selected by any DNSZone (no zoneRef in status)",
143 record_type, namespace, name
144 );
145 update_record_status(
146 client,
147 record,
148 "Ready",
149 "False",
150 "NotSelected",
151 "Record not selected by any DNSZone recordsFrom selector",
152 current_generation,
153 None, None, None, None, )
158 .await?;
159 return Ok(None);
160 };
161
162 let current_hash = crate::ddns::calculate_record_hash(spec_hashable);
164
165 let dnszone = match get_zone_from_ref(client, &zone_ref).await {
167 Ok(zone) => zone,
168 Err(e) => {
169 warn!(
170 "Failed to get DNSZone {}/{} for {} record {}/{}: {}",
171 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
172 );
173 update_record_status(
174 client,
175 record,
176 "Ready",
177 "False",
178 "ZoneNotFound",
179 &format!(
180 "Referenced DNSZone {}/{} not found: {e}",
181 zone_ref.namespace, zone_ref.name
182 ),
183 current_generation,
184 None, None, None, None, )
189 .await?;
190 return Ok(None);
191 }
192 };
193
194 let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
196 &dnszone,
197 bind9_instances_store,
198 ) {
199 Ok(refs) => refs,
200 Err(e) => {
201 warn!(
202 "DNSZone {}/{} has no instances assigned for {} record {}/{}: {}",
203 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
204 );
205 update_record_status(
206 client,
207 record,
208 "Ready",
209 "False",
210 "ZoneNotConfigured",
211 &format!("DNSZone has no instances: {e}"),
212 current_generation,
213 None, None, None, None, )
218 .await?;
219 return Ok(None);
220 }
221 };
222
223 let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
225 client,
226 &instance_refs,
227 )
228 .await
229 {
230 Ok(refs) => refs,
231 Err(e) => {
232 warn!(
233 "Failed to filter primary instances for {} record {}/{}: {}",
234 record_type, namespace, name, e
235 );
236 update_record_status(
237 client,
238 record,
239 "Ready",
240 "False",
241 "InstanceFilterError",
242 &format!("Failed to filter primary instances: {e}"),
243 current_generation,
244 None, None, None, None, )
249 .await?;
250 return Ok(None);
251 }
252 };
253
254 if primary_refs.is_empty() {
255 warn!(
256 "DNSZone {}/{} has no primary instances for {} record {}/{}",
257 zone_ref.namespace, zone_ref.name, record_type, namespace, name
258 );
259 update_record_status(
260 client,
261 record,
262 "Ready",
263 "False",
264 "NoPrimaryInstances",
265 "DNSZone has no primary instances configured",
266 current_generation,
267 None, None, None, None, )
272 .await?;
273 return Ok(None);
274 }
275
276 Ok(Some(RecordReconciliationContext {
277 zone_ref,
278 primary_refs,
279 current_hash,
280 }))
281}
282
283trait RecordOperation: Clone + Send + Sync {
314 fn record_type_name(&self) -> &'static str;
316
317 fn add_to_bind9(
332 &self,
333 zone_manager: &crate::bind9::Bind9Manager,
334 zone_name: &str,
335 record_name: &str,
336 ttl: Option<i32>,
337 server: &str,
338 key_data: &crate::bind9::RndcKeyData,
339 ) -> impl std::future::Future<Output = Result<()>> + Send;
340}
341
342trait ReconcilableRecord:
380 Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
381 + ResourceExt
382 + Clone
383 + std::fmt::Debug
384 + serde::Serialize
385 + for<'de> serde::Deserialize<'de>
386 + Send
387 + Sync
388{
389 type Spec: serde::Serialize + Clone;
391
392 type Operation: RecordOperation;
394
395 fn get_spec(&self) -> &Self::Spec;
397
398 fn get_status(&self) -> Option<&crate::crd::RecordStatus>;
400
401 fn record_type_name() -> &'static str;
403
404 fn record_type_hickory() -> hickory_proto::rr::RecordType;
406
407 fn create_operation(spec: &Self::Spec) -> Self::Operation;
409
410 fn get_record_name(spec: &Self::Spec) -> &str;
412
413 fn get_ttl(spec: &Self::Spec) -> Option<i32>;
415
416 fn get_display_addresses(_spec: &Self::Spec) -> Option<String> {
420 None
421 }
422}
423
424async fn add_record_to_instances_generic<R>(
448 client: &Client,
449 stores: &crate::context::Stores,
450 instance_refs: &[crate::crd::InstanceReference],
451 zone_name: &str,
452 record_name: &str,
453 ttl: Option<i32>,
454 record_op: R,
455) -> Result<()>
456where
457 R: RecordOperation,
458{
459 use crate::reconcilers::dnszone::helpers::for_each_instance_endpoint;
460
461 let instance_map: std::collections::HashMap<String, String> = instance_refs
463 .iter()
464 .map(|inst| (inst.name.clone(), inst.namespace.clone()))
465 .collect();
466
467 let (_first, _total) = for_each_instance_endpoint(
468 client,
469 instance_refs,
470 true, "dns-tcp", |pod_endpoint, instance_name, rndc_key| {
473 let zone_name = zone_name.to_string();
474 let record_name = record_name.to_string();
475
476 let instance_namespace = instance_map
478 .get(&instance_name)
479 .expect("Instance should be in map")
480 .clone();
481
482 let zone_manager =
484 stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
485
486 let record_op_clone = record_op.clone();
488
489 async move {
490 let key_data = rndc_key.expect("RNDC key should be loaded");
491
492 record_op_clone
493 .add_to_bind9(&zone_manager, &zone_name, &record_name, ttl, &pod_endpoint, &key_data)
494 .await
495 .context(format!(
496 "Failed to add {} record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})",
497 record_op_clone.record_type_name()
498 ))?;
499
500 Ok(())
501 }
502 },
503 )
504 .await?;
505
506 Ok(())
507}
508
509#[derive(Clone)]
513struct ARecordOp {
514 ipv4_addresses: Vec<String>,
515}
516
517impl RecordOperation for ARecordOp {
518 fn record_type_name(&self) -> &'static str {
519 "A"
520 }
521
522 async fn add_to_bind9(
523 &self,
524 zone_manager: &crate::bind9::Bind9Manager,
525 zone_name: &str,
526 record_name: &str,
527 ttl: Option<i32>,
528 server: &str,
529 key_data: &crate::bind9::RndcKeyData,
530 ) -> Result<()> {
531 zone_manager
532 .add_a_record(
533 zone_name,
534 record_name,
535 &self.ipv4_addresses,
536 ttl,
537 server,
538 key_data,
539 )
540 .await
541 }
542}
543
544impl ReconcilableRecord for ARecord {
546 type Spec = crate::crd::ARecordSpec;
547 type Operation = ARecordOp;
548
549 fn get_spec(&self) -> &Self::Spec {
550 &self.spec
551 }
552
553 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
554 self.status.as_ref()
555 }
556
557 fn record_type_name() -> &'static str {
558 "A"
559 }
560
561 fn record_type_hickory() -> hickory_proto::rr::RecordType {
562 hickory_proto::rr::RecordType::A
563 }
564
565 fn create_operation(spec: &Self::Spec) -> Self::Operation {
566 ARecordOp {
567 ipv4_addresses: spec.ipv4_addresses.clone(),
568 }
569 }
570
571 fn get_record_name(spec: &Self::Spec) -> &str {
572 &spec.name
573 }
574
575 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
576 spec.ttl
577 }
578
579 fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
580 Some(spec.ipv4_addresses.join(","))
581 }
582}
583
584#[derive(Clone)]
586struct AAAARecordOp {
587 ipv6_addresses: Vec<String>,
588}
589
590impl RecordOperation for AAAARecordOp {
591 fn record_type_name(&self) -> &'static str {
592 "AAAA"
593 }
594
595 async fn add_to_bind9(
596 &self,
597 zone_manager: &crate::bind9::Bind9Manager,
598 zone_name: &str,
599 record_name: &str,
600 ttl: Option<i32>,
601 server: &str,
602 key_data: &crate::bind9::RndcKeyData,
603 ) -> Result<()> {
604 zone_manager
605 .add_aaaa_record(
606 zone_name,
607 record_name,
608 &self.ipv6_addresses,
609 ttl,
610 server,
611 key_data,
612 )
613 .await
614 }
615}
616
617impl ReconcilableRecord for AAAARecord {
619 type Spec = crate::crd::AAAARecordSpec;
620 type Operation = AAAARecordOp;
621
622 fn get_spec(&self) -> &Self::Spec {
623 &self.spec
624 }
625
626 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
627 self.status.as_ref()
628 }
629
630 fn record_type_name() -> &'static str {
631 "AAAA"
632 }
633
634 fn record_type_hickory() -> hickory_proto::rr::RecordType {
635 hickory_proto::rr::RecordType::AAAA
636 }
637
638 fn create_operation(spec: &Self::Spec) -> Self::Operation {
639 AAAARecordOp {
640 ipv6_addresses: spec.ipv6_addresses.clone(),
641 }
642 }
643
644 fn get_record_name(spec: &Self::Spec) -> &str {
645 &spec.name
646 }
647
648 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
649 spec.ttl
650 }
651
652 fn get_display_addresses(spec: &Self::Spec) -> Option<String> {
653 Some(spec.ipv6_addresses.join(","))
654 }
655}
656
657#[derive(Clone)]
659struct CNAMERecordOp {
660 target: String,
661}
662
663impl RecordOperation for CNAMERecordOp {
664 fn record_type_name(&self) -> &'static str {
665 "CNAME"
666 }
667
668 async fn add_to_bind9(
669 &self,
670 zone_manager: &crate::bind9::Bind9Manager,
671 zone_name: &str,
672 record_name: &str,
673 ttl: Option<i32>,
674 server: &str,
675 key_data: &crate::bind9::RndcKeyData,
676 ) -> Result<()> {
677 zone_manager
678 .add_cname_record(zone_name, record_name, &self.target, ttl, server, key_data)
679 .await
680 }
681}
682
683impl ReconcilableRecord for CNAMERecord {
685 type Spec = crate::crd::CNAMERecordSpec;
686 type Operation = CNAMERecordOp;
687
688 fn get_spec(&self) -> &Self::Spec {
689 &self.spec
690 }
691
692 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
693 self.status.as_ref()
694 }
695
696 fn record_type_name() -> &'static str {
697 "CNAME"
698 }
699
700 fn record_type_hickory() -> hickory_proto::rr::RecordType {
701 hickory_proto::rr::RecordType::CNAME
702 }
703
704 fn create_operation(spec: &Self::Spec) -> Self::Operation {
705 CNAMERecordOp {
706 target: spec.target.clone(),
707 }
708 }
709
710 fn get_record_name(spec: &Self::Spec) -> &str {
711 &spec.name
712 }
713
714 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
715 spec.ttl
716 }
717}
718
719#[derive(Clone)]
721struct TXTRecordOp {
722 texts: Vec<String>,
723}
724
725impl RecordOperation for TXTRecordOp {
726 fn record_type_name(&self) -> &'static str {
727 "TXT"
728 }
729
730 async fn add_to_bind9(
731 &self,
732 zone_manager: &crate::bind9::Bind9Manager,
733 zone_name: &str,
734 record_name: &str,
735 ttl: Option<i32>,
736 server: &str,
737 key_data: &crate::bind9::RndcKeyData,
738 ) -> Result<()> {
739 zone_manager
740 .add_txt_record(zone_name, record_name, &self.texts, ttl, server, key_data)
741 .await
742 }
743}
744
745impl ReconcilableRecord for TXTRecord {
747 type Spec = crate::crd::TXTRecordSpec;
748 type Operation = TXTRecordOp;
749
750 fn get_spec(&self) -> &Self::Spec {
751 &self.spec
752 }
753
754 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
755 self.status.as_ref()
756 }
757
758 fn record_type_name() -> &'static str {
759 "TXT"
760 }
761
762 fn record_type_hickory() -> hickory_proto::rr::RecordType {
763 hickory_proto::rr::RecordType::TXT
764 }
765
766 fn create_operation(spec: &Self::Spec) -> Self::Operation {
767 TXTRecordOp {
768 texts: spec.text.clone(),
769 }
770 }
771
772 fn get_record_name(spec: &Self::Spec) -> &str {
773 &spec.name
774 }
775
776 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
777 spec.ttl
778 }
779}
780
781#[derive(Clone)]
783struct MXRecordOp {
784 priority: i32,
785 mail_server: String,
786}
787
788impl RecordOperation for MXRecordOp {
789 fn record_type_name(&self) -> &'static str {
790 "MX"
791 }
792
793 async fn add_to_bind9(
794 &self,
795 zone_manager: &crate::bind9::Bind9Manager,
796 zone_name: &str,
797 record_name: &str,
798 ttl: Option<i32>,
799 server: &str,
800 key_data: &crate::bind9::RndcKeyData,
801 ) -> Result<()> {
802 zone_manager
803 .add_mx_record(
804 zone_name,
805 record_name,
806 self.priority,
807 &self.mail_server,
808 ttl,
809 server,
810 key_data,
811 )
812 .await
813 }
814}
815
816impl ReconcilableRecord for MXRecord {
818 type Spec = crate::crd::MXRecordSpec;
819 type Operation = MXRecordOp;
820
821 fn get_spec(&self) -> &Self::Spec {
822 &self.spec
823 }
824
825 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
826 self.status.as_ref()
827 }
828
829 fn record_type_name() -> &'static str {
830 "MX"
831 }
832
833 fn record_type_hickory() -> hickory_proto::rr::RecordType {
834 hickory_proto::rr::RecordType::MX
835 }
836
837 fn create_operation(spec: &Self::Spec) -> Self::Operation {
838 MXRecordOp {
839 priority: spec.priority,
840 mail_server: spec.mail_server.clone(),
841 }
842 }
843
844 fn get_record_name(spec: &Self::Spec) -> &str {
845 &spec.name
846 }
847
848 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
849 spec.ttl
850 }
851}
852
853#[derive(Clone)]
855struct NSRecordOp {
856 nameserver: String,
857}
858
859impl RecordOperation for NSRecordOp {
860 fn record_type_name(&self) -> &'static str {
861 "NS"
862 }
863
864 async fn add_to_bind9(
865 &self,
866 zone_manager: &crate::bind9::Bind9Manager,
867 zone_name: &str,
868 record_name: &str,
869 ttl: Option<i32>,
870 server: &str,
871 key_data: &crate::bind9::RndcKeyData,
872 ) -> Result<()> {
873 zone_manager
874 .add_ns_record(
875 zone_name,
876 record_name,
877 &self.nameserver,
878 ttl,
879 server,
880 key_data,
881 )
882 .await
883 }
884}
885
886impl ReconcilableRecord for NSRecord {
888 type Spec = crate::crd::NSRecordSpec;
889 type Operation = NSRecordOp;
890
891 fn get_spec(&self) -> &Self::Spec {
892 &self.spec
893 }
894
895 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
896 self.status.as_ref()
897 }
898
899 fn record_type_name() -> &'static str {
900 "NS"
901 }
902
903 fn record_type_hickory() -> hickory_proto::rr::RecordType {
904 hickory_proto::rr::RecordType::NS
905 }
906
907 fn create_operation(spec: &Self::Spec) -> Self::Operation {
908 NSRecordOp {
909 nameserver: spec.nameserver.clone(),
910 }
911 }
912
913 fn get_record_name(spec: &Self::Spec) -> &str {
914 &spec.name
915 }
916
917 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
918 spec.ttl
919 }
920}
921
922#[derive(Clone)]
924struct SRVRecordOp {
925 priority: i32,
926 weight: i32,
927 port: i32,
928 target: String,
929}
930
931impl RecordOperation for SRVRecordOp {
932 fn record_type_name(&self) -> &'static str {
933 "SRV"
934 }
935
936 async fn add_to_bind9(
937 &self,
938 zone_manager: &crate::bind9::Bind9Manager,
939 zone_name: &str,
940 record_name: &str,
941 ttl: Option<i32>,
942 server: &str,
943 key_data: &crate::bind9::RndcKeyData,
944 ) -> Result<()> {
945 let srv_data = crate::bind9::SRVRecordData {
946 priority: self.priority,
947 weight: self.weight,
948 port: self.port,
949 target: self.target.clone(),
950 ttl,
951 };
952 zone_manager
953 .add_srv_record(zone_name, record_name, &srv_data, server, key_data)
954 .await
955 }
956}
957
958impl ReconcilableRecord for SRVRecord {
960 type Spec = crate::crd::SRVRecordSpec;
961 type Operation = SRVRecordOp;
962
963 fn get_spec(&self) -> &Self::Spec {
964 &self.spec
965 }
966
967 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
968 self.status.as_ref()
969 }
970
971 fn record_type_name() -> &'static str {
972 "SRV"
973 }
974
975 fn record_type_hickory() -> hickory_proto::rr::RecordType {
976 hickory_proto::rr::RecordType::SRV
977 }
978
979 fn create_operation(spec: &Self::Spec) -> Self::Operation {
980 SRVRecordOp {
981 priority: spec.priority,
982 weight: spec.weight,
983 port: spec.port,
984 target: spec.target.clone(),
985 }
986 }
987
988 fn get_record_name(spec: &Self::Spec) -> &str {
989 &spec.name
990 }
991
992 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
993 spec.ttl
994 }
995}
996
997#[derive(Clone)]
999struct CAARecordOp {
1000 flags: i32,
1001 tag: String,
1002 value: String,
1003}
1004
1005impl RecordOperation for CAARecordOp {
1006 fn record_type_name(&self) -> &'static str {
1007 "CAA"
1008 }
1009
1010 async fn add_to_bind9(
1011 &self,
1012 zone_manager: &crate::bind9::Bind9Manager,
1013 zone_name: &str,
1014 record_name: &str,
1015 ttl: Option<i32>,
1016 server: &str,
1017 key_data: &crate::bind9::RndcKeyData,
1018 ) -> Result<()> {
1019 zone_manager
1020 .add_caa_record(
1021 zone_name,
1022 record_name,
1023 self.flags,
1024 &self.tag,
1025 &self.value,
1026 ttl,
1027 server,
1028 key_data,
1029 )
1030 .await
1031 }
1032}
1033
1034impl ReconcilableRecord for CAARecord {
1036 type Spec = crate::crd::CAARecordSpec;
1037 type Operation = CAARecordOp;
1038
1039 fn get_spec(&self) -> &Self::Spec {
1040 &self.spec
1041 }
1042
1043 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
1044 self.status.as_ref()
1045 }
1046
1047 fn record_type_name() -> &'static str {
1048 "CAA"
1049 }
1050
1051 fn record_type_hickory() -> hickory_proto::rr::RecordType {
1052 hickory_proto::rr::RecordType::CAA
1053 }
1054
1055 fn create_operation(spec: &Self::Spec) -> Self::Operation {
1056 CAARecordOp {
1057 flags: spec.flags,
1058 tag: spec.tag.clone(),
1059 value: spec.value.clone(),
1060 }
1061 }
1062
1063 fn get_record_name(spec: &Self::Spec) -> &str {
1064 &spec.name
1065 }
1066
1067 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
1068 spec.ttl
1069 }
1070}
1071
1072#[derive(Clone)]
1074struct PTRRecordOp {
1075 target: String,
1076}
1077
1078impl RecordOperation for PTRRecordOp {
1079 fn record_type_name(&self) -> &'static str {
1080 "PTR"
1081 }
1082
1083 async fn add_to_bind9(
1084 &self,
1085 zone_manager: &crate::bind9::Bind9Manager,
1086 zone_name: &str,
1087 record_name: &str,
1088 ttl: Option<i32>,
1089 server: &str,
1090 key_data: &crate::bind9::RndcKeyData,
1091 ) -> Result<()> {
1092 let ptr_data = crate::bind9::PTRRecordData {
1093 target: self.target.clone(),
1094 ttl,
1095 };
1096 zone_manager
1097 .add_ptr_record(zone_name, record_name, &ptr_data, server, key_data)
1098 .await
1099 }
1100}
1101
1102impl ReconcilableRecord for PTRRecord {
1104 type Spec = crate::crd::PTRRecordSpec;
1105 type Operation = PTRRecordOp;
1106
1107 fn get_spec(&self) -> &Self::Spec {
1108 &self.spec
1109 }
1110
1111 fn get_status(&self) -> Option<&crate::crd::RecordStatus> {
1112 self.status.as_ref()
1113 }
1114
1115 fn record_type_name() -> &'static str {
1116 "PTR"
1117 }
1118
1119 fn record_type_hickory() -> hickory_proto::rr::RecordType {
1120 hickory_proto::rr::RecordType::PTR
1121 }
1122
1123 fn create_operation(spec: &Self::Spec) -> Self::Operation {
1124 PTRRecordOp {
1125 target: spec.target.clone(),
1126 }
1127 }
1128
1129 fn get_record_name(spec: &Self::Spec) -> &str {
1130 &spec.name
1131 }
1132
1133 fn get_ttl(spec: &Self::Spec) -> Option<i32> {
1134 spec.ttl
1135 }
1136}
1137
1138async fn reconcile_record<T>(ctx: std::sync::Arc<crate::context::Context>, record: T) -> Result<()>
1171where
1172 T: ReconcilableRecord,
1173{
1174 let client = ctx.client.clone();
1175 let bind9_instances_store = &ctx.stores.bind9_instances;
1176 let namespace = record.namespace().unwrap_or_default();
1177 let name = record.name_any();
1178
1179 info!(
1180 "Reconciling {}Record: {}/{}",
1181 T::record_type_name(),
1182 namespace,
1183 name
1184 );
1185
1186 let spec = record.get_spec();
1187 let current_generation = record.meta().generation;
1188
1189 let Some(rec_ctx) = prepare_record_reconciliation(
1191 &client,
1192 &record,
1193 T::record_type_name(),
1194 spec,
1195 bind9_instances_store,
1196 )
1197 .await?
1198 else {
1199 return Ok(()); };
1201
1202 if let Some(old_name) = detect_renamed_record(record.get_status(), T::get_record_name(spec)) {
1206 info!(
1207 "{} record {}/{} renamed from '{}' to '{}' - deleting old name from zone {}",
1208 T::record_type_name(),
1209 namespace,
1210 name,
1211 old_name,
1212 T::get_record_name(spec),
1213 rec_ctx.zone_ref.zone_name
1214 );
1215
1216 if let Err(e) = delete_record_from_primaries(
1217 &client,
1218 &ctx.stores,
1219 &rec_ctx.primary_refs,
1220 &rec_ctx.zone_ref.zone_name,
1221 &old_name,
1222 T::record_type_hickory(),
1223 true, )
1225 .await
1226 {
1227 warn!(
1228 "Failed to delete renamed {} record '{}' from zone {}: {}",
1229 T::record_type_name(),
1230 old_name,
1231 rec_ctx.zone_ref.zone_name,
1232 e
1233 );
1234 update_record_status(
1235 &client,
1236 &record,
1237 "Ready",
1238 "False",
1239 "ReconcileFailed",
1240 &format!("Failed to delete renamed record '{old_name}' from zone: {e}"),
1241 current_generation,
1242 None, None, None, None, )
1247 .await?;
1248 return Ok(());
1249 }
1250 }
1251
1252 let record_op = T::create_operation(spec);
1254
1255 match add_record_to_instances_generic(
1257 &client,
1258 &ctx.stores,
1259 &rec_ctx.primary_refs,
1260 &rec_ctx.zone_ref.zone_name,
1261 T::get_record_name(spec),
1262 T::get_ttl(spec),
1263 record_op,
1264 )
1265 .await
1266 {
1267 Ok(()) => {
1268 info!(
1269 "Successfully added {} record {}.{} via {} primary instance(s)",
1270 T::record_type_name(),
1271 T::get_record_name(spec),
1272 rec_ctx.zone_ref.zone_name,
1273 rec_ctx.primary_refs.len()
1274 );
1275
1276 update_record_reconciled_timestamp(
1278 &client,
1279 &rec_ctx.zone_ref.namespace,
1280 &rec_ctx.zone_ref.name,
1281 &format!("{}Record", T::record_type_name()),
1282 &name,
1283 &namespace,
1284 )
1285 .await?;
1286
1287 update_record_status(
1290 &client,
1291 &record,
1292 "Ready",
1293 "True",
1294 "ReconcileSucceeded",
1295 &format!(
1296 "{} record added to zone {}",
1297 T::record_type_name(),
1298 rec_ctx.zone_ref.zone_name
1299 ),
1300 current_generation,
1301 Some(rec_ctx.current_hash),
1302 Some(chrono::Utc::now().to_rfc3339()),
1303 T::get_display_addresses(spec),
1304 Some(T::get_record_name(spec).to_string()),
1305 )
1306 .await?;
1307 }
1308 Err(e) => {
1309 warn!(
1310 "Failed to add {} record {}.{}: {}",
1311 T::record_type_name(),
1312 T::get_record_name(spec),
1313 rec_ctx.zone_ref.zone_name,
1314 e
1315 );
1316 update_record_status(
1317 &client,
1318 &record,
1319 "Ready",
1320 "False",
1321 "ReconcileFailed",
1322 &format!("Failed to add record to zone: {e}"),
1323 current_generation,
1324 None, None, None, None, )
1329 .await?;
1330 }
1331 }
1332
1333 Ok(())
1334}
1335
1336pub(crate) fn detect_renamed_record(
1351 status: Option<&crate::crd::RecordStatus>,
1352 current_name: &str,
1353) -> Option<String> {
1354 let published = status?.published_name.as_deref()?;
1355 if published == current_name {
1356 return None;
1357 }
1358 Some(published.to_string())
1359}
1360
1361pub async fn reconcile_a_record(
1374 ctx: std::sync::Arc<crate::context::Context>,
1375 record: ARecord,
1376) -> Result<()> {
1377 reconcile_record(ctx, record).await
1378}
1379
1380pub async fn reconcile_txt_record(
1390 ctx: std::sync::Arc<crate::context::Context>,
1391 record: TXTRecord,
1392) -> Result<()> {
1393 reconcile_record(ctx, record).await
1394}
1395
1396pub async fn reconcile_aaaa_record(
1405 ctx: std::sync::Arc<crate::context::Context>,
1406 record: AAAARecord,
1407) -> Result<()> {
1408 reconcile_record(ctx, record).await
1409}
1410
1411pub async fn reconcile_cname_record(
1421 ctx: std::sync::Arc<crate::context::Context>,
1422 record: CNAMERecord,
1423) -> Result<()> {
1424 reconcile_record(ctx, record).await
1425}
1426
1427pub async fn reconcile_mx_record(
1437 ctx: std::sync::Arc<crate::context::Context>,
1438 record: MXRecord,
1439) -> Result<()> {
1440 reconcile_record(ctx, record).await
1441}
1442
1443pub async fn reconcile_ns_record(
1453 ctx: std::sync::Arc<crate::context::Context>,
1454 record: NSRecord,
1455) -> Result<()> {
1456 reconcile_record(ctx, record).await
1457}
1458
1459pub async fn reconcile_srv_record(
1469 ctx: std::sync::Arc<crate::context::Context>,
1470 record: SRVRecord,
1471) -> Result<()> {
1472 reconcile_record(ctx, record).await
1473}
1474
1475pub async fn reconcile_caa_record(
1486 ctx: std::sync::Arc<crate::context::Context>,
1487 record: CAARecord,
1488) -> Result<()> {
1489 reconcile_record(ctx, record).await
1490}
1491
1492pub async fn reconcile_ptr_record(
1502 ctx: std::sync::Arc<crate::context::Context>,
1503 record: PTRRecord,
1504) -> Result<()> {
1505 reconcile_record(ctx, record).await
1506}
1507
1508#[allow(clippy::too_many_lines)]
1538pub async fn delete_record<T>(
1539 client: &Client,
1540 record: &T,
1541 record_type: &str,
1542 record_type_hickory: hickory_proto::rr::RecordType,
1543 stores: &crate::context::Stores,
1544) -> Result<()>
1545where
1546 T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
1547 + ResourceExt
1548 + Clone
1549 + std::fmt::Debug
1550 + serde::Serialize
1551 + for<'de> serde::Deserialize<'de>,
1552{
1553 let namespace = record.namespace().unwrap_or_default();
1554 let name = record.name_any();
1555
1556 info!("Deleting {} record: {}/{}", record_type, namespace, name);
1557
1558 let record_json = serde_json::to_value(record).ok();
1560 let status = record_json.as_ref().and_then(|v| v.get("status").cloned());
1561
1562 let zone_ref = status
1563 .as_ref()
1564 .and_then(|s| s.get("zoneRef"))
1565 .cloned()
1566 .and_then(|z| serde_json::from_value::<crate::crd::ZoneReference>(z).ok());
1567
1568 let Some(zone_ref) = zone_ref else {
1570 info!(
1571 "{} record {}/{} has no zoneRef - was never added to DNS or already cleaned up",
1572 record_type, namespace, name
1573 );
1574 return Ok(());
1575 };
1576
1577 let dnszone = match get_zone_from_ref(client, &zone_ref).await {
1579 Ok(zone) => zone,
1580 Err(e) => {
1581 warn!(
1582 "DNSZone {}/{} not found for {} record {}/{}: {}. Allowing deletion anyway.",
1583 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1584 );
1585 return Ok(());
1586 }
1587 };
1588
1589 let instance_refs = match crate::reconcilers::dnszone::validation::get_instances_from_zone(
1591 &dnszone,
1592 &stores.bind9_instances,
1593 ) {
1594 Ok(refs) => refs,
1595 Err(e) => {
1596 warn!(
1597 "DNSZone {}/{} has no instances for {} record {}/{}: {}. Allowing deletion anyway.",
1598 zone_ref.namespace, zone_ref.name, record_type, namespace, name, e
1599 );
1600 return Ok(());
1601 }
1602 };
1603
1604 let primary_refs = match crate::reconcilers::dnszone::primary::filter_primary_instances(
1606 client,
1607 &instance_refs,
1608 )
1609 .await
1610 {
1611 Ok(refs) => refs,
1612 Err(e) => {
1613 warn!(
1614 "Failed to filter primary instances for {} record {}/{}: {}. Allowing deletion anyway.",
1615 record_type, namespace, name, e
1616 );
1617 return Ok(());
1618 }
1619 };
1620
1621 if primary_refs.is_empty() {
1622 warn!(
1623 "No primary instances found for {} record {}/{}. Allowing deletion anyway.",
1624 record_type, namespace, name
1625 );
1626 return Ok(());
1627 }
1628
1629 let record_name_str = status
1633 .as_ref()
1634 .and_then(|s| s.get("publishedName"))
1635 .and_then(|p| p.as_str())
1636 .map(ToString::to_string)
1637 .or_else(|| {
1638 record_json
1639 .as_ref()
1640 .and_then(|v| v.get("spec"))
1641 .and_then(|s| s.get("name"))
1642 .and_then(|n| n.as_str())
1643 .map(ToString::to_string)
1644 })
1645 .unwrap_or_else(|| name.clone());
1646
1647 delete_record_from_primaries(
1650 client,
1651 stores,
1652 &primary_refs,
1653 &zone_ref.zone_name,
1654 &record_name_str,
1655 record_type_hickory,
1656 false, )
1658 .await?;
1659
1660 info!(
1661 "Successfully deleted {} record {}/{} from {} primary instance(s)",
1662 record_type,
1663 namespace,
1664 name,
1665 primary_refs.len()
1666 );
1667
1668 Ok(())
1669}
1670
1671pub(crate) async fn delete_record_from_primaries(
1694 client: &Client,
1695 stores: &crate::context::Stores,
1696 primary_refs: &[crate::crd::InstanceReference],
1697 zone_name: &str,
1698 record_name: &str,
1699 record_type_hickory: hickory_proto::rr::RecordType,
1700 fail_on_error: bool,
1701) -> Result<()> {
1702 let instance_map: std::collections::HashMap<String, String> = primary_refs
1704 .iter()
1705 .map(|inst| (inst.name.clone(), inst.namespace.clone()))
1706 .collect();
1707
1708 let failures: std::sync::Arc<std::sync::Mutex<Vec<String>>> =
1713 std::sync::Arc::new(std::sync::Mutex::new(Vec::new()));
1714
1715 let failure_policy = if fail_on_error {
1720 crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::Strict
1721 } else {
1722 crate::reconcilers::dnszone::helpers::EndpointFailurePolicy::SkipUnavailable
1723 };
1724
1725 let (_first_endpoint, _total_endpoints) =
1726 crate::reconcilers::dnszone::helpers::for_each_instance_endpoint_with_policy(
1727 client,
1728 primary_refs,
1729 true, "dns-tcp", failure_policy,
1732 |pod_endpoint, instance_name, rndc_key| {
1733 let zone_name = zone_name.to_string();
1734 let record_name_str = record_name.to_string();
1735 let instance_namespace = instance_map
1736 .get(&instance_name)
1737 .expect("Instance should be in map")
1738 .clone();
1739 let failures = std::sync::Arc::clone(&failures);
1740
1741 let zone_manager =
1743 stores.create_bind9_manager_for_instance(&instance_name, &instance_namespace);
1744
1745 async move {
1746 let key_data = rndc_key.expect("RNDC key should be loaded");
1747
1748 let delete_result = zone_manager
1749 .delete_record(
1750 &zone_name,
1751 &record_name_str,
1752 record_type_hickory,
1753 &pod_endpoint,
1754 &key_data,
1755 )
1756 .await;
1757
1758 match delete_result {
1759 Ok(()) => {
1760 info!(
1761 "Successfully deleted {} record {}.{} from endpoint {} (instance: {})",
1762 record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name
1763 );
1764 }
1765 Err(e) => {
1766 warn!(
1767 "Failed to delete {} record {}.{} from endpoint {} (instance: {}): {}",
1768 record_type_hickory, record_name_str, zone_name, pod_endpoint, instance_name, e
1769 );
1770 failures
1771 .lock()
1772 .expect("delete failures mutex should not be poisoned")
1773 .push(format!(
1774 "endpoint {pod_endpoint} (instance: {instance_name}): {e}"
1775 ));
1776 }
1777 }
1778
1779 Ok(())
1780 }
1781 },
1782 )
1783 .await?;
1784
1785 let failures = failures
1786 .lock()
1787 .expect("delete failures mutex should not be poisoned");
1788
1789 if fail_on_error && !failures.is_empty() {
1790 return Err(anyhow::anyhow!(
1791 "Failed to delete {} record {}.{} from {} endpoint(s): {}",
1792 record_type_hickory,
1793 record_name,
1794 zone_name,
1795 failures.len(),
1796 failures.join("; ")
1797 ));
1798 }
1799
1800 if !failures.is_empty() {
1801 warn!(
1802 "Failed to delete {} record {}.{} from {} endpoint(s); continuing anyway (best-effort)",
1803 record_type_hickory,
1804 record_name,
1805 zone_name,
1806 failures.len()
1807 );
1808 }
1809
1810 Ok(())
1811}
1812
1813#[must_use]
1819pub(crate) fn build_records_timestamp_patch(
1820 records: &[crate::crd::RecordReferenceWithTimestamp],
1821) -> serde_json::Value {
1822 json!({
1823 "status": {
1824 "records": records
1825 }
1826 })
1827}
1828
1829pub async fn update_record_reconciled_timestamp(
1849 client: &Client,
1850 zone_namespace: &str,
1851 zone_name: &str,
1852 record_kind: &str,
1853 record_name: &str,
1854 record_namespace: &str,
1855) -> Result<()> {
1856 let api: Api<DNSZone> = Api::namespaced(client.clone(), zone_namespace);
1857
1858 let mut zone = api.get(zone_name).await?;
1860
1861 let mut found = false;
1863 if let Some(status) = &mut zone.status {
1864 for record_ref in &mut status.records {
1865 if record_ref.kind == record_kind
1866 && record_ref.name == record_name
1867 && record_ref.namespace == record_namespace
1868 {
1869 record_ref.last_reconciled_at = Some(Time(k8s_openapi::jiff::Timestamp::now()));
1870 found = true;
1871 break;
1872 }
1873 }
1874 }
1875
1876 if !found {
1877 warn!(
1878 "Record {} {}/{} not found in DNSZone {}/{} status.records[] - cannot update timestamp",
1879 record_kind, record_namespace, record_name, zone_namespace, zone_name
1880 );
1881 return Ok(());
1882 }
1883
1884 let status_patch = zone
1887 .status
1888 .as_ref()
1889 .map(|s| build_records_timestamp_patch(&s.records))
1890 .unwrap_or_else(|| build_records_timestamp_patch(&[]));
1891
1892 api.patch_status(
1893 zone_name,
1894 &PatchParams::default(),
1895 &Patch::Merge(status_patch),
1896 )
1897 .await?;
1898
1899 info!(
1900 "Updated lastReconciledAt for {} record {}/{} in zone {}/{}",
1901 record_kind, record_namespace, record_name, zone_namespace, zone_name
1902 );
1903
1904 Ok(())
1905}
1906
1907#[derive(Debug, Default, Clone, PartialEq, Eq)]
1916pub struct RecordReplayOutcome {
1917 pub attempted: usize,
1919 pub succeeded: usize,
1921 pub failures: Vec<String>,
1923}
1924
1925impl RecordReplayOutcome {
1926 #[must_use]
1928 pub fn is_complete(&self) -> bool {
1929 self.failures.is_empty()
1930 }
1931
1932 #[must_use]
1934 pub fn summary(&self, zone_name: &str) -> String {
1935 if self.is_complete() {
1936 return format!(
1937 "Replayed {}/{} record(s) into zone {zone_name}",
1938 self.succeeded, self.attempted
1939 );
1940 }
1941
1942 format!(
1943 "Replayed {}/{} record(s) into zone {zone_name}; {} failed: {}",
1944 self.succeeded,
1945 self.attempted,
1946 self.failures.len(),
1947 self.failures.join("; ")
1948 )
1949 }
1950}
1951
1952async fn replay_single_record<T>(
1959 client: &Client,
1960 stores: &crate::context::Stores,
1961 zone_name: &str,
1962 namespace: &str,
1963 name: &str,
1964 primary_refs: &[crate::crd::InstanceReference],
1965) -> Result<()>
1966where
1967 T: ReconcilableRecord,
1968{
1969 let api: Api<T> = Api::namespaced(client.clone(), namespace);
1970 let record = api
1971 .get(name)
1972 .await
1973 .with_context(|| format!("Failed to get {} {namespace}/{name}", T::record_type_name()))?;
1974
1975 let spec = record.get_spec();
1976
1977 add_record_to_instances_generic(
1978 client,
1979 stores,
1980 primary_refs,
1981 zone_name,
1982 T::get_record_name(spec),
1983 T::get_ttl(spec),
1984 T::create_operation(spec),
1985 )
1986 .await
1987}
1988
1989pub async fn replay_zone_records(
2024 client: &Client,
2025 stores: &crate::context::Stores,
2026 zone_name: &str,
2027 record_refs: &[crate::crd::RecordReferenceWithTimestamp],
2028 primary_refs: &[crate::crd::InstanceReference],
2029) -> RecordReplayOutcome {
2030 let mut outcome = RecordReplayOutcome::default();
2031
2032 if record_refs.is_empty() || primary_refs.is_empty() {
2033 return outcome;
2034 }
2035
2036 for record_ref in record_refs {
2037 outcome.attempted += 1;
2038
2039 let namespace = record_ref.namespace.as_str();
2040 let name = record_ref.name.as_str();
2041
2042 let result = match replay_dispatch(
2043 client,
2044 stores,
2045 zone_name,
2046 &record_ref.kind,
2047 namespace,
2048 name,
2049 primary_refs,
2050 )
2051 .await
2052 {
2053 Some(result) => result,
2054 None => {
2055 warn!(
2056 "Cannot replay unknown record kind '{}' for {}/{} in zone {}",
2057 record_ref.kind, namespace, name, zone_name
2058 );
2059 outcome.failures.push(format!(
2060 "{} {namespace}/{name}: unknown record kind",
2061 record_ref.kind
2062 ));
2063 continue;
2064 }
2065 };
2066
2067 match result {
2068 Ok(()) => {
2069 outcome.succeeded += 1;
2070 debug!(
2071 "Replayed {} {}/{} into zone {}",
2072 record_ref.kind, namespace, name, zone_name
2073 );
2074 }
2075 Err(e) => {
2076 warn!(
2077 "Failed to replay {} {}/{} into zone {}: {e:#}",
2078 record_ref.kind, namespace, name, zone_name
2079 );
2080 outcome
2081 .failures
2082 .push(format!("{} {namespace}/{name}: {e}", record_ref.kind));
2083 }
2084 }
2085 }
2086
2087 outcome
2088}
2089
2090async fn replay_dispatch(
2097 client: &Client,
2098 stores: &crate::context::Stores,
2099 zone_name: &str,
2100 kind: &str,
2101 namespace: &str,
2102 name: &str,
2103 primary_refs: &[crate::crd::InstanceReference],
2104) -> Option<Result<()>> {
2105 use crate::crd::DNSRecordKind;
2106
2107 let kind = DNSRecordKind::try_from(kind).ok()?;
2108
2109 macro_rules! replay {
2110 ($record_type:ty) => {
2111 Some(
2112 replay_single_record::<$record_type>(
2113 client,
2114 stores,
2115 zone_name,
2116 namespace,
2117 name,
2118 primary_refs,
2119 )
2120 .await,
2121 )
2122 };
2123 }
2124
2125 match kind {
2126 DNSRecordKind::A => replay!(ARecord),
2127 DNSRecordKind::AAAA => replay!(AAAARecord),
2128 DNSRecordKind::TXT => replay!(TXTRecord),
2129 DNSRecordKind::CNAME => replay!(CNAMERecord),
2130 DNSRecordKind::MX => replay!(MXRecord),
2131 DNSRecordKind::NS => replay!(NSRecord),
2132 DNSRecordKind::SRV => replay!(SRVRecord),
2133 DNSRecordKind::CAA => replay!(CAARecord),
2134 DNSRecordKind::PTR => replay!(PTRRecord),
2135 }
2136}