1use crate::constants::ANNOTATION_ZONE_OWNER;
15use crate::crd::{
16 AAAARecord, ARecord, CAARecord, CNAMERecord, Condition, DNSZone, MXRecord, NSRecord,
17 RecordStatus, SRVRecord, TXTRecord,
18};
19use anyhow::{anyhow, Context, Result};
20use k8s_openapi::api::core::v1::{Event, ObjectReference};
21use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
22use k8s_openapi::chrono::Utc;
23use kube::{
24 api::{ListParams, Patch, PatchParams, PostParams},
25 client::Client,
26 Api, Resource, ResourceExt,
27};
28use serde_json::json;
29use tracing::{debug, info, warn};
30
31fn get_zone_from_annotation<T: ResourceExt>(record: &T) -> Option<String> {
46 record
47 .annotations()
48 .get(ANNOTATION_ZONE_OWNER)
49 .filter(|zone| !zone.is_empty())
50 .cloned()
51}
52
53async fn get_zone_info(
72 client: &Client,
73 namespace: &str,
74 zone_fqdn: &str,
75) -> Result<(String, String, bool)> {
76 let dns_zones_api: Api<DNSZone> = Api::namespaced(client.clone(), namespace);
77
78 let zones = dns_zones_api.list(&ListParams::default()).await?;
80
81 for zone in zones {
82 if zone.spec.zone_name == zone_fqdn {
83 let (cluster_ref, is_cluster_provider) =
85 if let Some(ref cluster) = zone.spec.cluster_ref {
86 (cluster.clone(), false)
87 } else if let Some(ref provider) = zone.spec.cluster_provider_ref {
88 (provider.clone(), true)
89 } else {
90 return Err(anyhow!(
91 "DNSZone {}/{} has neither clusterRef nor clusterProviderRef",
92 namespace,
93 zone.name_any()
94 ));
95 };
96
97 return Ok((zone_fqdn.to_string(), cluster_ref, is_cluster_provider));
98 }
99 }
100
101 Err(anyhow!(
102 "DNSZone with zoneName '{zone_fqdn}' not found in namespace '{namespace}'"
103 ))
104}
105
106#[allow(clippy::too_many_lines)]
134pub async fn reconcile_a_record(client: Client, record: ARecord) -> Result<()> {
135 let namespace = record.namespace().unwrap_or_default();
136 let name = record.name_any();
137
138 info!("Reconciling ARecord: {}/{}", namespace, name);
139
140 let spec = &record.spec;
141 let current_generation = record.metadata.generation;
142 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
143
144 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
148 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
150 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
151 return Ok(());
152 }
153
154 info!(
155 "A record {}/{} not selected by any DNSZone (no zone annotation)",
156 namespace, name
157 );
158 update_record_status(
159 &client,
160 &record,
161 "Ready",
162 "False",
163 "NotSelected",
164 "Record not selected by any DNSZone label selector",
165 current_generation,
166 )
167 .await?;
168 return Ok(());
169 };
170
171 debug!(
174 "Ensuring A record exists in zone {} (declarative reconciliation)",
175 zone_fqdn
176 );
177
178 let (zone_name, cluster_ref, is_cluster_provider) =
180 match get_zone_info(&client, &namespace, &zone_fqdn).await {
181 Ok(info) => info,
182 Err(e) => {
183 warn!(
184 "Failed to find DNSZone for {} in {}/{}: {}",
185 zone_fqdn, namespace, name, e
186 );
187 update_record_status(
188 &client,
189 &record,
190 "Ready",
191 "False",
192 "ZoneNotFound",
193 &format!("DNSZone '{zone_fqdn}' not found: {e}"),
194 current_generation,
195 )
196 .await?;
197 return Ok(());
198 }
199 };
200
201 let zone_manager = crate::bind9::Bind9Manager::new();
203
204 match add_a_record_to_zone(
205 &client,
206 &namespace,
207 &zone_name,
208 &cluster_ref,
209 is_cluster_provider,
210 &spec.name,
211 &spec.ipv4_address,
212 spec.ttl,
213 &zone_manager,
214 )
215 .await
216 {
217 Ok(()) => {
218 info!(
219 "Successfully added A record {} to zone {} in cluster {}",
220 spec.name, zone_name, cluster_ref
221 );
222 update_record_status(
223 &client,
224 &record,
225 "Ready",
226 "True",
227 "ReconcileSucceeded",
228 &format!("A record added to zone {zone_name}"),
229 current_generation,
230 )
231 .await?;
232 }
233 Err(e) => {
234 warn!(
235 "Failed to add A record {} to zone {}: {}",
236 spec.name, zone_name, e
237 );
238 update_record_status(
239 &client,
240 &record,
241 "Ready",
242 "False",
243 "ReconcileFailed",
244 &format!("Failed to add record to zone {zone_name}: {e}"),
245 current_generation,
246 )
247 .await?;
248 }
249 }
250
251 Ok(())
252}
253
254#[allow(clippy::too_many_arguments)]
275async fn add_a_record_to_zone(
276 client: &Client,
277 namespace: &str,
278 zone_name: &str,
279 cluster_ref: &str,
280 is_cluster_provider: bool,
281 record_name: &str,
282 ipv4_address: &str,
283 ttl: Option<i32>,
284 zone_manager: &crate::bind9::Bind9Manager,
285) -> Result<()> {
286 use crate::reconcilers::dnszone::for_each_primary_endpoint;
287
288 let (_first, _total) = for_each_primary_endpoint(
289 client,
290 namespace,
291 cluster_ref,
292 is_cluster_provider,
293 true, "dns-tcp", |pod_endpoint, instance_name, rndc_key| {
296 let zone_name = zone_name.to_string();
297 let record_name = record_name.to_string();
298 let ipv4_address = ipv4_address.to_string();
299 let zone_manager = zone_manager.clone();
300
301 async move {
302 let key_data = rndc_key.expect("RNDC key should be loaded");
303
304 zone_manager
305 .add_a_record(
306 &zone_name,
307 &record_name,
308 &ipv4_address,
309 ttl,
310 &pod_endpoint,
311 &key_data,
312 )
313 .await
314 .context(format!(
315 "Failed to add A record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
316 ))?;
317
318 Ok(())
319 }
320 },
321 )
322 .await?;
323
324 Ok(())
325}
326
327#[allow(clippy::too_many_lines)]
337pub async fn reconcile_txt_record(client: Client, record: TXTRecord) -> Result<()> {
338 let namespace = record.namespace().unwrap_or_default();
339 let name = record.name_any();
340
341 info!("Reconciling TXTRecord: {}/{}", namespace, name);
342
343 let spec = &record.spec;
344 let current_generation = record.metadata.generation;
345 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
346
347 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
351 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
353 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
354 return Ok(());
355 }
356
357 info!(
358 "TXT record {}/{} not selected by any DNSZone",
359 namespace, name
360 );
361 update_record_status(
362 &client,
363 &record,
364 "Ready",
365 "False",
366 "NotSelected",
367 "Record not selected by any DNSZone label selector",
368 current_generation,
369 )
370 .await?;
371 return Ok(());
372 };
373
374 debug!(
377 "Ensuring record exists in zone {} (declarative reconciliation)",
378 zone_fqdn
379 );
380
381 let (zone_name, cluster_ref, is_cluster_provider) =
383 match get_zone_info(&client, &namespace, &zone_fqdn).await {
384 Ok(info) => info,
385 Err(e) => {
386 warn!(
387 "Failed to find DNSZone {} for TXT record {}/{}: {}",
388 zone_fqdn, namespace, name, e
389 );
390 update_record_status(
391 &client,
392 &record,
393 "Ready",
394 "False",
395 "ZoneNotFound",
396 &format!("DNSZone {zone_fqdn} not found: {e}"),
397 current_generation,
398 )
399 .await?;
400 return Ok(());
401 }
402 };
403
404 let zone_manager = crate::bind9::Bind9Manager::new();
406
407 match add_txt_record_to_zone(
408 &client,
409 &namespace,
410 &zone_name,
411 &cluster_ref,
412 is_cluster_provider,
413 &spec.name,
414 &spec.text,
415 spec.ttl,
416 &zone_manager,
417 )
418 .await
419 {
420 Ok(()) => {
421 info!(
422 "Successfully added TXT record {} to zone {} in cluster {}",
423 spec.name, zone_name, cluster_ref
424 );
425 update_record_status(
426 &client,
427 &record,
428 "Ready",
429 "True",
430 "RecordAvailable",
431 &format!(
432 "TXT record {} successfully added to zone {zone_name}",
433 spec.name
434 ),
435 current_generation,
436 )
437 .await?;
438 }
439 Err(e) => {
440 warn!(
441 "Failed to add TXT record {} to zone {}: {}",
442 spec.name, zone_name, e
443 );
444 update_record_status(
445 &client,
446 &record,
447 "Ready",
448 "False",
449 "ReconcileFailed",
450 &format!("Failed to add TXT record to zone {zone_name}: {e}"),
451 current_generation,
452 )
453 .await?;
454 }
455 }
456
457 Ok(())
458}
459
460#[allow(clippy::too_many_arguments)]
462async fn add_txt_record_to_zone(
463 client: &Client,
464 namespace: &str,
465 zone_name: &str,
466 cluster_ref: &str,
467 is_cluster_provider: bool,
468 record_name: &str,
469 text: &[String],
470 ttl: Option<i32>,
471 zone_manager: &crate::bind9::Bind9Manager,
472) -> Result<()> {
473 use crate::reconcilers::dnszone::for_each_primary_endpoint;
474
475 let (_first, _total) = for_each_primary_endpoint(
476 client,
477 namespace,
478 cluster_ref,
479 is_cluster_provider,
480 true,
481 "dns-tcp",
482 |pod_endpoint, instance_name, rndc_key| {
483 let zone_name = zone_name.to_string();
484 let record_name = record_name.to_string();
485 let text = text.to_vec();
486 let zone_manager = zone_manager.clone();
487
488 async move {
489 let key_data = rndc_key.expect("RNDC key should be loaded");
490
491 zone_manager
492 .add_txt_record(
493 &zone_name,
494 &record_name,
495 &text,
496 ttl,
497 &pod_endpoint,
498 &key_data,
499 )
500 .await
501 .context(format!(
502 "Failed to add TXT record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
503 ))?;
504
505 Ok(())
506 }
507 },
508 )
509 .await?;
510
511 Ok(())
512}
513
514#[allow(clippy::too_many_lines)]
523pub async fn reconcile_aaaa_record(client: Client, record: AAAARecord) -> Result<()> {
524 let namespace = record.namespace().unwrap_or_default();
525 let name = record.name_any();
526
527 info!("Reconciling AAAARecord: {}/{}", namespace, name);
528
529 let spec = &record.spec;
530 let current_generation = record.metadata.generation;
531 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
532
533 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
537 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
539 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
540 return Ok(());
541 }
542
543 info!(
544 "AAAA record {}/{} not selected by any DNSZone (no zone annotation)",
545 namespace, name
546 );
547 update_record_status(
548 &client,
549 &record,
550 "Ready",
551 "False",
552 "NotSelected",
553 "Record not selected by any DNSZone label selector",
554 current_generation,
555 )
556 .await?;
557 return Ok(());
558 };
559
560 debug!(
563 "Ensuring record exists in zone {} (declarative reconciliation)",
564 zone_fqdn
565 );
566
567 let (zone_name, cluster_ref, is_cluster_provider) =
569 match get_zone_info(&client, &namespace, &zone_fqdn).await {
570 Ok(info) => info,
571 Err(e) => {
572 warn!(
573 "Failed to find DNSZone for {} in {}/{}: {}",
574 zone_fqdn, namespace, name, e
575 );
576 update_record_status(
577 &client,
578 &record,
579 "Ready",
580 "False",
581 "ZoneNotFound",
582 &format!("DNSZone '{zone_fqdn}' not found: {e}"),
583 current_generation,
584 )
585 .await?;
586 return Ok(());
587 }
588 };
589
590 let zone_manager = crate::bind9::Bind9Manager::new();
592
593 match add_aaaa_record_to_zone(
594 &client,
595 &namespace,
596 &zone_name,
597 &cluster_ref,
598 is_cluster_provider,
599 &spec.name,
600 &spec.ipv6_address,
601 spec.ttl,
602 &zone_manager,
603 )
604 .await
605 {
606 Ok(()) => {
607 info!(
608 "Successfully added AAAA record {} to zone {} in cluster {}",
609 spec.name, zone_name, cluster_ref
610 );
611 update_record_status(
612 &client,
613 &record,
614 "Ready",
615 "True",
616 "ReconcileSucceeded",
617 &format!("AAAA record added to zone {zone_name}"),
618 current_generation,
619 )
620 .await?;
621 }
622 Err(e) => {
623 warn!(
624 "Failed to add AAAA record {} to zone {}: {}",
625 spec.name, zone_name, e
626 );
627 update_record_status(
628 &client,
629 &record,
630 "Ready",
631 "False",
632 "ReconcileFailed",
633 &format!("Failed to add record to zone {zone_name}: {e}"),
634 current_generation,
635 )
636 .await?;
637 }
638 }
639
640 Ok(())
641}
642
643#[allow(clippy::too_many_arguments)]
645async fn add_aaaa_record_to_zone(
646 client: &Client,
647 namespace: &str,
648 zone_name: &str,
649 cluster_ref: &str,
650 is_cluster_provider: bool,
651 record_name: &str,
652 ipv6_address: &str,
653 ttl: Option<i32>,
654 zone_manager: &crate::bind9::Bind9Manager,
655) -> Result<()> {
656 use crate::reconcilers::dnszone::for_each_primary_endpoint;
657
658 let (_first, _total) = for_each_primary_endpoint(
659 client,
660 namespace,
661 cluster_ref,
662 is_cluster_provider,
663 true,
664 "dns-tcp",
665 |pod_endpoint, instance_name, rndc_key| {
666 let zone_name = zone_name.to_string();
667 let record_name = record_name.to_string();
668 let ipv6_address = ipv6_address.to_string();
669 let zone_manager = zone_manager.clone();
670
671 async move {
672 let key_data = rndc_key.expect("RNDC key should be loaded");
673
674 zone_manager
675 .add_aaaa_record(
676 &zone_name,
677 &record_name,
678 &ipv6_address,
679 ttl,
680 &pod_endpoint,
681 &key_data,
682 )
683 .await
684 .context(format!(
685 "Failed to add AAAA record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
686 ))?;
687
688 Ok(())
689 }
690 },
691 )
692 .await?;
693
694 Ok(())
695}
696
697#[allow(clippy::too_many_lines)]
706pub async fn reconcile_cname_record(client: Client, record: CNAMERecord) -> Result<()> {
707 let namespace = record.namespace().unwrap_or_default();
708 let name = record.name_any();
709
710 info!("Reconciling CNAMERecord: {}/{}", namespace, name);
711
712 let spec = &record.spec;
713 let current_generation = record.metadata.generation;
714 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
715
716 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
720 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
722 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
723 return Ok(());
724 }
725
726 info!(
727 "CNAME record {}/{} not selected by any DNSZone",
728 namespace, name
729 );
730 update_record_status(
731 &client,
732 &record,
733 "Ready",
734 "False",
735 "NotSelected",
736 "Record not selected by any DNSZone label selector",
737 current_generation,
738 )
739 .await?;
740 return Ok(());
741 };
742
743 debug!(
746 "Ensuring record exists in zone {} (declarative reconciliation)",
747 zone_fqdn
748 );
749
750 let (zone_name, cluster_ref, is_cluster_provider) =
752 match get_zone_info(&client, &namespace, &zone_fqdn).await {
753 Ok(info) => info,
754 Err(e) => {
755 warn!(
756 "Failed to find DNSZone {} for CNAME record {}/{}: {}",
757 zone_fqdn, namespace, name, e
758 );
759 update_record_status(
760 &client,
761 &record,
762 "Ready",
763 "False",
764 "ZoneNotFound",
765 &format!("DNSZone {zone_fqdn} not found: {e}"),
766 current_generation,
767 )
768 .await?;
769 return Ok(());
770 }
771 };
772
773 let zone_manager = crate::bind9::Bind9Manager::new();
775
776 match add_cname_record_to_zone(
777 &client,
778 &namespace,
779 &zone_name,
780 &cluster_ref,
781 is_cluster_provider,
782 &spec.name,
783 &spec.target,
784 spec.ttl,
785 &zone_manager,
786 )
787 .await
788 {
789 Ok(()) => {
790 info!(
791 "Successfully added CNAME record {} to zone {} in cluster {}",
792 spec.name, zone_name, cluster_ref
793 );
794 update_record_status(
795 &client,
796 &record,
797 "Ready",
798 "True",
799 "RecordAvailable",
800 &format!(
801 "CNAME record {} successfully added to zone {zone_name}",
802 spec.name
803 ),
804 current_generation,
805 )
806 .await?;
807 }
808 Err(e) => {
809 warn!(
810 "Failed to add CNAME record {} to zone {}: {}",
811 spec.name, zone_name, e
812 );
813 update_record_status(
814 &client,
815 &record,
816 "Ready",
817 "False",
818 "ReconcileFailed",
819 &format!("Failed to add CNAME record to zone {zone_name}: {e}"),
820 current_generation,
821 )
822 .await?;
823 }
824 }
825
826 Ok(())
827}
828
829#[allow(clippy::too_many_arguments)]
831async fn add_cname_record_to_zone(
832 client: &Client,
833 namespace: &str,
834 zone_name: &str,
835 cluster_ref: &str,
836 is_cluster_provider: bool,
837 record_name: &str,
838 target: &str,
839 ttl: Option<i32>,
840 zone_manager: &crate::bind9::Bind9Manager,
841) -> Result<()> {
842 use crate::reconcilers::dnszone::for_each_primary_endpoint;
843
844 let (_first, _total) = for_each_primary_endpoint(
845 client,
846 namespace,
847 cluster_ref,
848 is_cluster_provider,
849 true,
850 "dns-tcp",
851 |pod_endpoint, instance_name, rndc_key| {
852 let zone_name = zone_name.to_string();
853 let record_name = record_name.to_string();
854 let target = target.to_string();
855 let zone_manager = zone_manager.clone();
856
857 async move {
858 let key_data = rndc_key.expect("RNDC key should be loaded");
859
860 zone_manager
861 .add_cname_record(
862 &zone_name,
863 &record_name,
864 &target,
865 ttl,
866 &pod_endpoint,
867 &key_data,
868 )
869 .await
870 .context(format!(
871 "Failed to add CNAME record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
872 ))?;
873
874 Ok(())
875 }
876 },
877 )
878 .await?;
879
880 Ok(())
881}
882
883#[allow(clippy::too_many_lines)]
893pub async fn reconcile_mx_record(client: Client, record: MXRecord) -> Result<()> {
894 let namespace = record.namespace().unwrap_or_default();
895 let name = record.name_any();
896
897 info!("Reconciling MXRecord: {}/{}", namespace, name);
898
899 let spec = &record.spec;
900 let current_generation = record.metadata.generation;
901 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
902
903 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
907 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
909 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
910 return Ok(());
911 }
912
913 info!(
914 "MX record {}/{} not selected by any DNSZone",
915 namespace, name
916 );
917 update_record_status(
918 &client,
919 &record,
920 "Ready",
921 "False",
922 "NotSelected",
923 "Record not selected by any DNSZone label selector",
924 current_generation,
925 )
926 .await?;
927 return Ok(());
928 };
929
930 debug!(
933 "Ensuring record exists in zone {} (declarative reconciliation)",
934 zone_fqdn
935 );
936
937 let (zone_name, cluster_ref, is_cluster_provider) =
939 match get_zone_info(&client, &namespace, &zone_fqdn).await {
940 Ok(info) => info,
941 Err(e) => {
942 warn!(
943 "Failed to find DNSZone {} for MX record {}/{}: {}",
944 zone_fqdn, namespace, name, e
945 );
946 update_record_status(
947 &client,
948 &record,
949 "Ready",
950 "False",
951 "ZoneNotFound",
952 &format!("DNSZone {zone_fqdn} not found: {e}"),
953 current_generation,
954 )
955 .await?;
956 return Ok(());
957 }
958 };
959
960 let zone_manager = crate::bind9::Bind9Manager::new();
962
963 match add_mx_record_to_zone(
964 &client,
965 &namespace,
966 &zone_name,
967 &cluster_ref,
968 is_cluster_provider,
969 &spec.name,
970 spec.priority,
971 &spec.mail_server,
972 spec.ttl,
973 &zone_manager,
974 )
975 .await
976 {
977 Ok(()) => {
978 info!(
979 "Successfully added MX record {} to zone {} in cluster {}",
980 spec.name, zone_name, cluster_ref
981 );
982 update_record_status(
983 &client,
984 &record,
985 "Ready",
986 "True",
987 "RecordAvailable",
988 &format!(
989 "MX record {} successfully added to zone {zone_name}",
990 spec.name
991 ),
992 current_generation,
993 )
994 .await?;
995 }
996 Err(e) => {
997 warn!(
998 "Failed to add MX record {} to zone {}: {}",
999 spec.name, zone_name, e
1000 );
1001 update_record_status(
1002 &client,
1003 &record,
1004 "Ready",
1005 "False",
1006 "ReconcileFailed",
1007 &format!("Failed to add MX record to zone {zone_name}: {e}"),
1008 current_generation,
1009 )
1010 .await?;
1011 }
1012 }
1013
1014 Ok(())
1015}
1016
1017#[allow(clippy::too_many_arguments)]
1019async fn add_mx_record_to_zone(
1020 client: &Client,
1021 namespace: &str,
1022 zone_name: &str,
1023 cluster_ref: &str,
1024 is_cluster_provider: bool,
1025 record_name: &str,
1026 priority: i32,
1027 mail_server: &str,
1028 ttl: Option<i32>,
1029 zone_manager: &crate::bind9::Bind9Manager,
1030) -> Result<()> {
1031 use crate::reconcilers::dnszone::for_each_primary_endpoint;
1032
1033 let (_first, _total) = for_each_primary_endpoint(
1034 client,
1035 namespace,
1036 cluster_ref,
1037 is_cluster_provider,
1038 true,
1039 "dns-tcp",
1040 |pod_endpoint, instance_name, rndc_key| {
1041 let zone_name = zone_name.to_string();
1042 let record_name = record_name.to_string();
1043 let mail_server = mail_server.to_string();
1044 let zone_manager = zone_manager.clone();
1045
1046 async move {
1047 let key_data = rndc_key.expect("RNDC key should be loaded");
1048
1049 zone_manager
1050 .add_mx_record(
1051 &zone_name,
1052 &record_name,
1053 priority,
1054 &mail_server,
1055 ttl,
1056 &pod_endpoint,
1057 &key_data,
1058 )
1059 .await
1060 .context(format!(
1061 "Failed to add MX record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1062 ))?;
1063
1064 Ok(())
1065 }
1066 },
1067 )
1068 .await?;
1069
1070 Ok(())
1071}
1072
1073#[allow(clippy::too_many_lines)]
1083pub async fn reconcile_ns_record(client: Client, record: NSRecord) -> Result<()> {
1084 let namespace = record.namespace().unwrap_or_default();
1085 let name = record.name_any();
1086
1087 info!("Reconciling NSRecord: {}/{}", namespace, name);
1088
1089 let spec = &record.spec;
1090 let current_generation = record.metadata.generation;
1091 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1092
1093 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1097 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1099 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1100 return Ok(());
1101 }
1102
1103 info!(
1104 "NS record {}/{} not selected by any DNSZone",
1105 namespace, name
1106 );
1107 update_record_status(
1108 &client,
1109 &record,
1110 "Ready",
1111 "False",
1112 "NotSelected",
1113 "Record not selected by any DNSZone label selector",
1114 current_generation,
1115 )
1116 .await?;
1117 return Ok(());
1118 };
1119
1120 debug!(
1123 "Ensuring record exists in zone {} (declarative reconciliation)",
1124 zone_fqdn
1125 );
1126
1127 let (zone_name, cluster_ref, is_cluster_provider) =
1129 match get_zone_info(&client, &namespace, &zone_fqdn).await {
1130 Ok(info) => info,
1131 Err(e) => {
1132 warn!(
1133 "Failed to find DNSZone {} for NS record {}/{}: {}",
1134 zone_fqdn, namespace, name, e
1135 );
1136 update_record_status(
1137 &client,
1138 &record,
1139 "Ready",
1140 "False",
1141 "ZoneNotFound",
1142 &format!("DNSZone {zone_fqdn} not found: {e}"),
1143 current_generation,
1144 )
1145 .await?;
1146 return Ok(());
1147 }
1148 };
1149
1150 let zone_manager = crate::bind9::Bind9Manager::new();
1152
1153 match add_ns_record_to_zone(
1154 &client,
1155 &namespace,
1156 &zone_name,
1157 &cluster_ref,
1158 is_cluster_provider,
1159 &spec.name,
1160 &spec.nameserver,
1161 spec.ttl,
1162 &zone_manager,
1163 )
1164 .await
1165 {
1166 Ok(()) => {
1167 info!(
1168 "Successfully added NS record {} to zone {} in cluster {}",
1169 spec.name, zone_name, cluster_ref
1170 );
1171 update_record_status(
1172 &client,
1173 &record,
1174 "Ready",
1175 "True",
1176 "RecordAvailable",
1177 &format!(
1178 "NS record {} successfully added to zone {zone_name}",
1179 spec.name
1180 ),
1181 current_generation,
1182 )
1183 .await?;
1184 }
1185 Err(e) => {
1186 warn!(
1187 "Failed to add NS record {} to zone {}: {}",
1188 spec.name, zone_name, e
1189 );
1190 update_record_status(
1191 &client,
1192 &record,
1193 "Ready",
1194 "False",
1195 "ReconcileFailed",
1196 &format!("Failed to add NS record to zone {zone_name}: {e}"),
1197 current_generation,
1198 )
1199 .await?;
1200 }
1201 }
1202
1203 Ok(())
1204}
1205
1206#[allow(clippy::too_many_arguments)]
1208async fn add_ns_record_to_zone(
1209 client: &Client,
1210 namespace: &str,
1211 zone_name: &str,
1212 cluster_ref: &str,
1213 is_cluster_provider: bool,
1214 record_name: &str,
1215 nameserver: &str,
1216 ttl: Option<i32>,
1217 zone_manager: &crate::bind9::Bind9Manager,
1218) -> Result<()> {
1219 use crate::reconcilers::dnszone::for_each_primary_endpoint;
1220
1221 let (_first, _total) = for_each_primary_endpoint(
1222 client,
1223 namespace,
1224 cluster_ref,
1225 is_cluster_provider,
1226 true,
1227 "dns-tcp",
1228 |pod_endpoint, instance_name, rndc_key| {
1229 let zone_name = zone_name.to_string();
1230 let record_name = record_name.to_string();
1231 let nameserver = nameserver.to_string();
1232 let zone_manager = zone_manager.clone();
1233
1234 async move {
1235 let key_data = rndc_key.expect("RNDC key should be loaded");
1236
1237 zone_manager
1238 .add_ns_record(
1239 &zone_name,
1240 &record_name,
1241 &nameserver,
1242 ttl,
1243 &pod_endpoint,
1244 &key_data,
1245 )
1246 .await
1247 .context(format!(
1248 "Failed to add NS record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1249 ))?;
1250
1251 Ok(())
1252 }
1253 },
1254 )
1255 .await?;
1256
1257 Ok(())
1258}
1259
1260#[allow(clippy::too_many_lines)]
1270pub async fn reconcile_srv_record(client: Client, record: SRVRecord) -> Result<()> {
1271 let namespace = record.namespace().unwrap_or_default();
1272 let name = record.name_any();
1273
1274 info!("Reconciling SRVRecord: {}/{}", namespace, name);
1275
1276 let spec = &record.spec;
1277 let current_generation = record.metadata.generation;
1278 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1279
1280 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1284 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1286 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1287 return Ok(());
1288 }
1289
1290 info!(
1291 "SRV record {}/{} not selected by any DNSZone",
1292 namespace, name
1293 );
1294 update_record_status(
1295 &client,
1296 &record,
1297 "Ready",
1298 "False",
1299 "NotSelected",
1300 "Record not selected by any DNSZone label selector",
1301 current_generation,
1302 )
1303 .await?;
1304 return Ok(());
1305 };
1306
1307 debug!(
1310 "Ensuring record exists in zone {} (declarative reconciliation)",
1311 zone_fqdn
1312 );
1313
1314 let (zone_name, cluster_ref, is_cluster_provider) =
1316 match get_zone_info(&client, &namespace, &zone_fqdn).await {
1317 Ok(info) => info,
1318 Err(e) => {
1319 warn!(
1320 "Failed to find DNSZone {} for SRV record {}/{}: {}",
1321 zone_fqdn, namespace, name, e
1322 );
1323 update_record_status(
1324 &client,
1325 &record,
1326 "Ready",
1327 "False",
1328 "ZoneNotFound",
1329 &format!("DNSZone {zone_fqdn} not found: {e}"),
1330 current_generation,
1331 )
1332 .await?;
1333 return Ok(());
1334 }
1335 };
1336
1337 let zone_manager = crate::bind9::Bind9Manager::new();
1339
1340 match add_srv_record_to_zone(
1341 &client,
1342 &namespace,
1343 &zone_name,
1344 &cluster_ref,
1345 is_cluster_provider,
1346 &spec.name,
1347 spec.priority,
1348 spec.weight,
1349 spec.port,
1350 &spec.target,
1351 spec.ttl,
1352 &zone_manager,
1353 )
1354 .await
1355 {
1356 Ok(()) => {
1357 info!(
1358 "Successfully added SRV record {} to zone {} in cluster {}",
1359 spec.name, zone_name, cluster_ref
1360 );
1361 update_record_status(
1362 &client,
1363 &record,
1364 "Ready",
1365 "True",
1366 "RecordAvailable",
1367 &format!(
1368 "SRV record {} successfully added to zone {zone_name}",
1369 spec.name
1370 ),
1371 current_generation,
1372 )
1373 .await?;
1374 }
1375 Err(e) => {
1376 warn!(
1377 "Failed to add SRV record {} to zone {}: {}",
1378 spec.name, zone_name, e
1379 );
1380 update_record_status(
1381 &client,
1382 &record,
1383 "Ready",
1384 "False",
1385 "ReconcileFailed",
1386 &format!("Failed to add SRV record to zone {zone_name}: {e}"),
1387 current_generation,
1388 )
1389 .await?;
1390 }
1391 }
1392
1393 Ok(())
1394}
1395
1396#[allow(clippy::too_many_arguments)]
1398async fn add_srv_record_to_zone(
1399 client: &Client,
1400 namespace: &str,
1401 zone_name: &str,
1402 cluster_ref: &str,
1403 is_cluster_provider: bool,
1404 record_name: &str,
1405 priority: i32,
1406 weight: i32,
1407 port: i32,
1408 target: &str,
1409 ttl: Option<i32>,
1410 zone_manager: &crate::bind9::Bind9Manager,
1411) -> Result<()> {
1412 use crate::bind9::types::SRVRecordData;
1413 use crate::reconcilers::dnszone::for_each_primary_endpoint;
1414
1415 let (_first, _total) = for_each_primary_endpoint(
1416 client,
1417 namespace,
1418 cluster_ref,
1419 is_cluster_provider,
1420 true,
1421 "dns-tcp",
1422 |pod_endpoint, instance_name, rndc_key| {
1423 let zone_name = zone_name.to_string();
1424 let record_name = record_name.to_string();
1425 let srv_data = SRVRecordData {
1426 priority,
1427 weight,
1428 port,
1429 target: target.to_string(),
1430 ttl,
1431 };
1432 let zone_manager = zone_manager.clone();
1433
1434 async move {
1435 let key_data = rndc_key.expect("RNDC key should be loaded");
1436
1437 zone_manager
1438 .add_srv_record(
1439 &zone_name,
1440 &record_name,
1441 &srv_data,
1442 &pod_endpoint,
1443 &key_data,
1444 )
1445 .await
1446 .context(format!(
1447 "Failed to add SRV record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1448 ))?;
1449
1450 Ok(())
1451 }
1452 },
1453 )
1454 .await?;
1455
1456 Ok(())
1457}
1458
1459#[allow(clippy::too_many_lines)]
1469pub async fn reconcile_caa_record(client: Client, record: CAARecord) -> Result<()> {
1470 let namespace = record.namespace().unwrap_or_default();
1471 let name = record.name_any();
1472
1473 info!("Reconciling CAARecord: {}/{}", namespace, name);
1474
1475 let spec = &record.spec;
1476 let current_generation = record.metadata.generation;
1477 let observed_generation = record.status.as_ref().and_then(|s| s.observed_generation);
1478
1479 let Some(zone_fqdn) = get_zone_from_annotation(&record) else {
1483 if !crate::reconcilers::should_reconcile(current_generation, observed_generation) {
1485 debug!("Spec unchanged and no zone annotation, skipping reconciliation");
1486 return Ok(());
1487 }
1488
1489 info!(
1490 "CAA record {}/{} not selected by any DNSZone",
1491 namespace, name
1492 );
1493 update_record_status(
1494 &client,
1495 &record,
1496 "Ready",
1497 "False",
1498 "NotSelected",
1499 "Record not selected by any DNSZone label selector",
1500 current_generation,
1501 )
1502 .await?;
1503 return Ok(());
1504 };
1505
1506 debug!(
1509 "Ensuring record exists in zone {} (declarative reconciliation)",
1510 zone_fqdn
1511 );
1512
1513 let (zone_name, cluster_ref, is_cluster_provider) =
1515 match get_zone_info(&client, &namespace, &zone_fqdn).await {
1516 Ok(info) => info,
1517 Err(e) => {
1518 warn!(
1519 "Failed to find DNSZone {} for CAA record {}/{}: {}",
1520 zone_fqdn, namespace, name, e
1521 );
1522 update_record_status(
1523 &client,
1524 &record,
1525 "Ready",
1526 "False",
1527 "ZoneNotFound",
1528 &format!("DNSZone {zone_fqdn} not found: {e}"),
1529 current_generation,
1530 )
1531 .await?;
1532 return Ok(());
1533 }
1534 };
1535
1536 let zone_manager = crate::bind9::Bind9Manager::new();
1538
1539 match add_caa_record_to_zone(
1540 &client,
1541 &namespace,
1542 &zone_name,
1543 &cluster_ref,
1544 is_cluster_provider,
1545 &spec.name,
1546 spec.flags,
1547 &spec.tag,
1548 &spec.value,
1549 spec.ttl,
1550 &zone_manager,
1551 )
1552 .await
1553 {
1554 Ok(()) => {
1555 info!(
1556 "Successfully added CAA record {} to zone {} in cluster {}",
1557 spec.name, zone_name, cluster_ref
1558 );
1559 update_record_status(
1560 &client,
1561 &record,
1562 "Ready",
1563 "True",
1564 "RecordAvailable",
1565 &format!(
1566 "CAA record {} successfully added to zone {zone_name}",
1567 spec.name
1568 ),
1569 current_generation,
1570 )
1571 .await?;
1572 }
1573 Err(e) => {
1574 warn!(
1575 "Failed to add CAA record {} to zone {}: {}",
1576 spec.name, zone_name, e
1577 );
1578 update_record_status(
1579 &client,
1580 &record,
1581 "Ready",
1582 "False",
1583 "ReconcileFailed",
1584 &format!("Failed to add CAA record to zone {zone_name}: {e}"),
1585 current_generation,
1586 )
1587 .await?;
1588 }
1589 }
1590
1591 Ok(())
1592}
1593
1594#[allow(clippy::too_many_arguments)]
1596async fn add_caa_record_to_zone(
1597 client: &Client,
1598 namespace: &str,
1599 zone_name: &str,
1600 cluster_ref: &str,
1601 is_cluster_provider: bool,
1602 record_name: &str,
1603 flags: i32,
1604 tag: &str,
1605 value: &str,
1606 ttl: Option<i32>,
1607 zone_manager: &crate::bind9::Bind9Manager,
1608) -> Result<()> {
1609 use crate::reconcilers::dnszone::for_each_primary_endpoint;
1610
1611 let (_first, _total) = for_each_primary_endpoint(
1612 client,
1613 namespace,
1614 cluster_ref,
1615 is_cluster_provider,
1616 true,
1617 "dns-tcp",
1618 |pod_endpoint, instance_name, rndc_key| {
1619 let zone_name = zone_name.to_string();
1620 let record_name = record_name.to_string();
1621 let tag = tag.to_string();
1622 let value = value.to_string();
1623 let zone_manager = zone_manager.clone();
1624
1625 async move {
1626 let key_data = rndc_key.expect("RNDC key should be loaded");
1627
1628 zone_manager
1629 .add_caa_record(
1630 &zone_name,
1631 &record_name,
1632 flags,
1633 &tag,
1634 &value,
1635 ttl,
1636 &pod_endpoint,
1637 &key_data,
1638 )
1639 .await
1640 .context(format!(
1641 "Failed to add CAA record {record_name}.{zone_name} to primary {pod_endpoint} (instance: {instance_name})"
1642 ))?;
1643
1644 Ok(())
1645 }
1646 },
1647 )
1648 .await?;
1649
1650 Ok(())
1651}
1652
1653async fn create_event<T>(
1663 client: &Client,
1664 record: &T,
1665 event_type: &str,
1666 reason: &str,
1667 message: &str,
1668) -> Result<()>
1669where
1670 T: Resource<DynamicType = ()> + ResourceExt,
1671{
1672 let namespace = record.namespace().unwrap_or_default();
1673 let name = record.name_any();
1674 let event_api: Api<Event> = Api::namespaced(client.clone(), &namespace);
1675
1676 let now = Time(Utc::now());
1677 let event = Event {
1678 metadata: k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta {
1679 generate_name: Some(format!("{name}-")),
1680 namespace: Some(namespace.clone()),
1681 ..Default::default()
1682 },
1683 involved_object: ObjectReference {
1684 api_version: Some(T::api_version(&()).to_string()),
1685 kind: Some(T::kind(&()).to_string()),
1686 name: Some(name.clone()),
1687 namespace: Some(namespace),
1688 uid: record.meta().uid.clone(),
1689 ..Default::default()
1690 },
1691 reason: Some(reason.to_string()),
1692 message: Some(message.to_string()),
1693 type_: Some(event_type.to_string()),
1694 first_timestamp: Some(now.clone()),
1695 last_timestamp: Some(now),
1696 count: Some(1),
1697 ..Default::default()
1698 };
1699
1700 match event_api.create(&PostParams::default(), &event).await {
1701 Ok(_) => Ok(()),
1702 Err(e) => {
1703 warn!("Failed to create event for {}: {}", name, e);
1704 Ok(()) }
1706 }
1707}
1708
1709#[allow(clippy::too_many_lines)]
1728async fn update_record_status<T>(
1729 client: &Client,
1730 record: &T,
1731 condition_type: &str,
1732 status: &str,
1733 reason: &str,
1734 message: &str,
1735 observed_generation: Option<i64>,
1736) -> Result<()>
1737where
1738 T: Resource<DynamicType = (), Scope = k8s_openapi::NamespaceResourceScope>
1739 + ResourceExt
1740 + Clone
1741 + std::fmt::Debug
1742 + serde::Serialize
1743 + for<'de> serde::Deserialize<'de>,
1744{
1745 let namespace = record.namespace().unwrap_or_default();
1746 let name = record.name_any();
1747 let api: Api<T> = Api::namespaced(client.clone(), &namespace);
1748
1749 let current = api
1751 .get(&name)
1752 .await
1753 .context("Failed to fetch current resource")?;
1754
1755 let current_json = serde_json::to_value(¤t)?;
1758 let needs_update = if let Some(current_status) = current_json.get("status") {
1759 if let Some(observed_gen) = current_status.get("observedGeneration") {
1760 if observed_gen == &json!(record.meta().generation) {
1762 if let Some(conditions) =
1763 current_status.get("conditions").and_then(|c| c.as_array())
1764 {
1765 let matching_condition = conditions.iter().find(|cond| {
1767 cond.get("type").and_then(|t| t.as_str()) == Some(condition_type)
1768 });
1769
1770 if let Some(cond) = matching_condition {
1771 let status_matches =
1772 cond.get("status").and_then(|s| s.as_str()) == Some(status);
1773 let reason_matches =
1774 cond.get("reason").and_then(|r| r.as_str()) == Some(reason);
1775 let message_matches =
1776 cond.get("message").and_then(|m| m.as_str()) == Some(message);
1777 !(status_matches && reason_matches && message_matches)
1779 } else {
1780 true }
1782 } else {
1783 true }
1785 } else {
1786 true }
1788 } else {
1789 true }
1791 } else {
1792 true };
1794
1795 if !needs_update {
1796 return Ok(());
1798 }
1799
1800 let last_transition_time = if let Some(current_status) = current_json.get("status") {
1802 if let Some(conditions) = current_status.get("conditions").and_then(|c| c.as_array()) {
1803 let matching_condition = conditions
1805 .iter()
1806 .find(|cond| cond.get("type").and_then(|t| t.as_str()) == Some(condition_type));
1807
1808 if let Some(cond) = matching_condition {
1809 let status_changed = cond.get("status").and_then(|s| s.as_str()) != Some(status);
1810 if status_changed {
1811 Utc::now().to_rfc3339()
1813 } else {
1814 cond.get("lastTransitionTime")
1816 .and_then(|t| t.as_str())
1817 .unwrap_or(&Utc::now().to_rfc3339())
1818 .to_string()
1819 }
1820 } else {
1821 Utc::now().to_rfc3339()
1823 }
1824 } else {
1825 Utc::now().to_rfc3339()
1826 }
1827 } else {
1828 Utc::now().to_rfc3339()
1829 };
1830
1831 let condition = Condition {
1832 r#type: condition_type.to_string(),
1833 status: status.to_string(),
1834 reason: Some(reason.to_string()),
1835 message: Some(message.to_string()),
1836 last_transition_time: Some(last_transition_time),
1837 };
1838
1839 let zone = current_json
1841 .get("status")
1842 .and_then(|s| s.get("zone"))
1843 .and_then(|z| z.as_str())
1844 .map(ToString::to_string);
1845
1846 let record_status = RecordStatus {
1847 conditions: vec![condition],
1848 observed_generation: observed_generation.or(record.meta().generation),
1849 zone,
1850 };
1851
1852 let status_patch = json!({
1853 "status": record_status
1854 });
1855
1856 api.patch_status(&name, &PatchParams::default(), &Patch::Merge(&status_patch))
1857 .await
1858 .context("Failed to update record status")?;
1859
1860 info!(
1861 "Updated status for {}/{}: {} = {}",
1862 namespace, name, condition_type, status
1863 );
1864
1865 let event_type = if status == "True" {
1867 "Normal"
1868 } else {
1869 "Warning"
1870 };
1871 create_event(client, record, event_type, reason, message).await?;
1872
1873 Ok(())
1874}