1#![allow(unused_imports)] use anyhow::{Context as AnyhowContext, Result};
12use kube::{
13 api::{ListParams, Patch, PatchParams},
14 Api, Client, ResourceExt,
15};
16use serde_json::json;
17use std::collections::HashSet;
18use tracing::{debug, info, warn};
19
20use crate::crd::DNSZone;
21use crate::reconcilers::pagination::list_all_paginated;
22
23#[allow(clippy::too_many_lines)]
55pub async fn reconcile_zone_records(
56 client: Client,
57 dnszone: DNSZone,
58 stores: &crate::context::Stores,
59) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
60 let namespace = dnszone.namespace().unwrap_or_default();
61 let spec = &dnszone.spec;
62 let zone_name = &spec.zone_name;
63
64 let Some(ref records_from) = spec.records_from else {
66 info!(
67 "No label selectors defined for zone {}, skipping record discovery",
68 zone_name
69 );
70 return Ok(Vec::new());
72 };
73
74 info!(
75 "Discovering DNS records for zone {} using {} label selector(s)",
76 zone_name,
77 records_from.len()
78 );
79
80 let mut all_record_refs = Vec::new();
81
82 for record_source in records_from {
84 let selector = &record_source.selector;
85
86 all_record_refs.extend(discover_a_records(&client, &namespace, selector, zone_name).await?);
88 all_record_refs
89 .extend(discover_aaaa_records(&client, &namespace, selector, zone_name).await?);
90 all_record_refs
91 .extend(discover_txt_records(&client, &namespace, selector, zone_name).await?);
92 all_record_refs
93 .extend(discover_cname_records(&client, &namespace, selector, zone_name).await?);
94 all_record_refs
95 .extend(discover_mx_records(&client, &namespace, selector, zone_name).await?);
96 all_record_refs
97 .extend(discover_ns_records(&client, &namespace, selector, zone_name).await?);
98 all_record_refs
99 .extend(discover_srv_records(&client, &namespace, selector, zone_name).await?);
100 all_record_refs
101 .extend(discover_caa_records(&client, &namespace, selector, zone_name).await?);
102 }
103
104 info!(
105 "Discovered {} DNS record(s) for zone {}",
106 all_record_refs.len(),
107 zone_name
108 );
109
110 let previous_records: HashSet<String> = dnszone
112 .status
113 .as_ref()
114 .map(|s| {
115 s.records
116 .iter()
117 .map(|r| format!("{}/{}", r.kind, r.name))
118 .collect()
119 })
120 .unwrap_or_default();
121
122 let current_records: HashSet<String> = all_record_refs
124 .iter()
125 .map(|r| format!("{}/{}", r.kind, r.name))
126 .collect();
127
128 for record_ref in &all_record_refs {
133 let record_key = format!("{}/{}", record_ref.kind, record_ref.name);
134 let is_new = !previous_records.contains(&record_key);
135
136 if is_new {
137 info!(
138 "Newly matched record: {} {}/{}",
139 record_ref.kind, namespace, record_ref.name
140 );
141 } else {
142 debug!(
143 "Re-tagging existing record to ensure status.zoneRef: {} {}/{}",
144 record_ref.kind, namespace, record_ref.name
145 );
146 }
147
148 tag_record_with_zone(
149 &client,
150 &namespace,
151 &record_ref.kind,
152 &record_ref.name,
153 zone_name,
154 &dnszone,
155 )
156 .await?;
157 }
158
159 let previous_refs: Vec<crate::crd::RecordReferenceWithTimestamp> = dnszone
165 .status
166 .as_ref()
167 .map(|s| s.records.clone())
168 .unwrap_or_default();
169
170 let mut primary_refs_cache: Option<Vec<crate::crd::InstanceReference>> = None;
172
173 for record_ref in unselected_previous_records(&previous_refs, ¤t_records) {
174 let kind = record_ref.kind.as_str();
175 let name = record_ref.name.as_str();
176
177 warn!(
178 "Record no longer matches zone {} (unmatched or deleted): {} {}/{}",
179 zone_name, kind, namespace, name
180 );
181
182 if let Err(e) = cleanup_unselected_record_dns(
184 &client,
185 stores,
186 &dnszone,
187 &namespace,
188 &record_ref,
189 &mut primary_refs_cache,
190 )
191 .await
192 {
193 warn!(
197 "Failed to delete DNS data for unselected record {} {}/{} from zone {}: {}. \
198 Keeping record selected for retry.",
199 kind, namespace, name, zone_name, e
200 );
201 all_record_refs.push(record_ref.clone());
202 continue;
203 }
204
205 if let Err(e) = untag_record_from_zone(&client, &namespace, kind, name, zone_name).await {
208 if e.to_string().contains("NotFound") || e.to_string().contains("not found") {
210 info!(
211 "Record {} {}/{} was deleted, removing from zone {} status",
212 kind, namespace, name, zone_name
213 );
214 } else {
215 warn!(
217 "Failed to untag record {} {}/{} from zone {}: {}",
218 kind, namespace, name, zone_name, e
219 );
220 }
221 }
222 }
225
226 if let Some(status) = &dnszone.status {
229 let existing_timestamps: std::collections::HashMap<String, _> = status
230 .records
231 .iter()
232 .filter_map(|r| {
233 r.last_reconciled_at
234 .as_ref()
235 .map(|timestamp| (format!("{}/{}", r.kind, r.name), timestamp.clone()))
236 })
237 .collect();
238
239 for record_ref in &mut all_record_refs {
241 let key = format!("{}/{}", record_ref.kind, record_ref.name);
242 if let Some(existing_timestamp) = existing_timestamps.get(&key) {
243 record_ref.last_reconciled_at = Some(existing_timestamp.clone());
244 }
245 }
246 }
247
248 Ok(all_record_refs)
249}
250
251const HTTP_STATUS_NOT_FOUND: u16 = 404;
253
254fn unselected_previous_records(
261 previous: &[crate::crd::RecordReferenceWithTimestamp],
262 current_keys: &HashSet<String>,
263) -> Vec<crate::crd::RecordReferenceWithTimestamp> {
264 previous
265 .iter()
266 .filter(|r| !current_keys.contains(&format!("{}/{}", r.kind, r.name)))
267 .cloned()
268 .collect()
269}
270
271fn hickory_record_type_for_kind(kind: &str) -> Result<hickory_proto::rr::RecordType> {
277 use crate::crd::DNSRecordKind;
278 use hickory_proto::rr::RecordType;
279
280 let record_kind = DNSRecordKind::try_from(kind)
281 .map_err(|e| anyhow::anyhow!("Unknown DNS record kind '{kind}': {e}"))?;
282
283 Ok(match record_kind {
284 DNSRecordKind::A => RecordType::A,
285 DNSRecordKind::AAAA => RecordType::AAAA,
286 DNSRecordKind::TXT => RecordType::TXT,
287 DNSRecordKind::CNAME => RecordType::CNAME,
288 DNSRecordKind::MX => RecordType::MX,
289 DNSRecordKind::NS => RecordType::NS,
290 DNSRecordKind::SRV => RecordType::SRV,
291 DNSRecordKind::CAA => RecordType::CAA,
292 })
293}
294
295fn dynamic_record_api(
297 client: &Client,
298 namespace: &str,
299 kind: &str,
300) -> kube::api::Api<kube::api::DynamicObject> {
301 let plural = format!("{}s", kind.to_lowercase());
303
304 let gvk = kube::core::GroupVersionKind {
305 group: "bindy.firestoned.io".to_string(),
306 version: "v1beta1".to_string(),
307 kind: kind.to_string(),
308 };
309
310 let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural);
311
312 kube::api::Api::<kube::api::DynamicObject>::namespaced_with(
313 client.clone(),
314 namespace,
315 &api_resource,
316 )
317}
318
319async fn cleanup_unselected_record_dns(
347 client: &Client,
348 stores: &crate::context::Stores,
349 dnszone: &DNSZone,
350 namespace: &str,
351 record_ref: &crate::crd::RecordReferenceWithTimestamp,
352 primary_refs_cache: &mut Option<Vec<crate::crd::InstanceReference>>,
353) -> Result<()> {
354 let kind = record_ref.kind.as_str();
355 let name = record_ref.name.as_str();
356
357 let api = dynamic_record_api(client, namespace, kind);
359 let record = match api.get(name).await {
360 Ok(record) => record,
361 Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => {
362 debug!(
364 "Record {} {}/{} no longer exists, skipping DNS cleanup",
365 kind, namespace, name
366 );
367 return Ok(());
368 }
369 Err(e) => {
370 return Err(anyhow::Error::from(e)
371 .context(format!("Failed to fetch {kind} {namespace}/{name}")));
372 }
373 };
374
375 let record_json = serde_json::to_value(&record)?;
376 let status = record_json.get("status");
377
378 let Some(zone_ref) = status
380 .and_then(|s| s.get("zoneRef"))
381 .filter(|z| !z.is_null())
382 else {
383 debug!(
384 "Record {} {}/{} has no status.zoneRef, skipping DNS cleanup",
385 kind, namespace, name
386 );
387 return Ok(());
388 };
389
390 let owned_by_this_zone = zone_ref.get("name").and_then(|v| v.as_str())
392 == Some(dnszone.name_any().as_str())
393 && zone_ref.get("namespace").and_then(|v| v.as_str())
394 == Some(dnszone.namespace().unwrap_or_default().as_str());
395 if !owned_by_this_zone {
396 debug!(
397 "Record {} {}/{} is owned by a different zone, skipping DNS cleanup",
398 kind, namespace, name
399 );
400 return Ok(());
401 }
402
403 if primary_refs_cache.is_none() {
405 let Ok(instance_refs) = crate::reconcilers::dnszone::validation::get_instances_from_zone(
406 dnszone,
407 &stores.bind9_instances,
408 ) else {
409 debug!(
411 "Zone {} has no instances, skipping DNS cleanup for {} {}/{}",
412 dnszone.spec.zone_name, kind, namespace, name
413 );
414 return Ok(());
415 };
416
417 let primaries =
418 crate::reconcilers::dnszone::primary::filter_primary_instances(client, &instance_refs)
419 .await?;
420 *primary_refs_cache = Some(primaries);
421 }
422
423 let primary_refs = primary_refs_cache.as_deref().unwrap_or_default();
424 if primary_refs.is_empty() {
425 debug!(
426 "Zone {} has no primary instances, skipping DNS cleanup for {} {}/{}",
427 dnszone.spec.zone_name, kind, namespace, name
428 );
429 return Ok(());
430 }
431
432 let record_name = status
434 .and_then(|s| s.get("publishedName"))
435 .and_then(|p| p.as_str())
436 .or_else(|| {
437 record_json
438 .get("spec")
439 .and_then(|s| s.get("name"))
440 .and_then(|n| n.as_str())
441 })
442 .unwrap_or(name);
443
444 let record_type_hickory = hickory_record_type_for_kind(kind)?;
445
446 crate::reconcilers::records::delete_record_from_primaries(
447 client,
448 stores,
449 primary_refs,
450 &dnszone.spec.zone_name,
451 record_name,
452 record_type_hickory,
453 true, )
455 .await?;
456
457 info!(
458 "Deleted DNS data for unselected record {} {}/{} ('{}') from zone {}",
459 kind, namespace, name, record_name, dnszone.spec.zone_name
460 );
461
462 Ok(())
463}
464
465async fn tag_record_with_zone(
484 client: &Client,
485 namespace: &str,
486 kind: &str,
487 name: &str,
488 zone_fqdn: &str,
489 dnszone: &DNSZone,
490) -> Result<()> {
491 debug!(
492 "Tagging {} {}/{} with zone {}",
493 kind, namespace, name, zone_fqdn
494 );
495
496 let api = dynamic_record_api(client, namespace, kind);
498
499 let zone_ref = crate::crd::ZoneReference {
501 api_version: crate::constants::API_GROUP_VERSION.to_string(),
502 kind: crate::constants::KIND_DNS_ZONE.to_string(),
503 name: dnszone.name_any(),
504 namespace: dnszone.namespace().unwrap_or_default(),
505 zone_name: zone_fqdn.to_string(),
506 last_reconciled_at: None, };
508
509 let status_patch = json!({
511 "status": {
512 "zone": zone_fqdn,
513 "zoneRef": zone_ref
514 }
515 });
516
517 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
518 .await
519 .with_context(|| {
520 format!("Failed to set status.zone and status.zoneRef on {kind} {namespace}/{name}")
521 })?;
522
523 info!(
524 "Successfully tagged {} {}/{} with zone {} (set status.zoneRef)",
525 kind, namespace, name, zone_fqdn
526 );
527
528 Ok(())
529}
530
531async fn untag_record_from_zone(
553 client: &Client,
554 namespace: &str,
555 kind: &str,
556 name: &str,
557 previous_zone_fqdn: &str,
558) -> Result<()> {
559 debug!(
560 "Untagging {} {}/{} from zone {} (clearing status.zoneRef)",
561 kind, namespace, name, previous_zone_fqdn
562 );
563
564 let api = dynamic_record_api(client, namespace, kind);
566
567 let status_patch = json!({
569 "status": {
570 "zoneRef": null,
571 "zone": null }
573 });
574
575 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
576 .await
577 .with_context(|| format!("Failed to clear status.zoneRef on {kind} {namespace}/{name}"))?;
578
579 info!(
580 "Successfully untagged {} {}/{} from zone {} (cleared status.zoneRef)",
581 kind, namespace, name, previous_zone_fqdn
582 );
583
584 Ok(())
585}
586
587trait DiscoverableRecord:
592 kube::Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
593 + Clone
594 + std::fmt::Debug
595 + serde::de::DeserializeOwned
596 + kube::ResourceExt
597{
598 fn dns_record_kind() -> crate::crd::DNSRecordKind;
600
601 fn spec_name(&self) -> &str;
603
604 fn record_status(&self) -> Option<&crate::crd::RecordStatus>;
606}
607
608impl DiscoverableRecord for crate::crd::ARecord {
611 fn dns_record_kind() -> crate::crd::DNSRecordKind {
612 crate::crd::DNSRecordKind::A
613 }
614
615 fn spec_name(&self) -> &str {
616 &self.spec.name
617 }
618
619 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
620 self.status.as_ref()
621 }
622}
623
624impl DiscoverableRecord for crate::crd::AAAARecord {
625 fn dns_record_kind() -> crate::crd::DNSRecordKind {
626 crate::crd::DNSRecordKind::AAAA
627 }
628
629 fn spec_name(&self) -> &str {
630 &self.spec.name
631 }
632
633 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
634 self.status.as_ref()
635 }
636}
637
638impl DiscoverableRecord for crate::crd::TXTRecord {
639 fn dns_record_kind() -> crate::crd::DNSRecordKind {
640 crate::crd::DNSRecordKind::TXT
641 }
642
643 fn spec_name(&self) -> &str {
644 &self.spec.name
645 }
646
647 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
648 self.status.as_ref()
649 }
650}
651
652impl DiscoverableRecord for crate::crd::CNAMERecord {
653 fn dns_record_kind() -> crate::crd::DNSRecordKind {
654 crate::crd::DNSRecordKind::CNAME
655 }
656
657 fn spec_name(&self) -> &str {
658 &self.spec.name
659 }
660
661 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
662 self.status.as_ref()
663 }
664}
665
666impl DiscoverableRecord for crate::crd::MXRecord {
667 fn dns_record_kind() -> crate::crd::DNSRecordKind {
668 crate::crd::DNSRecordKind::MX
669 }
670
671 fn spec_name(&self) -> &str {
672 &self.spec.name
673 }
674
675 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
676 self.status.as_ref()
677 }
678}
679
680impl DiscoverableRecord for crate::crd::NSRecord {
681 fn dns_record_kind() -> crate::crd::DNSRecordKind {
682 crate::crd::DNSRecordKind::NS
683 }
684
685 fn spec_name(&self) -> &str {
686 &self.spec.name
687 }
688
689 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
690 self.status.as_ref()
691 }
692}
693
694impl DiscoverableRecord for crate::crd::SRVRecord {
695 fn dns_record_kind() -> crate::crd::DNSRecordKind {
696 crate::crd::DNSRecordKind::SRV
697 }
698
699 fn spec_name(&self) -> &str {
700 &self.spec.name
701 }
702
703 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
704 self.status.as_ref()
705 }
706}
707
708impl DiscoverableRecord for crate::crd::CAARecord {
709 fn dns_record_kind() -> crate::crd::DNSRecordKind {
710 crate::crd::DNSRecordKind::CAA
711 }
712
713 fn spec_name(&self) -> &str {
714 &self.spec.name
715 }
716
717 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
718 self.status.as_ref()
719 }
720}
721
722async fn discover_records_generic<T>(
746 client: &Client,
747 namespace: &str,
748 selector: &crate::crd::LabelSelector,
749 _zone_name: &str,
750) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>>
751where
752 T: DiscoverableRecord,
753{
754 use std::collections::BTreeMap;
755
756 let api: kube::Api<T> = kube::Api::namespaced(client.clone(), namespace);
757 let records = list_all_paginated(&api, kube::api::ListParams::default()).await?;
758
759 let mut record_refs = Vec::new();
760 for record in records {
761 let labels: BTreeMap<String, String> = record.meta().labels.clone().unwrap_or_default();
762
763 if !selector.matches(&labels) {
764 continue;
765 }
766
767 debug!(
768 "Discovered {} record {}/{}",
769 T::dns_record_kind().as_str(),
770 namespace,
771 record.name_any()
772 );
773
774 let last_reconciled_at = record
776 .record_status()
777 .and_then(|s| s.last_updated.as_ref())
778 .and_then(|ts| {
779 ts.parse::<k8s_openapi::jiff::Timestamp>()
781 .ok()
782 .map(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time)
783 });
784
785 record_refs.push(crate::crd::RecordReferenceWithTimestamp {
786 api_version: "bindy.firestoned.io/v1beta1".to_string(),
787 kind: T::dns_record_kind().as_str().to_string(),
788 name: record.name_any(),
789 namespace: namespace.to_string(),
790 record_name: Some(record.spec_name().to_string()),
791 last_reconciled_at,
792 });
793 }
794
795 Ok(record_refs)
796}
797
798async fn discover_a_records(
800 client: &Client,
801 namespace: &str,
802 selector: &crate::crd::LabelSelector,
803 zone_name: &str,
804) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
805 discover_records_generic::<crate::crd::ARecord>(client, namespace, selector, zone_name).await
806}
807
808async fn discover_aaaa_records(
810 client: &Client,
811 namespace: &str,
812 selector: &crate::crd::LabelSelector,
813 zone_name: &str,
814) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
815 discover_records_generic::<crate::crd::AAAARecord>(client, namespace, selector, zone_name).await
816}
817
818async fn discover_txt_records(
820 client: &Client,
821 namespace: &str,
822 selector: &crate::crd::LabelSelector,
823 zone_name: &str,
824) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
825 discover_records_generic::<crate::crd::TXTRecord>(client, namespace, selector, zone_name).await
826}
827
828async fn discover_cname_records(
830 client: &Client,
831 namespace: &str,
832 selector: &crate::crd::LabelSelector,
833 zone_name: &str,
834) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
835 discover_records_generic::<crate::crd::CNAMERecord>(client, namespace, selector, zone_name)
836 .await
837}
838
839async fn discover_mx_records(
841 client: &Client,
842 namespace: &str,
843 selector: &crate::crd::LabelSelector,
844 zone_name: &str,
845) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
846 discover_records_generic::<crate::crd::MXRecord>(client, namespace, selector, zone_name).await
847}
848
849async fn discover_ns_records(
851 client: &Client,
852 namespace: &str,
853 selector: &crate::crd::LabelSelector,
854 zone_name: &str,
855) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
856 discover_records_generic::<crate::crd::NSRecord>(client, namespace, selector, zone_name).await
857}
858
859async fn discover_srv_records(
861 client: &Client,
862 namespace: &str,
863 selector: &crate::crd::LabelSelector,
864 zone_name: &str,
865) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
866 discover_records_generic::<crate::crd::SRVRecord>(client, namespace, selector, zone_name).await
867}
868
869async fn discover_caa_records(
871 client: &Client,
872 namespace: &str,
873 selector: &crate::crd::LabelSelector,
874 zone_name: &str,
875) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
876 discover_records_generic::<crate::crd::CAARecord>(client, namespace, selector, zone_name).await
877}
878
879pub async fn check_all_records_ready(
897 client: &Client,
898 namespace: &str,
899 record_refs: &[crate::crd::RecordReferenceWithTimestamp],
900) -> Result<bool> {
901 use crate::crd::{
902 AAAARecord, ARecord, CAARecord, CNAMERecord, DNSRecordKind, MXRecord, NSRecord, SRVRecord,
903 TXTRecord,
904 };
905
906 for record_ref in record_refs {
907 let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
908 let is_ready = match kind {
909 DNSRecordKind::A => {
910 let api: Api<ARecord> = Api::namespaced(client.clone(), namespace);
911 check_record_ready(&api, &record_ref.name).await?
912 }
913 DNSRecordKind::AAAA => {
914 let api: Api<AAAARecord> = Api::namespaced(client.clone(), namespace);
915 check_record_ready(&api, &record_ref.name).await?
916 }
917 DNSRecordKind::TXT => {
918 let api: Api<TXTRecord> = Api::namespaced(client.clone(), namespace);
919 check_record_ready(&api, &record_ref.name).await?
920 }
921 DNSRecordKind::CNAME => {
922 let api: Api<CNAMERecord> = Api::namespaced(client.clone(), namespace);
923 check_record_ready(&api, &record_ref.name).await?
924 }
925 DNSRecordKind::MX => {
926 let api: Api<MXRecord> = Api::namespaced(client.clone(), namespace);
927 check_record_ready(&api, &record_ref.name).await?
928 }
929 DNSRecordKind::NS => {
930 let api: Api<NSRecord> = Api::namespaced(client.clone(), namespace);
931 check_record_ready(&api, &record_ref.name).await?
932 }
933 DNSRecordKind::SRV => {
934 let api: Api<SRVRecord> = Api::namespaced(client.clone(), namespace);
935 check_record_ready(&api, &record_ref.name).await?
936 }
937 DNSRecordKind::CAA => {
938 let api: Api<CAARecord> = Api::namespaced(client.clone(), namespace);
939 check_record_ready(&api, &record_ref.name).await?
940 }
941 };
942
943 if !is_ready {
944 debug!(
945 "Record {}/{} (kind: {}) is not ready yet",
946 namespace, record_ref.name, record_ref.kind
947 );
948 return Ok(false);
949 }
950 }
951
952 Ok(true)
953}
954
955async fn check_record_ready<T>(api: &Api<T>, name: &str) -> Result<bool>
957where
958 T: kube::Resource<DynamicType = ()>
959 + Clone
960 + serde::de::DeserializeOwned
961 + serde::Serialize
962 + std::fmt::Debug
963 + Send
964 + Sync,
965 <T as kube::Resource>::DynamicType: Default,
966{
967 let record = match api.get(name).await {
968 Ok(r) => r,
969 Err(e) => {
970 warn!("Failed to get record {}: {}", name, e);
971 return Ok(false);
972 }
973 };
974
975 let record_json = serde_json::to_value(&record)?;
977 let status = record_json.get("status");
978
979 if let Some(status_obj) = status {
980 if let Some(conditions) = status_obj.get("conditions").and_then(|c| c.as_array()) {
981 for condition in conditions {
982 if let (Some(type_val), Some(status_val)) = (
983 condition.get("type").and_then(|t| t.as_str()),
984 condition.get("status").and_then(|s| s.as_str()),
985 ) {
986 if type_val == "Ready" && status_val == "True" {
987 return Ok(true);
988 }
989 }
990 }
991 }
992 }
993
994 Ok(false)
995}
996
997pub async fn find_zones_selecting_record(
1019 client: &Client,
1020 record_namespace: &str,
1021 record_kind: &str,
1022 record_name: &str,
1023) -> Result<Vec<(String, String)>> {
1024 let api: Api<DNSZone> = Api::namespaced(client.clone(), record_namespace);
1025 let zones = list_all_paginated(&api, ListParams::default()).await?;
1026
1027 let mut selecting_zones = vec![];
1028
1029 for zone in zones {
1030 let Some(ref status) = zone.status else {
1031 continue;
1032 };
1033
1034 let is_selected = status
1036 .records
1037 .iter()
1038 .any(|r| r.kind == record_kind && r.name == record_name);
1039
1040 if is_selected {
1041 let zone_name = zone.name_any();
1042 let zone_namespace = zone.namespace().unwrap_or_default();
1043 selecting_zones.push((zone_name, zone_namespace));
1044 }
1045 }
1046
1047 Ok(selecting_zones)
1048}
1049pub async fn trigger_record_reconciliation(
1070 client: &Client,
1071 namespace: &str,
1072 zone_name: &str,
1073) -> Result<()> {
1074 use crate::crd::{
1075 AAAARecord, ARecord, CAARecord, CNAMERecord, MXRecord, NSRecord, SRVRecord, TXTRecord,
1076 };
1077
1078 debug!(
1079 "Triggering record reconciliation for zone {} in namespace {}",
1080 zone_name, namespace
1081 );
1082
1083 macro_rules! count_records {
1087 ($record_type:ty, $type_name:expr) => {{
1088 let api: Api<$record_type> = Api::namespaced(client.clone(), namespace);
1089 let lp = ListParams::default();
1090
1091 match api.list(&lp).await {
1092 Ok(records) => {
1093 let matching_count = records
1094 .items
1095 .iter()
1096 .filter(|r| {
1097 r.status
1099 .as_ref()
1100 .and_then(|s| s.zone_ref.as_ref())
1101 .map(|zr| zr.zone_name == zone_name)
1102 .unwrap_or(false)
1103 })
1104 .count();
1105
1106 debug!(
1107 "Found {} {} record(s) for zone {} (event-driven watches will trigger reconciliation)",
1108 matching_count,
1109 $type_name,
1110 zone_name
1111 );
1112 }
1113 Err(e) => {
1114 warn!(
1115 "Failed to list {} records in namespace {}: {}",
1116 $type_name, namespace, e
1117 );
1118 }
1119 }
1120 }};
1121 }
1122
1123 count_records!(ARecord, "A");
1125 count_records!(AAAARecord, "AAAA");
1126 count_records!(TXTRecord, "TXT");
1127 count_records!(CNAMERecord, "CNAME");
1128 count_records!(MXRecord, "MX");
1129 count_records!(NSRecord, "NS");
1130 count_records!(SRVRecord, "SRV");
1131 count_records!(CAARecord, "CAA");
1132
1133 Ok(())
1134}
1135
1136pub async fn discover_and_update_records(
1161 client: &kube::Client,
1162 dnszone: &crate::crd::DNSZone,
1163 status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
1164 stores: &crate::context::Stores,
1165) -> Result<(Vec<crate::crd::RecordReferenceWithTimestamp>, usize)> {
1166 let spec = &dnszone.spec;
1167
1168 status_updater.set_condition(
1170 "Progressing",
1171 "True",
1172 "RecordsDiscovering",
1173 "Discovering DNS records via label selectors",
1174 );
1175
1176 let record_refs = reconcile_zone_records(client.clone(), dnszone.clone(), stores)
1181 .await
1182 .map_err(|e| {
1183 warn!(
1184 "Failed to discover DNS records for zone {}: {}. Keeping existing status.records for retry.",
1185 spec.zone_name, e
1186 );
1187 e.context(format!(
1188 "Failed to discover DNS records for zone {}",
1189 spec.zone_name
1190 ))
1191 })?;
1192
1193 info!(
1194 "Discovered {} DNS record(s) for zone {} via label selectors",
1195 record_refs.len(),
1196 spec.zone_name
1197 );
1198
1199 let records_count = record_refs.len();
1200
1201 status_updater.set_records(&record_refs);
1203
1204 Ok((record_refs, records_count))
1205}
1206
1207#[cfg(test)]
1208#[path = "discovery_tests.rs"]
1209mod discovery_tests;