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 all_record_refs
103 .extend(discover_ptr_records(&client, &namespace, selector, zone_name).await?);
104 }
105
106 info!(
107 "Discovered {} DNS record(s) for zone {}",
108 all_record_refs.len(),
109 zone_name
110 );
111
112 let previous_records: HashSet<String> = dnszone
114 .status
115 .as_ref()
116 .map(|s| {
117 s.records
118 .iter()
119 .map(|r| format!("{}/{}", r.kind, r.name))
120 .collect()
121 })
122 .unwrap_or_default();
123
124 let current_records: HashSet<String> = all_record_refs
126 .iter()
127 .map(|r| format!("{}/{}", r.kind, r.name))
128 .collect();
129
130 for record_ref in &all_record_refs {
135 let record_key = format!("{}/{}", record_ref.kind, record_ref.name);
136 let is_new = !previous_records.contains(&record_key);
137
138 if is_new {
139 info!(
140 "Newly matched record: {} {}/{}",
141 record_ref.kind, namespace, record_ref.name
142 );
143 } else {
144 debug!(
145 "Re-tagging existing record to ensure status.zoneRef: {} {}/{}",
146 record_ref.kind, namespace, record_ref.name
147 );
148 }
149
150 tag_record_with_zone(
151 &client,
152 &namespace,
153 &record_ref.kind,
154 &record_ref.name,
155 zone_name,
156 &dnszone,
157 )
158 .await?;
159 }
160
161 let previous_refs: Vec<crate::crd::RecordReferenceWithTimestamp> = dnszone
167 .status
168 .as_ref()
169 .map(|s| s.records.clone())
170 .unwrap_or_default();
171
172 let mut primary_refs_cache: Option<Vec<crate::crd::InstanceReference>> = None;
174
175 for record_ref in unselected_previous_records(&previous_refs, ¤t_records) {
176 let kind = record_ref.kind.as_str();
177 let name = record_ref.name.as_str();
178
179 warn!(
180 "Record no longer matches zone {} (unmatched or deleted): {} {}/{}",
181 zone_name, kind, namespace, name
182 );
183
184 if let Err(e) = cleanup_unselected_record_dns(
186 &client,
187 stores,
188 &dnszone,
189 &namespace,
190 &record_ref,
191 &mut primary_refs_cache,
192 )
193 .await
194 {
195 warn!(
199 "Failed to delete DNS data for unselected record {} {}/{} from zone {}: {}. \
200 Keeping record selected for retry.",
201 kind, namespace, name, zone_name, e
202 );
203 all_record_refs.push(record_ref.clone());
204 continue;
205 }
206
207 if let Err(e) = untag_record_from_zone(&client, &namespace, kind, name, zone_name).await {
210 if e.to_string().contains("NotFound") || e.to_string().contains("not found") {
212 info!(
213 "Record {} {}/{} was deleted, removing from zone {} status",
214 kind, namespace, name, zone_name
215 );
216 } else {
217 warn!(
219 "Failed to untag record {} {}/{} from zone {}: {}",
220 kind, namespace, name, zone_name, e
221 );
222 }
223 }
224 }
227
228 if let Some(status) = &dnszone.status {
231 let existing_timestamps: std::collections::HashMap<String, _> = status
232 .records
233 .iter()
234 .filter_map(|r| {
235 r.last_reconciled_at
236 .as_ref()
237 .map(|timestamp| (format!("{}/{}", r.kind, r.name), timestamp.clone()))
238 })
239 .collect();
240
241 for record_ref in &mut all_record_refs {
243 let key = format!("{}/{}", record_ref.kind, record_ref.name);
244 if let Some(existing_timestamp) = existing_timestamps.get(&key) {
245 record_ref.last_reconciled_at = Some(existing_timestamp.clone());
246 }
247 }
248 }
249
250 Ok(all_record_refs)
251}
252
253const HTTP_STATUS_NOT_FOUND: u16 = 404;
255
256fn unselected_previous_records(
263 previous: &[crate::crd::RecordReferenceWithTimestamp],
264 current_keys: &HashSet<String>,
265) -> Vec<crate::crd::RecordReferenceWithTimestamp> {
266 previous
267 .iter()
268 .filter(|r| !current_keys.contains(&format!("{}/{}", r.kind, r.name)))
269 .cloned()
270 .collect()
271}
272
273fn hickory_record_type_for_kind(kind: &str) -> Result<hickory_proto::rr::RecordType> {
279 use crate::crd::DNSRecordKind;
280 use hickory_proto::rr::RecordType;
281
282 let record_kind = DNSRecordKind::try_from(kind)
283 .map_err(|e| anyhow::anyhow!("Unknown DNS record kind '{kind}': {e}"))?;
284
285 Ok(match record_kind {
286 DNSRecordKind::A => RecordType::A,
287 DNSRecordKind::AAAA => RecordType::AAAA,
288 DNSRecordKind::TXT => RecordType::TXT,
289 DNSRecordKind::CNAME => RecordType::CNAME,
290 DNSRecordKind::MX => RecordType::MX,
291 DNSRecordKind::NS => RecordType::NS,
292 DNSRecordKind::SRV => RecordType::SRV,
293 DNSRecordKind::CAA => RecordType::CAA,
294 DNSRecordKind::PTR => RecordType::PTR,
295 })
296}
297
298fn dynamic_record_api(
300 client: &Client,
301 namespace: &str,
302 kind: &str,
303) -> kube::api::Api<kube::api::DynamicObject> {
304 let plural = format!("{}s", kind.to_lowercase());
306
307 let gvk = kube::core::GroupVersionKind {
308 group: "bindy.firestoned.io".to_string(),
309 version: "v1beta1".to_string(),
310 kind: kind.to_string(),
311 };
312
313 let api_resource = kube::api::ApiResource::from_gvk_with_plural(&gvk, &plural);
314
315 kube::api::Api::<kube::api::DynamicObject>::namespaced_with(
316 client.clone(),
317 namespace,
318 &api_resource,
319 )
320}
321
322async fn cleanup_unselected_record_dns(
350 client: &Client,
351 stores: &crate::context::Stores,
352 dnszone: &DNSZone,
353 namespace: &str,
354 record_ref: &crate::crd::RecordReferenceWithTimestamp,
355 primary_refs_cache: &mut Option<Vec<crate::crd::InstanceReference>>,
356) -> Result<()> {
357 let kind = record_ref.kind.as_str();
358 let name = record_ref.name.as_str();
359
360 let api = dynamic_record_api(client, namespace, kind);
362 let record = match api.get(name).await {
363 Ok(record) => record,
364 Err(kube::Error::Api(ae)) if ae.code == HTTP_STATUS_NOT_FOUND => {
365 debug!(
367 "Record {} {}/{} no longer exists, skipping DNS cleanup",
368 kind, namespace, name
369 );
370 return Ok(());
371 }
372 Err(e) => {
373 return Err(anyhow::Error::from(e)
374 .context(format!("Failed to fetch {kind} {namespace}/{name}")));
375 }
376 };
377
378 let record_json = serde_json::to_value(&record)?;
379 let status = record_json.get("status");
380
381 let Some(zone_ref) = status
383 .and_then(|s| s.get("zoneRef"))
384 .filter(|z| !z.is_null())
385 else {
386 debug!(
387 "Record {} {}/{} has no status.zoneRef, skipping DNS cleanup",
388 kind, namespace, name
389 );
390 return Ok(());
391 };
392
393 let owned_by_this_zone = zone_ref.get("name").and_then(|v| v.as_str())
395 == Some(dnszone.name_any().as_str())
396 && zone_ref.get("namespace").and_then(|v| v.as_str())
397 == Some(dnszone.namespace().unwrap_or_default().as_str());
398 if !owned_by_this_zone {
399 debug!(
400 "Record {} {}/{} is owned by a different zone, skipping DNS cleanup",
401 kind, namespace, name
402 );
403 return Ok(());
404 }
405
406 if primary_refs_cache.is_none() {
408 let Ok(instance_refs) = crate::reconcilers::dnszone::validation::get_instances_from_zone(
409 dnszone,
410 &stores.bind9_instances,
411 ) else {
412 debug!(
414 "Zone {} has no instances, skipping DNS cleanup for {} {}/{}",
415 dnszone.spec.zone_name, kind, namespace, name
416 );
417 return Ok(());
418 };
419
420 let primaries =
421 crate::reconcilers::dnszone::primary::filter_primary_instances(client, &instance_refs)
422 .await?;
423 *primary_refs_cache = Some(primaries);
424 }
425
426 let primary_refs = primary_refs_cache.as_deref().unwrap_or_default();
427 if primary_refs.is_empty() {
428 debug!(
429 "Zone {} has no primary instances, skipping DNS cleanup for {} {}/{}",
430 dnszone.spec.zone_name, kind, namespace, name
431 );
432 return Ok(());
433 }
434
435 let record_name = status
437 .and_then(|s| s.get("publishedName"))
438 .and_then(|p| p.as_str())
439 .or_else(|| {
440 record_json
441 .get("spec")
442 .and_then(|s| s.get("name"))
443 .and_then(|n| n.as_str())
444 })
445 .unwrap_or(name);
446
447 let record_type_hickory = hickory_record_type_for_kind(kind)?;
448
449 crate::reconcilers::records::delete_record_from_primaries(
450 client,
451 stores,
452 primary_refs,
453 &dnszone.spec.zone_name,
454 record_name,
455 record_type_hickory,
456 true, )
458 .await?;
459
460 info!(
461 "Deleted DNS data for unselected record {} {}/{} ('{}') from zone {}",
462 kind, namespace, name, record_name, dnszone.spec.zone_name
463 );
464
465 Ok(())
466}
467
468async fn tag_record_with_zone(
487 client: &Client,
488 namespace: &str,
489 kind: &str,
490 name: &str,
491 zone_fqdn: &str,
492 dnszone: &DNSZone,
493) -> Result<()> {
494 debug!(
495 "Tagging {} {}/{} with zone {}",
496 kind, namespace, name, zone_fqdn
497 );
498
499 let api = dynamic_record_api(client, namespace, kind);
501
502 let zone_ref = crate::crd::ZoneReference {
504 api_version: crate::constants::API_GROUP_VERSION.to_string(),
505 kind: crate::constants::KIND_DNS_ZONE.to_string(),
506 name: dnszone.name_any(),
507 namespace: dnszone.namespace().unwrap_or_default(),
508 zone_name: zone_fqdn.to_string(),
509 last_reconciled_at: None, };
511
512 let status_patch = json!({
514 "status": {
515 "zone": zone_fqdn,
516 "zoneRef": zone_ref
517 }
518 });
519
520 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
521 .await
522 .with_context(|| {
523 format!("Failed to set status.zone and status.zoneRef on {kind} {namespace}/{name}")
524 })?;
525
526 info!(
527 "Successfully tagged {} {}/{} with zone {} (set status.zoneRef)",
528 kind, namespace, name, zone_fqdn
529 );
530
531 Ok(())
532}
533
534async fn untag_record_from_zone(
556 client: &Client,
557 namespace: &str,
558 kind: &str,
559 name: &str,
560 previous_zone_fqdn: &str,
561) -> Result<()> {
562 debug!(
563 "Untagging {} {}/{} from zone {} (clearing status.zoneRef)",
564 kind, namespace, name, previous_zone_fqdn
565 );
566
567 let api = dynamic_record_api(client, namespace, kind);
569
570 let status_patch = json!({
572 "status": {
573 "zoneRef": null,
574 "zone": null }
576 });
577
578 api.patch_status(name, &PatchParams::default(), &Patch::Merge(&status_patch))
579 .await
580 .with_context(|| format!("Failed to clear status.zoneRef on {kind} {namespace}/{name}"))?;
581
582 info!(
583 "Successfully untagged {} {}/{} from zone {} (cleared status.zoneRef)",
584 kind, namespace, name, previous_zone_fqdn
585 );
586
587 Ok(())
588}
589
590trait DiscoverableRecord:
595 kube::Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
596 + Clone
597 + std::fmt::Debug
598 + serde::de::DeserializeOwned
599 + kube::ResourceExt
600{
601 fn dns_record_kind() -> crate::crd::DNSRecordKind;
603
604 fn spec_name(&self) -> &str;
606
607 fn record_status(&self) -> Option<&crate::crd::RecordStatus>;
609}
610
611impl DiscoverableRecord for crate::crd::ARecord {
614 fn dns_record_kind() -> crate::crd::DNSRecordKind {
615 crate::crd::DNSRecordKind::A
616 }
617
618 fn spec_name(&self) -> &str {
619 &self.spec.name
620 }
621
622 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
623 self.status.as_ref()
624 }
625}
626
627impl DiscoverableRecord for crate::crd::AAAARecord {
628 fn dns_record_kind() -> crate::crd::DNSRecordKind {
629 crate::crd::DNSRecordKind::AAAA
630 }
631
632 fn spec_name(&self) -> &str {
633 &self.spec.name
634 }
635
636 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
637 self.status.as_ref()
638 }
639}
640
641impl DiscoverableRecord for crate::crd::TXTRecord {
642 fn dns_record_kind() -> crate::crd::DNSRecordKind {
643 crate::crd::DNSRecordKind::TXT
644 }
645
646 fn spec_name(&self) -> &str {
647 &self.spec.name
648 }
649
650 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
651 self.status.as_ref()
652 }
653}
654
655impl DiscoverableRecord for crate::crd::CNAMERecord {
656 fn dns_record_kind() -> crate::crd::DNSRecordKind {
657 crate::crd::DNSRecordKind::CNAME
658 }
659
660 fn spec_name(&self) -> &str {
661 &self.spec.name
662 }
663
664 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
665 self.status.as_ref()
666 }
667}
668
669impl DiscoverableRecord for crate::crd::MXRecord {
670 fn dns_record_kind() -> crate::crd::DNSRecordKind {
671 crate::crd::DNSRecordKind::MX
672 }
673
674 fn spec_name(&self) -> &str {
675 &self.spec.name
676 }
677
678 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
679 self.status.as_ref()
680 }
681}
682
683impl DiscoverableRecord for crate::crd::NSRecord {
684 fn dns_record_kind() -> crate::crd::DNSRecordKind {
685 crate::crd::DNSRecordKind::NS
686 }
687
688 fn spec_name(&self) -> &str {
689 &self.spec.name
690 }
691
692 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
693 self.status.as_ref()
694 }
695}
696
697impl DiscoverableRecord for crate::crd::SRVRecord {
698 fn dns_record_kind() -> crate::crd::DNSRecordKind {
699 crate::crd::DNSRecordKind::SRV
700 }
701
702 fn spec_name(&self) -> &str {
703 &self.spec.name
704 }
705
706 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
707 self.status.as_ref()
708 }
709}
710
711impl DiscoverableRecord for crate::crd::CAARecord {
712 fn dns_record_kind() -> crate::crd::DNSRecordKind {
713 crate::crd::DNSRecordKind::CAA
714 }
715
716 fn spec_name(&self) -> &str {
717 &self.spec.name
718 }
719
720 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
721 self.status.as_ref()
722 }
723}
724
725impl DiscoverableRecord for crate::crd::PTRRecord {
726 fn dns_record_kind() -> crate::crd::DNSRecordKind {
727 crate::crd::DNSRecordKind::PTR
728 }
729
730 fn spec_name(&self) -> &str {
731 &self.spec.name
732 }
733
734 fn record_status(&self) -> Option<&crate::crd::RecordStatus> {
735 self.status.as_ref()
736 }
737}
738
739async fn discover_records_generic<T>(
763 client: &Client,
764 namespace: &str,
765 selector: &crate::crd::LabelSelector,
766 _zone_name: &str,
767) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>>
768where
769 T: DiscoverableRecord,
770{
771 use std::collections::BTreeMap;
772
773 let api: kube::Api<T> = kube::Api::namespaced(client.clone(), namespace);
774 let records = list_all_paginated(&api, kube::api::ListParams::default()).await?;
775
776 let mut record_refs = Vec::new();
777 for record in records {
778 let labels: BTreeMap<String, String> = record.meta().labels.clone().unwrap_or_default();
779
780 if !selector.matches(&labels) {
781 continue;
782 }
783
784 debug!(
785 "Discovered {} record {}/{}",
786 T::dns_record_kind().as_str(),
787 namespace,
788 record.name_any()
789 );
790
791 let last_reconciled_at = record
793 .record_status()
794 .and_then(|s| s.last_updated.as_ref())
795 .and_then(|ts| {
796 ts.parse::<k8s_openapi::jiff::Timestamp>()
798 .ok()
799 .map(k8s_openapi::apimachinery::pkg::apis::meta::v1::Time)
800 });
801
802 record_refs.push(crate::crd::RecordReferenceWithTimestamp {
803 api_version: "bindy.firestoned.io/v1beta1".to_string(),
804 kind: T::dns_record_kind().as_str().to_string(),
805 name: record.name_any(),
806 namespace: namespace.to_string(),
807 record_name: Some(record.spec_name().to_string()),
808 last_reconciled_at,
809 });
810 }
811
812 Ok(record_refs)
813}
814
815async fn discover_a_records(
817 client: &Client,
818 namespace: &str,
819 selector: &crate::crd::LabelSelector,
820 zone_name: &str,
821) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
822 discover_records_generic::<crate::crd::ARecord>(client, namespace, selector, zone_name).await
823}
824
825async fn discover_aaaa_records(
827 client: &Client,
828 namespace: &str,
829 selector: &crate::crd::LabelSelector,
830 zone_name: &str,
831) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
832 discover_records_generic::<crate::crd::AAAARecord>(client, namespace, selector, zone_name).await
833}
834
835async fn discover_txt_records(
837 client: &Client,
838 namespace: &str,
839 selector: &crate::crd::LabelSelector,
840 zone_name: &str,
841) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
842 discover_records_generic::<crate::crd::TXTRecord>(client, namespace, selector, zone_name).await
843}
844
845async fn discover_cname_records(
847 client: &Client,
848 namespace: &str,
849 selector: &crate::crd::LabelSelector,
850 zone_name: &str,
851) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
852 discover_records_generic::<crate::crd::CNAMERecord>(client, namespace, selector, zone_name)
853 .await
854}
855
856async fn discover_mx_records(
858 client: &Client,
859 namespace: &str,
860 selector: &crate::crd::LabelSelector,
861 zone_name: &str,
862) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
863 discover_records_generic::<crate::crd::MXRecord>(client, namespace, selector, zone_name).await
864}
865
866async fn discover_ns_records(
868 client: &Client,
869 namespace: &str,
870 selector: &crate::crd::LabelSelector,
871 zone_name: &str,
872) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
873 discover_records_generic::<crate::crd::NSRecord>(client, namespace, selector, zone_name).await
874}
875
876async fn discover_srv_records(
878 client: &Client,
879 namespace: &str,
880 selector: &crate::crd::LabelSelector,
881 zone_name: &str,
882) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
883 discover_records_generic::<crate::crd::SRVRecord>(client, namespace, selector, zone_name).await
884}
885
886async fn discover_caa_records(
888 client: &Client,
889 namespace: &str,
890 selector: &crate::crd::LabelSelector,
891 zone_name: &str,
892) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
893 discover_records_generic::<crate::crd::CAARecord>(client, namespace, selector, zone_name).await
894}
895
896async fn discover_ptr_records(
898 client: &Client,
899 namespace: &str,
900 selector: &crate::crd::LabelSelector,
901 zone_name: &str,
902) -> Result<Vec<crate::crd::RecordReferenceWithTimestamp>> {
903 discover_records_generic::<crate::crd::PTRRecord>(client, namespace, selector, zone_name).await
904}
905
906pub async fn check_all_records_ready(
924 client: &Client,
925 namespace: &str,
926 record_refs: &[crate::crd::RecordReferenceWithTimestamp],
927) -> Result<bool> {
928 use crate::crd::{
929 AAAARecord, ARecord, CAARecord, CNAMERecord, DNSRecordKind, MXRecord, NSRecord, PTRRecord,
930 SRVRecord, TXTRecord,
931 };
932
933 for record_ref in record_refs {
934 let kind = DNSRecordKind::try_from(record_ref.kind.as_str())?;
935 let is_ready = match kind {
936 DNSRecordKind::A => {
937 let api: Api<ARecord> = Api::namespaced(client.clone(), namespace);
938 check_record_ready(&api, &record_ref.name).await?
939 }
940 DNSRecordKind::AAAA => {
941 let api: Api<AAAARecord> = Api::namespaced(client.clone(), namespace);
942 check_record_ready(&api, &record_ref.name).await?
943 }
944 DNSRecordKind::TXT => {
945 let api: Api<TXTRecord> = Api::namespaced(client.clone(), namespace);
946 check_record_ready(&api, &record_ref.name).await?
947 }
948 DNSRecordKind::CNAME => {
949 let api: Api<CNAMERecord> = Api::namespaced(client.clone(), namespace);
950 check_record_ready(&api, &record_ref.name).await?
951 }
952 DNSRecordKind::MX => {
953 let api: Api<MXRecord> = Api::namespaced(client.clone(), namespace);
954 check_record_ready(&api, &record_ref.name).await?
955 }
956 DNSRecordKind::NS => {
957 let api: Api<NSRecord> = Api::namespaced(client.clone(), namespace);
958 check_record_ready(&api, &record_ref.name).await?
959 }
960 DNSRecordKind::SRV => {
961 let api: Api<SRVRecord> = Api::namespaced(client.clone(), namespace);
962 check_record_ready(&api, &record_ref.name).await?
963 }
964 DNSRecordKind::CAA => {
965 let api: Api<CAARecord> = Api::namespaced(client.clone(), namespace);
966 check_record_ready(&api, &record_ref.name).await?
967 }
968 DNSRecordKind::PTR => {
969 let api: Api<PTRRecord> = Api::namespaced(client.clone(), namespace);
970 check_record_ready(&api, &record_ref.name).await?
971 }
972 };
973
974 if !is_ready {
975 debug!(
976 "Record {}/{} (kind: {}) is not ready yet",
977 namespace, record_ref.name, record_ref.kind
978 );
979 return Ok(false);
980 }
981 }
982
983 Ok(true)
984}
985
986async fn check_record_ready<T>(api: &Api<T>, name: &str) -> Result<bool>
988where
989 T: kube::Resource<DynamicType = ()>
990 + Clone
991 + serde::de::DeserializeOwned
992 + serde::Serialize
993 + std::fmt::Debug
994 + Send
995 + Sync,
996 <T as kube::Resource>::DynamicType: Default,
997{
998 let record = match api.get(name).await {
999 Ok(r) => r,
1000 Err(e) => {
1001 warn!("Failed to get record {}: {}", name, e);
1002 return Ok(false);
1003 }
1004 };
1005
1006 let record_json = serde_json::to_value(&record)?;
1008 let status = record_json.get("status");
1009
1010 if let Some(status_obj) = status {
1011 if let Some(conditions) = status_obj.get("conditions").and_then(|c| c.as_array()) {
1012 for condition in conditions {
1013 if let (Some(type_val), Some(status_val)) = (
1014 condition.get("type").and_then(|t| t.as_str()),
1015 condition.get("status").and_then(|s| s.as_str()),
1016 ) {
1017 if type_val == "Ready" && status_val == "True" {
1018 return Ok(true);
1019 }
1020 }
1021 }
1022 }
1023 }
1024
1025 Ok(false)
1026}
1027
1028pub async fn find_zones_selecting_record(
1050 client: &Client,
1051 record_namespace: &str,
1052 record_kind: &str,
1053 record_name: &str,
1054) -> Result<Vec<(String, String)>> {
1055 let api: Api<DNSZone> = Api::namespaced(client.clone(), record_namespace);
1056 let zones = list_all_paginated(&api, ListParams::default()).await?;
1057
1058 let mut selecting_zones = vec![];
1059
1060 for zone in zones {
1061 let Some(ref status) = zone.status else {
1062 continue;
1063 };
1064
1065 let is_selected = status
1067 .records
1068 .iter()
1069 .any(|r| r.kind == record_kind && r.name == record_name);
1070
1071 if is_selected {
1072 let zone_name = zone.name_any();
1073 let zone_namespace = zone.namespace().unwrap_or_default();
1074 selecting_zones.push((zone_name, zone_namespace));
1075 }
1076 }
1077
1078 Ok(selecting_zones)
1079}
1080pub async fn discover_and_update_records(
1105 client: &kube::Client,
1106 dnszone: &crate::crd::DNSZone,
1107 status_updater: &mut crate::reconcilers::status::DNSZoneStatusUpdater,
1108 stores: &crate::context::Stores,
1109) -> Result<(Vec<crate::crd::RecordReferenceWithTimestamp>, usize)> {
1110 let spec = &dnszone.spec;
1111
1112 status_updater.set_condition(
1114 "Progressing",
1115 "True",
1116 "RecordsDiscovering",
1117 "Discovering DNS records via label selectors",
1118 );
1119
1120 let record_refs = reconcile_zone_records(client.clone(), dnszone.clone(), stores)
1125 .await
1126 .map_err(|e| {
1127 warn!(
1128 "Failed to discover DNS records for zone {}: {}. Keeping existing status.records for retry.",
1129 spec.zone_name, e
1130 );
1131 e.context(format!(
1132 "Failed to discover DNS records for zone {}",
1133 spec.zone_name
1134 ))
1135 })?;
1136
1137 info!(
1138 "Discovered {} DNS record(s) for zone {} via label selectors",
1139 record_refs.len(),
1140 spec.zone_name
1141 );
1142
1143 let records_count = record_refs.len();
1144
1145 status_updater.set_records(&record_refs);
1147
1148 Ok((record_refs, records_count))
1149}
1150
1151#[cfg(test)]
1152#[path = "discovery_tests.rs"]
1153mod discovery_tests;
1154
1155#[must_use]
1177pub fn zones_configured_on_instance(
1178 zones: &[crate::crd::DNSZone],
1179 instance_namespace: &str,
1180 instance_name: &str,
1181) -> Vec<(String, String)> {
1182 zones
1183 .iter()
1184 .filter_map(|zone| {
1185 let zone_namespace = zone.namespace()?;
1186 let status = zone.status.as_ref()?;
1187
1188 let serves_instance = status
1189 .bind9_instances
1190 .iter()
1191 .any(|inst| inst.name == instance_name && inst.namespace == instance_namespace);
1192
1193 serves_instance.then(|| (zone_namespace, zone.name_any()))
1194 })
1195 .collect()
1196}