1use crate::constants::{ALLOW_ZONE_NAMESPACES_WILDCARD, ANNOTATION_ALLOW_ZONE_NAMESPACES};
25use crate::crd::{ARecord, ARecordSpec, DNSZone};
26use anyhow::{anyhow, Context, Result};
27use k8s_openapi::api::core::v1::{Namespace, Secret, Service};
28use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
29use k8s_openapi::jiff::Timestamp;
30use kube::api::{DeleteParams, ListParams, Patch, PatchParams};
31use kube::config::{KubeConfigOptions, Kubeconfig};
32
33#[derive(Debug, thiserror::Error)]
36#[error(transparent)]
37pub struct ScoutError(#[from] anyhow::Error);
38use futures::StreamExt;
39use k8s_openapi::api::networking::v1::Ingress;
40use kube::{
41 runtime::{
42 controller::Action, reflector, watcher, watcher::Config as WatcherConfig, Controller,
43 },
44 Api, Client, Error as KubeError, ResourceExt,
45};
46use std::{collections::BTreeMap, sync::Arc, time::Duration};
47use tracing::{debug, error, info, warn};
48
49#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ParentReference {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub group: Option<String>,
68 #[serde(default, skip_serializing_if = "Option::is_none")]
70 pub kind: Option<String>,
71 #[serde(default, skip_serializing_if = "Option::is_none")]
73 pub namespace: Option<String>,
74 pub name: String,
76}
77
78#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct HTTPRouteSpec {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub hostnames: Option<Vec<String>>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub parent_refs: Option<Vec<ParentReference>>,
89}
90
91#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct HTTPRoute {
94 #[serde(rename = "apiVersion")]
95 pub api_version: String,
96 pub kind: String,
97 pub metadata: kube::api::ObjectMeta,
98 #[serde(default)]
99 pub spec: Option<HTTPRouteSpec>,
100}
101
102#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct TLSRouteSpec {
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub hostnames: Option<Vec<String>>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub rules: Option<Vec<serde_json::Value>>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub parent_refs: Option<Vec<ParentReference>>,
116}
117
118#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
120pub struct TLSRoute {
121 #[serde(rename = "apiVersion")]
122 pub api_version: String,
123 pub kind: String,
124 pub metadata: kube::api::ObjectMeta,
125 #[serde(default)]
126 pub spec: Option<TLSRouteSpec>,
127}
128
129#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct TCPRouteSpec {
133 #[serde(default, skip_serializing_if = "Option::is_none")]
138 pub rules: Option<Vec<serde_json::Value>>,
139 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub parent_refs: Option<Vec<ParentReference>>,
143}
144
145#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
147pub struct TCPRoute {
148 #[serde(rename = "apiVersion")]
149 pub api_version: String,
150 pub kind: String,
151 pub metadata: kube::api::ObjectMeta,
152 #[serde(default)]
153 pub spec: Option<TCPRouteSpec>,
154}
155
156impl k8s_openapi::Metadata for HTTPRoute {
158 type Ty = kube::api::ObjectMeta;
159 fn metadata(&self) -> &kube::api::ObjectMeta {
160 &self.metadata
161 }
162 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
163 &mut self.metadata
164 }
165}
166
167impl k8s_openapi::Metadata for TLSRoute {
168 type Ty = kube::api::ObjectMeta;
169 fn metadata(&self) -> &kube::api::ObjectMeta {
170 &self.metadata
171 }
172 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
173 &mut self.metadata
174 }
175}
176
177impl k8s_openapi::Resource for HTTPRoute {
179 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
180 const GROUP: &'static str = "gateway.networking.k8s.io";
181 const KIND: &'static str = "HTTPRoute";
182 const VERSION: &'static str = "v1";
183 const URL_PATH_SEGMENT: &'static str = "httproutes";
184 type Scope = k8s_openapi::NamespaceResourceScope;
185}
186
187impl k8s_openapi::Resource for TLSRoute {
188 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
189 const GROUP: &'static str = "gateway.networking.k8s.io";
190 const KIND: &'static str = "TLSRoute";
191 const VERSION: &'static str = "v1alpha2";
192 const URL_PATH_SEGMENT: &'static str = "tlsroutes";
193 type Scope = k8s_openapi::NamespaceResourceScope;
194}
195
196impl k8s_openapi::Metadata for TCPRoute {
197 type Ty = kube::api::ObjectMeta;
198 fn metadata(&self) -> &kube::api::ObjectMeta {
199 &self.metadata
200 }
201 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
202 &mut self.metadata
203 }
204}
205
206impl k8s_openapi::Resource for TCPRoute {
207 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
208 const GROUP: &'static str = "gateway.networking.k8s.io";
209 const KIND: &'static str = "TCPRoute";
210 const VERSION: &'static str = "v1alpha2";
211 const URL_PATH_SEGMENT: &'static str = "tcproutes";
212 type Scope = k8s_openapi::NamespaceResourceScope;
213}
214
215pub const GATEWAY_API_GROUP: &str = "gateway.networking.k8s.io";
217
218pub const GATEWAY_KIND: &str = "Gateway";
220
221#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct NamespacedName {
227 pub namespace: String,
229 pub name: String,
231}
232
233#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum GatewayServiceTarget {
240 Name(NamespacedName),
242 Labeled {
246 namespace: String,
248 selector: String,
250 },
251}
252
253impl GatewayServiceTarget {
254 #[must_use]
256 pub fn namespace(&self) -> &str {
257 match self {
258 Self::Name(nn) => &nn.namespace,
259 Self::Labeled { namespace, .. } => namespace,
260 }
261 }
262}
263
264#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct GatewaySpec {
268 pub gateway_class_name: String,
270}
271
272#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct GatewayStatusAddress {
276 #[serde(default, skip_serializing_if = "Option::is_none")]
278 pub r#type: Option<String>,
279 pub value: String,
281}
282
283#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct GatewayStatus {
287 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub addresses: Option<Vec<GatewayStatusAddress>>,
291}
292
293#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
295pub struct Gateway {
296 #[serde(rename = "apiVersion")]
297 pub api_version: String,
298 pub kind: String,
299 pub metadata: kube::api::ObjectMeta,
300 pub spec: GatewaySpec,
301 #[serde(default, skip_serializing_if = "Option::is_none")]
302 pub status: Option<GatewayStatus>,
303}
304
305impl k8s_openapi::Metadata for Gateway {
306 type Ty = kube::api::ObjectMeta;
307 fn metadata(&self) -> &kube::api::ObjectMeta {
308 &self.metadata
309 }
310 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
311 &mut self.metadata
312 }
313}
314
315impl k8s_openapi::Resource for Gateway {
316 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
317 const GROUP: &'static str = "gateway.networking.k8s.io";
318 const KIND: &'static str = "Gateway";
319 const VERSION: &'static str = "v1";
320 const URL_PATH_SEGMENT: &'static str = "gateways";
321 type Scope = k8s_openapi::NamespaceResourceScope;
322}
323
324pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
331
332pub const RECORD_KIND_ARECORD: &str = "ARecord";
334
335pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
337
338pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
342
343pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
351
352pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
355
356pub const ANNOTATION_RECORD_NAME: &str = "bindy.firestoned.io/record-name";
365
366pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
368
369pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
371
372pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
374
375pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
377
378pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
380
381pub const LABEL_SOURCE_NAME: &str = "bindy.firestoned.io/source-name";
384
385pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
387
388pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
390
391const MAX_K8S_NAME_LEN: usize = 253;
393
394const ARECORD_NAME_PREFIX: &str = "scout";
396
397const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
399
400pub(crate) const REMOTE_CLEANUP_GRACE_SECS: i64 = 300;
415
416const REFLECTOR_ERROR_BACKOFF_SECS: u64 = 5;
420
421pub struct ScoutContext {
427 pub client: Client,
430 pub remote_client: Client,
434 pub target_namespace: String,
436 pub cluster_name: String,
438 pub excluded_namespaces: Vec<String>,
440 pub default_ips: Vec<String>,
444 pub gateway_services: BTreeMap<String, GatewayServiceTarget>,
449 pub default_zone: Option<String>,
452 pub namespace_selector: Option<String>,
461 pub zone_store: reflector::Store<DNSZone>,
464}
465
466async fn namespace_matches_selector(
489 client: &Client,
490 namespace: &str,
491 selector: &str,
492) -> Result<bool> {
493 let ns_api: Api<Namespace> = Api::all(client.clone());
494 let lp = ListParams::default()
495 .labels(selector)
496 .fields(&format!("metadata.name={namespace}"));
497 let list = ns_api.list(&lp).await.with_context(|| {
498 format!("failed to check namespace '{namespace}' against selector '{selector}'")
499 })?;
500 Ok(!list.items.is_empty())
501}
502
503async fn source_namespace_eligible(
518 client: &Client,
519 namespace: &str,
520 selector: Option<&str>,
521) -> Result<bool> {
522 match selector {
523 None => Ok(true),
524 Some(sel) => namespace_matches_selector(client, namespace, sel).await,
525 }
526}
527
528pub(crate) fn cleanup_grace_expired(deletion_timestamp: Option<&Time>, now: Timestamp) -> bool {
544 match deletion_timestamp {
545 Some(Time(started)) => now.duration_since(*started).as_secs() >= REMOTE_CLEANUP_GRACE_SECS,
546 None => false,
547 }
548}
549
550pub fn is_arecord_enabled(annotations: &BTreeMap<String, String>) -> bool {
555 annotations
556 .get(ANNOTATION_RECORD_KIND)
557 .map(|v| v == RECORD_KIND_ARECORD)
558 .unwrap_or(false)
559}
560
561pub fn is_scout_opted_in(annotations: &BTreeMap<String, String>) -> bool {
571 annotations
572 .get(ANNOTATION_SCOUT_ENABLED)
573 .map(|v| v == "true")
574 .unwrap_or(false)
575 || is_arecord_enabled(annotations)
576}
577
578pub fn resolve_zone(
585 annotations: &BTreeMap<String, String>,
586 default_zone: Option<&str>,
587) -> Option<String> {
588 get_zone_annotation(annotations).or_else(|| default_zone.map(ToString::to_string))
589}
590
591pub fn get_zone_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
595 annotations
596 .get(ANNOTATION_ZONE)
597 .filter(|v| !v.is_empty())
598 .cloned()
599}
600
601pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
613 let host = host.trim_end_matches('.');
615
616 if host == zone {
618 return Ok("@".to_string());
619 }
620
621 let zone_suffix = format!(".{zone}");
622 if !host.ends_with(&zone_suffix) {
623 return Err(anyhow!(
624 "host \"{host}\" does not belong to zone \"{zone}\""
625 ));
626 }
627
628 let record_name = &host[..host.len() - zone_suffix.len()];
629 Ok(record_name.to_string())
630}
631
632pub fn get_record_name_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
637 annotations
638 .get(ANNOTATION_RECORD_NAME)
639 .map(|v| v.trim().to_string())
640 .filter(|v| !v.is_empty())
641}
642
643pub fn resolve_record_name(
656 annotations: &BTreeMap<String, String>,
657 host: &str,
658 zone: &str,
659) -> Result<String> {
660 if let Some(override_name) = get_record_name_annotation(annotations) {
661 return Ok(override_name);
662 }
663 derive_record_name(host, zone)
664}
665
666pub fn resolve_ips_from_annotation(annotations: &BTreeMap<String, String>) -> Option<Vec<String>> {
675 let raw = annotations.get(ANNOTATION_IP)?;
676 let ips: Vec<String> = raw
677 .split(',')
678 .map(str::trim)
679 .filter(|s| !s.is_empty())
680 .map(ToString::to_string)
681 .collect();
682 if ips.is_empty() {
683 None
684 } else {
685 Some(ips)
686 }
687}
688
689#[must_use]
702pub fn zone_allows_source_namespace(zone: &DNSZone, source_namespace: &str) -> bool {
703 if zone.namespace().as_deref() == Some(source_namespace) {
704 return true;
705 }
706 let Some(annotations) = zone.metadata.annotations.as_ref() else {
707 return false;
708 };
709 let Some(value) = annotations.get(ANNOTATION_ALLOW_ZONE_NAMESPACES) else {
710 return false;
711 };
712 value
713 .split(',')
714 .map(str::trim)
715 .any(|entry| entry == ALLOW_ZONE_NAMESPACES_WILDCARD || entry == source_namespace)
716}
717
718#[derive(Debug, PartialEq, Eq)]
721pub(crate) enum ZoneAuthz {
722 Authorized,
724 Forbidden,
726 NotFound,
728}
729
730pub(crate) fn check_zone_authorization(
737 zones: &[Arc<DNSZone>],
738 zone_name: &str,
739 source_namespace: &str,
740) -> ZoneAuthz {
741 let mut found = false;
742 for zone in zones {
743 if zone.spec.zone_name != zone_name {
744 continue;
745 }
746 found = true;
747 if zone_allows_source_namespace(zone, source_namespace) {
748 return ZoneAuthz::Authorized;
749 }
750 }
751 if found {
752 ZoneAuthz::Forbidden
753 } else {
754 ZoneAuthz::NotFound
755 }
756}
757
758pub fn resolve_ips(
766 annotations: &BTreeMap<String, String>,
767 default_ips: &[String],
768 ingress: &Ingress,
769) -> Option<Vec<String>> {
770 if let Some(ips) = resolve_ips_from_annotation(annotations) {
771 return Some(ips);
772 }
773 if !default_ips.is_empty() {
774 return Some(default_ips.to_vec());
775 }
776 resolve_ip_from_lb_status(ingress).map(|ip| vec![ip])
777}
778
779pub fn resolve_ip_from_lb_status(ingress: &Ingress) -> Option<String> {
784 let lb_ingresses = ingress
785 .status
786 .as_ref()?
787 .load_balancer
788 .as_ref()?
789 .ingress
790 .as_ref()?;
791
792 for lb in lb_ingresses {
793 if let Some(ip) = &lb.ip {
794 if !ip.is_empty() {
795 return Some(ip.clone());
796 }
797 }
798 if lb.hostname.is_some() {
799 warn!(
800 ingress = %ingress.name_any(),
801 "Ingress LB status has hostname but no IP — A record requires an IP address; skipping"
802 );
803 }
804 }
805 None
806}
807
808pub fn arecord_cr_name(
816 cluster: &str,
817 namespace: &str,
818 ingress_name: &str,
819 host_index: usize,
820) -> String {
821 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{ingress_name}-{host_index}");
822 let sanitized = sanitize_k8s_name(&raw);
823 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
824}
825
826fn sanitize_k8s_name(s: &str) -> String {
833 let lower = s.to_lowercase();
834 let mut result = String::with_capacity(lower.len());
835 let mut last_was_hyphen = false;
836
837 for ch in lower.chars() {
838 if ch.is_ascii_alphanumeric() {
839 result.push(ch);
840 last_was_hyphen = false;
841 } else {
842 if !last_was_hyphen {
844 result.push('-');
845 last_was_hyphen = true;
846 }
847 }
848 }
849
850 let trimmed = result.trim_end_matches('-');
852 trimmed.trim_start_matches('-').to_string()
854}
855
856pub fn has_finalizer(ingress: &Ingress) -> bool {
858 ingress
859 .metadata
860 .finalizers
861 .as_ref()
862 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
863 .unwrap_or(false)
864}
865
866pub fn is_being_deleted(ingress: &Ingress) -> bool {
868 ingress.metadata.deletion_timestamp.is_some()
869}
870
871pub fn arecord_label_selector(cluster: &str, namespace: &str, ingress_name: &str) -> String {
877 format!(
878 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={ingress_name}",
879 LABEL_MANAGED_BY,
880 LABEL_MANAGED_BY_SCOUT,
881 cluster_key = LABEL_SOURCE_CLUSTER,
882 ns_key = LABEL_SOURCE_NAMESPACE,
883 name_key = LABEL_SOURCE_NAME,
884 )
885}
886
887pub fn stale_arecord_label_selector(
894 current_cluster: &str,
895 namespace: &str,
896 ingress_name: &str,
897) -> String {
898 format!(
899 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={ingress_name}",
900 LABEL_MANAGED_BY,
901 LABEL_MANAGED_BY_SCOUT,
902 cluster_key = LABEL_SOURCE_CLUSTER,
903 ns_key = LABEL_SOURCE_NAMESPACE,
904 name_key = LABEL_SOURCE_NAME,
905 )
906}
907
908pub struct ARecordParams<'a> {
914 pub name: &'a str,
916 pub target_namespace: &'a str,
918 pub record_name: &'a str,
920 pub ips: &'a [String],
922 pub ttl: Option<i32>,
924 pub cluster_name: &'a str,
926 pub ingress_namespace: &'a str,
928 pub ingress_name: &'a str,
930 pub zone: &'a str,
932}
933
934pub fn build_arecord(params: ARecordParams<'_>) -> ARecord {
936 let mut labels = BTreeMap::new();
937 labels.insert(
938 LABEL_MANAGED_BY.to_string(),
939 LABEL_MANAGED_BY_SCOUT.to_string(),
940 );
941 labels.insert(
942 LABEL_SOURCE_CLUSTER.to_string(),
943 params.cluster_name.to_string(),
944 );
945 labels.insert(
946 LABEL_SOURCE_NAMESPACE.to_string(),
947 params.ingress_namespace.to_string(),
948 );
949 labels.insert(
950 LABEL_SOURCE_NAME.to_string(),
951 params.ingress_name.to_string(),
952 );
953 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
954
955 let meta = kube::api::ObjectMeta {
956 name: Some(params.name.to_string()),
957 namespace: Some(params.target_namespace.to_string()),
958 labels: Some(labels),
959 ..Default::default()
960 };
961
962 ARecord {
963 metadata: meta,
964 spec: ARecordSpec {
965 name: params.record_name.to_string(),
966 ipv4_addresses: params.ips.to_vec(),
967 ttl: params.ttl,
968 },
969 status: None,
970 }
971}
972
973pub fn is_loadbalancer_service(svc: &Service) -> bool {
982 svc.spec
983 .as_ref()
984 .and_then(|s| s.type_.as_deref())
985 .map(|t| t == "LoadBalancer")
986 .unwrap_or(false)
987}
988
989pub fn resolve_ip_from_service_lb_status(svc: &Service) -> Option<String> {
995 svc.status
996 .as_ref()?
997 .load_balancer
998 .as_ref()?
999 .ingress
1000 .as_ref()?
1001 .iter()
1002 .find_map(|entry| entry.ip.clone().filter(|ip| !ip.is_empty()))
1003}
1004
1005#[must_use]
1010pub fn service_ref_from_str(s: &str) -> Option<NamespacedName> {
1011 let mut parts = s.split('/');
1012 let namespace = parts.next()?.trim();
1013 let name = parts.next()?.trim();
1014 if namespace.is_empty() || name.is_empty() || parts.next().is_some() {
1015 return None;
1016 }
1017 Some(NamespacedName {
1018 namespace: namespace.to_string(),
1019 name: name.to_string(),
1020 })
1021}
1022
1023#[must_use]
1031pub fn gateway_service_target_from_str(s: &str) -> Option<GatewayServiceTarget> {
1032 let (namespace, rest) = s.split_once('/')?;
1033 let namespace = namespace.trim();
1034 let rest = rest.trim();
1035 if namespace.is_empty() || rest.is_empty() {
1036 return None;
1037 }
1038 if rest.contains('=') {
1039 return Some(GatewayServiceTarget::Labeled {
1040 namespace: namespace.to_string(),
1041 selector: rest.to_string(),
1042 });
1043 }
1044 service_ref_from_str(s).map(GatewayServiceTarget::Name)
1047}
1048
1049#[must_use]
1056pub fn parse_gateway_service_entry(entry: &str) -> Option<(String, GatewayServiceTarget)> {
1057 let (class, target) = entry.trim().split_once('=')?;
1058 let class = class.trim();
1059 if class.is_empty() {
1060 return None;
1061 }
1062 let target = gateway_service_target_from_str(target)?;
1063 Some((class.to_string(), target))
1064}
1065
1066#[must_use]
1077pub fn parse_gateway_services(raw: &str) -> BTreeMap<String, GatewayServiceTarget> {
1078 raw.split(',')
1079 .filter(|e| !e.trim().is_empty())
1080 .filter_map(parse_gateway_service_entry)
1081 .collect()
1082}
1083
1084#[must_use]
1091pub fn gateway_addresses_as_ips(gw: &Gateway) -> Vec<String> {
1092 let Some(addresses) = gw.status.as_ref().and_then(|s| s.addresses.as_ref()) else {
1093 return Vec::new();
1094 };
1095 addresses
1096 .iter()
1097 .filter(|addr| match addr.r#type.as_deref() {
1098 Some("IPAddress") => true,
1099 Some("Hostname") => false,
1100 _ => addr.value.parse::<std::net::IpAddr>().is_ok(),
1101 })
1102 .map(|addr| addr.value.clone())
1103 .filter(|v| !v.is_empty())
1104 .collect()
1105}
1106
1107#[must_use]
1113pub fn gateway_parent_refs(
1114 parent_refs: &[ParentReference],
1115 route_namespace: &str,
1116) -> Vec<NamespacedName> {
1117 parent_refs
1118 .iter()
1119 .filter(|r| {
1120 let group_ok = r
1121 .group
1122 .as_deref()
1123 .is_none_or(|g| g.is_empty() || g == GATEWAY_API_GROUP);
1124 let kind_ok = r.kind.as_deref().is_none_or(|k| k == GATEWAY_KIND);
1125 group_ok && kind_ok
1126 })
1127 .map(|r| NamespacedName {
1128 namespace: r
1129 .namespace
1130 .clone()
1131 .filter(|ns| !ns.is_empty())
1132 .unwrap_or_else(|| route_namespace.to_string()),
1133 name: r.name.clone(),
1134 })
1135 .collect()
1136}
1137
1138async fn resolve_ip_from_gateway_service(
1146 client: &Client,
1147 target: &GatewayServiceTarget,
1148) -> Option<String> {
1149 match target {
1150 GatewayServiceTarget::Name(svc_ref) => {
1151 let svc_api: Api<Service> = Api::namespaced(client.clone(), &svc_ref.namespace);
1152 match svc_api.get(&svc_ref.name).await {
1153 Ok(svc) => resolve_ip_from_service_lb_status(&svc).or_else(|| {
1154 debug!(service = %svc_ref.name, ns = %svc_ref.namespace,
1155 "Gateway LoadBalancer Service has no external IP yet");
1156 None
1157 }),
1158 Err(e) => {
1159 debug!(service = %svc_ref.name, ns = %svc_ref.namespace, error = %e,
1160 "Could not fetch Gateway's LoadBalancer Service");
1161 None
1162 }
1163 }
1164 }
1165 GatewayServiceTarget::Labeled {
1166 namespace,
1167 selector,
1168 } => {
1169 let svc_api: Api<Service> = Api::namespaced(client.clone(), namespace);
1170 let lp = kube::api::ListParams::default().labels(selector);
1171 match svc_api.list(&lp).await {
1172 Ok(list) => list
1173 .items
1174 .iter()
1175 .filter(|svc| is_loadbalancer_service(svc))
1176 .find_map(resolve_ip_from_service_lb_status)
1177 .or_else(|| {
1178 debug!(ns = %namespace, selector = %selector,
1179 "No LoadBalancer Service with an external IP matched the selector");
1180 None
1181 }),
1182 Err(e) => {
1183 debug!(ns = %namespace, selector = %selector, error = %e,
1184 "Could not list Gateway LoadBalancer Services by selector");
1185 None
1186 }
1187 }
1188 }
1189 }
1190}
1191
1192pub async fn resolve_ips_from_gateways(
1214 client: &Client,
1215 route_namespace: &str,
1216 parent_refs: &[ParentReference],
1217 gateway_services: &BTreeMap<String, GatewayServiceTarget>,
1218) -> Option<Vec<String>> {
1219 if parent_refs.is_empty() {
1220 return None;
1221 }
1222
1223 let mut ips: Vec<String> = Vec::new();
1224 for gw_ref in gateway_parent_refs(parent_refs, route_namespace) {
1225 let gw_api: Api<Gateway> = Api::namespaced(client.clone(), &gw_ref.namespace);
1226 let gateway = match gw_api.get(&gw_ref.name).await {
1227 Ok(gw) => gw,
1228 Err(e) => {
1229 debug!(gateway = %gw_ref.name, ns = %gw_ref.namespace, error = %e,
1230 "Skipping parentRef Gateway that could not be fetched");
1231 continue;
1232 }
1233 };
1234
1235 let gw_ips = gateway_addresses_as_ips(&gateway);
1238 if !gw_ips.is_empty() {
1239 ips.extend(gw_ips);
1240 continue;
1241 }
1242
1243 let class = &gateway.spec.gateway_class_name;
1246 let Some(target) = gateway_services.get(class) else {
1247 debug!(gateway = %gw_ref.name, class = %class,
1248 "Gateway has no status.addresses and class not in configured gateway-services — skipping");
1249 continue;
1250 };
1251 if let Some(ip) = resolve_ip_from_gateway_service(client, target).await {
1252 ips.push(ip);
1253 }
1254 }
1255
1256 let mut seen = std::collections::HashSet::new();
1258 ips.retain(|ip| seen.insert(ip.clone()));
1259
1260 if ips.is_empty() {
1261 None
1262 } else {
1263 Some(ips)
1264 }
1265}
1266
1267pub fn service_arecord_cr_name(cluster: &str, namespace: &str, service_name: &str) -> String {
1274 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{service_name}");
1275 let sanitized = sanitize_k8s_name(&raw);
1276 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1277}
1278
1279pub fn service_arecord_label_selector(
1282 cluster: &str,
1283 namespace: &str,
1284 service_name: &str,
1285) -> String {
1286 format!(
1287 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={service_name}",
1288 LABEL_MANAGED_BY,
1289 LABEL_MANAGED_BY_SCOUT,
1290 cluster_key = LABEL_SOURCE_CLUSTER,
1291 ns_key = LABEL_SOURCE_NAMESPACE,
1292 name_key = LABEL_SOURCE_NAME,
1293 )
1294}
1295
1296pub struct ServiceARecordParams<'a> {
1298 pub name: &'a str,
1300 pub target_namespace: &'a str,
1302 pub record_name: &'a str,
1304 pub ips: &'a [String],
1306 pub ttl: Option<i32>,
1308 pub cluster_name: &'a str,
1310 pub service_namespace: &'a str,
1312 pub service_name: &'a str,
1314 pub zone: &'a str,
1316}
1317
1318pub fn build_service_arecord(params: ServiceARecordParams<'_>) -> ARecord {
1320 let mut labels = BTreeMap::new();
1321 labels.insert(
1322 LABEL_MANAGED_BY.to_string(),
1323 LABEL_MANAGED_BY_SCOUT.to_string(),
1324 );
1325 labels.insert(
1326 LABEL_SOURCE_CLUSTER.to_string(),
1327 params.cluster_name.to_string(),
1328 );
1329 labels.insert(
1330 LABEL_SOURCE_NAMESPACE.to_string(),
1331 params.service_namespace.to_string(),
1332 );
1333 labels.insert(
1334 LABEL_SOURCE_NAME.to_string(),
1335 params.service_name.to_string(),
1336 );
1337 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1338
1339 let meta = kube::api::ObjectMeta {
1340 name: Some(params.name.to_string()),
1341 namespace: Some(params.target_namespace.to_string()),
1342 labels: Some(labels),
1343 ..Default::default()
1344 };
1345
1346 ARecord {
1347 metadata: meta,
1348 spec: ARecordSpec {
1349 name: params.record_name.to_string(),
1350 ipv4_addresses: params.ips.to_vec(),
1351 ttl: params.ttl,
1352 },
1353 status: None,
1354 }
1355}
1356
1357pub fn httproute_arecord_cr_name(
1369 cluster: &str,
1370 namespace: &str,
1371 route_name: &str,
1372 hostname_index: usize,
1373) -> String {
1374 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1375 let sanitized = sanitize_k8s_name(&raw);
1376 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1377}
1378
1379pub fn tlsroute_arecord_cr_name(
1386 cluster: &str,
1387 namespace: &str,
1388 route_name: &str,
1389 hostname_index: usize,
1390) -> String {
1391 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1392 let sanitized = sanitize_k8s_name(&raw);
1393 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1394}
1395
1396pub fn httproute_arecord_label_selector(
1399 cluster: &str,
1400 namespace: &str,
1401 route_name: &str,
1402) -> String {
1403 format!(
1404 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1405 LABEL_MANAGED_BY,
1406 LABEL_MANAGED_BY_SCOUT,
1407 cluster_key = LABEL_SOURCE_CLUSTER,
1408 ns_key = LABEL_SOURCE_NAMESPACE,
1409 name_key = LABEL_SOURCE_NAME,
1410 )
1411}
1412
1413pub fn tlsroute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1416 format!(
1417 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1418 LABEL_MANAGED_BY,
1419 LABEL_MANAGED_BY_SCOUT,
1420 cluster_key = LABEL_SOURCE_CLUSTER,
1421 ns_key = LABEL_SOURCE_NAMESPACE,
1422 name_key = LABEL_SOURCE_NAME,
1423 )
1424}
1425
1426pub fn tcproute_arecord_cr_name(
1428 cluster: &str,
1429 namespace: &str,
1430 route_name: &str,
1431 hostname_index: usize,
1432) -> String {
1433 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1434 let sanitized = sanitize_k8s_name(&raw);
1435 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1436}
1437
1438pub fn tcproute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1441 format!(
1442 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1443 LABEL_MANAGED_BY,
1444 LABEL_MANAGED_BY_SCOUT,
1445 cluster_key = LABEL_SOURCE_CLUSTER,
1446 ns_key = LABEL_SOURCE_NAMESPACE,
1447 name_key = LABEL_SOURCE_NAME,
1448 )
1449}
1450
1451pub fn stale_httproute_arecord_label_selector(
1457 current_cluster: &str,
1458 namespace: &str,
1459 route_name: &str,
1460) -> String {
1461 format!(
1462 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1463 LABEL_MANAGED_BY,
1464 LABEL_MANAGED_BY_SCOUT,
1465 cluster_key = LABEL_SOURCE_CLUSTER,
1466 ns_key = LABEL_SOURCE_NAMESPACE,
1467 name_key = LABEL_SOURCE_NAME,
1468 )
1469}
1470
1471pub fn stale_tlsroute_arecord_label_selector(
1474 current_cluster: &str,
1475 namespace: &str,
1476 route_name: &str,
1477) -> String {
1478 format!(
1479 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1480 LABEL_MANAGED_BY,
1481 LABEL_MANAGED_BY_SCOUT,
1482 cluster_key = LABEL_SOURCE_CLUSTER,
1483 ns_key = LABEL_SOURCE_NAMESPACE,
1484 name_key = LABEL_SOURCE_NAME,
1485 )
1486}
1487
1488pub fn stale_tcproute_arecord_label_selector(
1491 current_cluster: &str,
1492 namespace: &str,
1493 route_name: &str,
1494) -> String {
1495 format!(
1496 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1497 LABEL_MANAGED_BY,
1498 LABEL_MANAGED_BY_SCOUT,
1499 cluster_key = LABEL_SOURCE_CLUSTER,
1500 ns_key = LABEL_SOURCE_NAMESPACE,
1501 name_key = LABEL_SOURCE_NAME,
1502 )
1503}
1504
1505pub struct HTTPRouteARecordParams<'a> {
1507 pub name: &'a str,
1509 pub target_namespace: &'a str,
1511 pub record_name: &'a str,
1513 pub ips: &'a [String],
1515 pub ttl: Option<i32>,
1517 pub cluster_name: &'a str,
1519 pub route_namespace: &'a str,
1521 pub route_name: &'a str,
1523 pub zone: &'a str,
1525}
1526
1527pub fn build_httproute_arecord(params: HTTPRouteARecordParams<'_>) -> ARecord {
1529 let mut labels = BTreeMap::new();
1530 labels.insert(
1531 LABEL_MANAGED_BY.to_string(),
1532 LABEL_MANAGED_BY_SCOUT.to_string(),
1533 );
1534 labels.insert(
1535 LABEL_SOURCE_CLUSTER.to_string(),
1536 params.cluster_name.to_string(),
1537 );
1538 labels.insert(
1539 LABEL_SOURCE_NAMESPACE.to_string(),
1540 params.route_namespace.to_string(),
1541 );
1542 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1543 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1544
1545 let meta = kube::api::ObjectMeta {
1546 name: Some(params.name.to_string()),
1547 namespace: Some(params.target_namespace.to_string()),
1548 labels: Some(labels),
1549 ..Default::default()
1550 };
1551
1552 ARecord {
1553 metadata: meta,
1554 spec: ARecordSpec {
1555 name: params.record_name.to_string(),
1556 ipv4_addresses: params.ips.to_vec(),
1557 ttl: params.ttl,
1558 },
1559 status: None,
1560 }
1561}
1562
1563pub struct TLSRouteARecordParams<'a> {
1565 pub name: &'a str,
1567 pub target_namespace: &'a str,
1569 pub record_name: &'a str,
1571 pub ips: &'a [String],
1573 pub ttl: Option<i32>,
1575 pub cluster_name: &'a str,
1577 pub route_namespace: &'a str,
1579 pub route_name: &'a str,
1581 pub zone: &'a str,
1583}
1584
1585pub fn build_tlsroute_arecord(params: TLSRouteARecordParams<'_>) -> ARecord {
1587 let mut labels = BTreeMap::new();
1588 labels.insert(
1589 LABEL_MANAGED_BY.to_string(),
1590 LABEL_MANAGED_BY_SCOUT.to_string(),
1591 );
1592 labels.insert(
1593 LABEL_SOURCE_CLUSTER.to_string(),
1594 params.cluster_name.to_string(),
1595 );
1596 labels.insert(
1597 LABEL_SOURCE_NAMESPACE.to_string(),
1598 params.route_namespace.to_string(),
1599 );
1600 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1601 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1602
1603 let meta = kube::api::ObjectMeta {
1604 name: Some(params.name.to_string()),
1605 namespace: Some(params.target_namespace.to_string()),
1606 labels: Some(labels),
1607 ..Default::default()
1608 };
1609
1610 ARecord {
1611 metadata: meta,
1612 spec: ARecordSpec {
1613 name: params.record_name.to_string(),
1614 ipv4_addresses: params.ips.to_vec(),
1615 ttl: params.ttl,
1616 },
1617 status: None,
1618 }
1619}
1620
1621pub struct TCPRouteARecordParams<'a> {
1623 pub name: &'a str,
1625 pub target_namespace: &'a str,
1627 pub record_name: &'a str,
1629 pub ips: &'a [String],
1631 pub ttl: Option<i32>,
1633 pub cluster_name: &'a str,
1635 pub route_namespace: &'a str,
1637 pub route_name: &'a str,
1639 pub zone: &'a str,
1641}
1642
1643pub fn build_tcproute_arecord(params: TCPRouteARecordParams<'_>) -> ARecord {
1645 let mut labels = BTreeMap::new();
1646 labels.insert(
1647 LABEL_MANAGED_BY.to_string(),
1648 LABEL_MANAGED_BY_SCOUT.to_string(),
1649 );
1650 labels.insert(
1651 LABEL_SOURCE_CLUSTER.to_string(),
1652 params.cluster_name.to_string(),
1653 );
1654 labels.insert(
1655 LABEL_SOURCE_NAMESPACE.to_string(),
1656 params.route_namespace.to_string(),
1657 );
1658 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1659 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1660
1661 let meta = kube::api::ObjectMeta {
1662 name: Some(params.name.to_string()),
1663 namespace: Some(params.target_namespace.to_string()),
1664 labels: Some(labels),
1665 ..Default::default()
1666 };
1667
1668 ARecord {
1669 metadata: meta,
1670 spec: ARecordSpec {
1671 name: params.record_name.to_string(),
1672 ipv4_addresses: params.ips.to_vec(),
1673 ttl: params.ttl,
1674 },
1675 status: None,
1676 }
1677}
1678
1679async fn add_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1688 let namespace = ingress.namespace().unwrap_or_default();
1689 let name = ingress.name_any();
1690 let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1691
1692 let mut finalizers = ingress.metadata.finalizers.clone().unwrap_or_default();
1693 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1694 finalizers.push(FINALIZER_SCOUT.to_string());
1695 }
1696
1697 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1698 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1699 .await?;
1700 Ok(())
1701}
1702
1703async fn remove_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1707 let namespace = ingress.namespace().unwrap_or_default();
1708 let name = ingress.name_any();
1709 let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1710
1711 let finalizers: Vec<String> = ingress
1712 .metadata
1713 .finalizers
1714 .clone()
1715 .unwrap_or_default()
1716 .into_iter()
1717 .filter(|f| f != FINALIZER_SCOUT)
1718 .collect();
1719
1720 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1721 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1722 .await?;
1723 Ok(())
1724}
1725
1726async fn add_finalizer_to_service(client: &Client, svc: &Service) -> Result<()> {
1728 let namespace = svc.namespace().unwrap_or_default();
1729 let name = svc.name_any();
1730 let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
1731
1732 let mut finalizers = svc.metadata.finalizers.clone().unwrap_or_default();
1733 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1734 finalizers.push(FINALIZER_SCOUT.to_string());
1735 }
1736
1737 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1738 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1739 .await?;
1740 Ok(())
1741}
1742
1743async fn remove_finalizer_from_service(client: &Client, svc: &Service) -> Result<()> {
1745 let namespace = svc.namespace().unwrap_or_default();
1746 let name = svc.name_any();
1747 let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
1748
1749 let finalizers: Vec<String> = svc
1750 .metadata
1751 .finalizers
1752 .clone()
1753 .unwrap_or_default()
1754 .into_iter()
1755 .filter(|f| f != FINALIZER_SCOUT)
1756 .collect();
1757
1758 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1759 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1760 .await?;
1761 Ok(())
1762}
1763
1764async fn add_finalizer_to_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
1766 let namespace = route.namespace().unwrap_or_default();
1767 let name = route.name_any();
1768 let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
1769
1770 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1771 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1772 finalizers.push(FINALIZER_SCOUT.to_string());
1773 }
1774
1775 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1776 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1777 .await?;
1778 Ok(())
1779}
1780
1781async fn remove_finalizer_from_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
1783 let namespace = route.namespace().unwrap_or_default();
1784 let name = route.name_any();
1785 let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
1786
1787 let finalizers: Vec<String> = route
1788 .metadata
1789 .finalizers
1790 .clone()
1791 .unwrap_or_default()
1792 .into_iter()
1793 .filter(|f| f != FINALIZER_SCOUT)
1794 .collect();
1795
1796 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1797 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1798 .await?;
1799 Ok(())
1800}
1801
1802async fn add_finalizer_to_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
1804 let namespace = route.namespace().unwrap_or_default();
1805 let name = route.name_any();
1806 let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
1807
1808 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1809 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1810 finalizers.push(FINALIZER_SCOUT.to_string());
1811 }
1812
1813 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1814 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1815 .await?;
1816 Ok(())
1817}
1818
1819async fn remove_finalizer_from_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
1821 let namespace = route.namespace().unwrap_or_default();
1822 let name = route.name_any();
1823 let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
1824
1825 let finalizers: Vec<String> = route
1826 .metadata
1827 .finalizers
1828 .clone()
1829 .unwrap_or_default()
1830 .into_iter()
1831 .filter(|f| f != FINALIZER_SCOUT)
1832 .collect();
1833
1834 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1835 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1836 .await?;
1837 Ok(())
1838}
1839
1840async fn add_finalizer_to_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
1842 let namespace = route.namespace().unwrap_or_default();
1843 let name = route.name_any();
1844 let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
1845
1846 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1847 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1848 finalizers.push(FINALIZER_SCOUT.to_string());
1849 }
1850
1851 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1852 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1853 .await?;
1854 Ok(())
1855}
1856
1857async fn remove_finalizer_from_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
1859 let namespace = route.namespace().unwrap_or_default();
1860 let name = route.name_any();
1861 let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
1862
1863 let finalizers: Vec<String> = route
1864 .metadata
1865 .finalizers
1866 .clone()
1867 .unwrap_or_default()
1868 .into_iter()
1869 .filter(|f| f != FINALIZER_SCOUT)
1870 .collect();
1871
1872 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1873 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1874 .await?;
1875 Ok(())
1876}
1877
1878async fn delete_arecords_for_ingress(
1884 remote_client: &Client,
1885 target_namespace: &str,
1886 cluster: &str,
1887 ingress_namespace: &str,
1888 ingress_name: &str,
1889) -> Result<()> {
1890 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1891 let selector = arecord_label_selector(cluster, ingress_namespace, ingress_name);
1892 let lp = ListParams::default().labels(&selector);
1893
1894 let arecords = api.list(&lp).await?;
1895 for ar in arecords.items {
1896 let ar_name = ar.name_any();
1897 api.delete(&ar_name, &DeleteParams::default()).await?;
1898 info!(
1899 arecord = %ar_name,
1900 ingress = %ingress_name,
1901 ns = %ingress_namespace,
1902 "Deleted ARecord during Ingress cleanup"
1903 );
1904 }
1905 Ok(())
1906}
1907
1908async fn delete_stale_cluster_arecords(
1916 remote_client: &Client,
1917 target_namespace: &str,
1918 current_cluster: &str,
1919 ingress_namespace: &str,
1920 ingress_name: &str,
1921) -> Result<()> {
1922 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1923 let selector = stale_arecord_label_selector(current_cluster, ingress_namespace, ingress_name);
1924 let lp = ListParams::default().labels(&selector);
1925
1926 let arecords = api.list(&lp).await?;
1927 for ar in arecords.items {
1928 let ar_name = ar.name_any();
1929 let old_cluster = ar
1930 .metadata
1931 .labels
1932 .as_ref()
1933 .and_then(|l| l.get(LABEL_SOURCE_CLUSTER))
1934 .map(String::as_str)
1935 .unwrap_or("unknown");
1936 api.delete(&ar_name, &DeleteParams::default()).await?;
1937 info!(
1938 arecord = %ar_name,
1939 old_cluster = %old_cluster,
1940 new_cluster = %current_cluster,
1941 ingress = %ingress_name,
1942 ns = %ingress_namespace,
1943 "Deleted stale ARecord after cluster-name change"
1944 );
1945 }
1946 Ok(())
1947}
1948
1949async fn delete_arecords_for_service(
1953 remote_client: &Client,
1954 target_namespace: &str,
1955 cluster: &str,
1956 svc_namespace: &str,
1957 svc_name: &str,
1958) -> Result<()> {
1959 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1960 let selector = service_arecord_label_selector(cluster, svc_namespace, svc_name);
1961 let lp = ListParams::default().labels(&selector);
1962
1963 let arecords = api.list(&lp).await?;
1964 for ar in arecords.items {
1965 let ar_name = ar.name_any();
1966 api.delete(&ar_name, &DeleteParams::default()).await?;
1967 info!(
1968 arecord = %ar_name,
1969 service = %svc_name,
1970 ns = %svc_namespace,
1971 "Deleted ARecord during Service cleanup"
1972 );
1973 }
1974 Ok(())
1975}
1976
1977async fn delete_arecords_for_httproute(
1979 remote_client: &Client,
1980 target_namespace: &str,
1981 cluster: &str,
1982 route_namespace: &str,
1983 route_name: &str,
1984) -> Result<()> {
1985 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1986 let selector = httproute_arecord_label_selector(cluster, route_namespace, route_name);
1987 let lp = ListParams::default().labels(&selector);
1988
1989 let arecords = api.list(&lp).await?;
1990 for ar in arecords.items {
1991 let ar_name = ar.name_any();
1992 api.delete(&ar_name, &DeleteParams::default()).await?;
1993 info!(
1994 arecord = %ar_name,
1995 httproute = %route_name,
1996 ns = %route_namespace,
1997 "Deleted ARecord during HTTPRoute cleanup"
1998 );
1999 }
2000 Ok(())
2001}
2002
2003async fn delete_arecords_for_tlsroute(
2005 remote_client: &Client,
2006 target_namespace: &str,
2007 cluster: &str,
2008 route_namespace: &str,
2009 route_name: &str,
2010) -> Result<()> {
2011 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2012 let selector = tlsroute_arecord_label_selector(cluster, route_namespace, route_name);
2013 let lp = ListParams::default().labels(&selector);
2014
2015 let arecords = api.list(&lp).await?;
2016 for ar in arecords.items {
2017 let ar_name = ar.name_any();
2018 api.delete(&ar_name, &DeleteParams::default()).await?;
2019 info!(
2020 arecord = %ar_name,
2021 tlsroute = %route_name,
2022 ns = %route_namespace,
2023 "Deleted ARecord during TLSRoute cleanup"
2024 );
2025 }
2026 Ok(())
2027}
2028
2029async fn delete_stale_cluster_httproute_arecords(
2031 remote_client: &Client,
2032 target_namespace: &str,
2033 current_cluster: &str,
2034 route_namespace: &str,
2035 route_name: &str,
2036) -> Result<()> {
2037 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2038 let selector =
2039 stale_httproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2040 let lp = ListParams::default().labels(&selector);
2041
2042 let arecords = api.list(&lp).await?;
2043 for ar in arecords.items {
2044 let ar_name = ar.name_any();
2045 api.delete(&ar_name, &DeleteParams::default()).await?;
2046 info!(
2047 arecord = %ar_name,
2048 httproute = %route_name,
2049 "Deleted stale HTTPRoute ARecord from previous cluster name"
2050 );
2051 }
2052 Ok(())
2053}
2054
2055async fn delete_stale_cluster_tlsroute_arecords(
2057 remote_client: &Client,
2058 target_namespace: &str,
2059 current_cluster: &str,
2060 route_namespace: &str,
2061 route_name: &str,
2062) -> Result<()> {
2063 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2064 let selector =
2065 stale_tlsroute_arecord_label_selector(current_cluster, route_namespace, route_name);
2066 let lp = ListParams::default().labels(&selector);
2067
2068 let arecords = api.list(&lp).await?;
2069 for ar in arecords.items {
2070 let ar_name = ar.name_any();
2071 api.delete(&ar_name, &DeleteParams::default()).await?;
2072 info!(
2073 arecord = %ar_name,
2074 tlsroute = %route_name,
2075 "Deleted stale TLSRoute ARecord from previous cluster name"
2076 );
2077 }
2078 Ok(())
2079}
2080
2081async fn delete_arecords_for_tcproute(
2083 remote_client: &Client,
2084 target_namespace: &str,
2085 cluster: &str,
2086 route_namespace: &str,
2087 route_name: &str,
2088) -> Result<()> {
2089 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2090 let selector = tcproute_arecord_label_selector(cluster, route_namespace, route_name);
2091 let lp = ListParams::default().labels(&selector);
2092
2093 let arecords = api.list(&lp).await?;
2094 for ar in arecords.items {
2095 let ar_name = ar.name_any();
2096 api.delete(&ar_name, &DeleteParams::default()).await?;
2097 info!(
2098 arecord = %ar_name,
2099 tcproute = %route_name,
2100 ns = %route_namespace,
2101 "Deleted ARecord during TCPRoute cleanup"
2102 );
2103 }
2104 Ok(())
2105}
2106
2107async fn delete_stale_cluster_tcproute_arecords(
2109 remote_client: &Client,
2110 target_namespace: &str,
2111 current_cluster: &str,
2112 route_namespace: &str,
2113 route_name: &str,
2114) -> Result<()> {
2115 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2116 let selector =
2117 stale_tcproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2118 let lp = ListParams::default().labels(&selector);
2119
2120 let arecords = api.list(&lp).await?;
2121 for ar in arecords.items {
2122 let ar_name = ar.name_any();
2123 api.delete(&ar_name, &DeleteParams::default()).await?;
2124 info!(
2125 arecord = %ar_name,
2126 tcproute = %route_name,
2127 "Deleted stale TCPRoute ARecord from previous cluster name"
2128 );
2129 }
2130 Ok(())
2131}
2132
2133async fn reconcile(ingress: Arc<Ingress>, ctx: Arc<ScoutContext>) -> Result<Action, ScoutError> {
2148 let name = ingress.name_any();
2149 let namespace = ingress.namespace().unwrap_or_default();
2150
2151 if ctx.excluded_namespaces.contains(&namespace) {
2153 debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
2154 return Ok(Action::await_change());
2155 }
2156
2157 if is_being_deleted(&ingress) {
2159 if has_finalizer(&ingress) {
2160 info!(ingress = %name, ns = %namespace, "Ingress deleting — cleaning up ARecords");
2161 let cleanup: Result<()> = async {
2162 delete_arecords_for_ingress(
2163 &ctx.remote_client,
2164 &ctx.target_namespace,
2165 &ctx.cluster_name,
2166 &namespace,
2167 &name,
2168 )
2169 .await?;
2170 delete_stale_cluster_arecords(
2171 &ctx.remote_client,
2172 &ctx.target_namespace,
2173 &ctx.cluster_name,
2174 &namespace,
2175 &name,
2176 )
2177 .await
2178 }
2179 .await;
2180 if let Err(e) = cleanup {
2181 if !cleanup_grace_expired(
2182 ingress.metadata.deletion_timestamp.as_ref(),
2183 Timestamp::now(),
2184 ) {
2185 warn!(ingress = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Ingress deletion — retrying within grace period");
2186 return Ok(Action::requeue(Duration::from_secs(
2187 SCOUT_ERROR_REQUEUE_SECS,
2188 )));
2189 }
2190 error!(ingress = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock Ingress deletion; remote ARecords may be orphaned and must be reconciled separately");
2191 }
2192 remove_finalizer(&ctx.client, &ingress)
2193 .await
2194 .map_err(ScoutError::from)?;
2195 info!(ingress = %name, ns = %namespace, "Finalizer removed — Ingress deletion unblocked");
2196 }
2197 return Ok(Action::await_change());
2198 }
2199
2200 let annotations = ingress
2201 .metadata
2202 .annotations
2203 .as_ref()
2204 .cloned()
2205 .unwrap_or_default();
2206
2207 let namespace_eligible =
2209 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2210 .await
2211 .map_err(ScoutError::from)?;
2212
2213 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2214 if has_finalizer(&ingress) {
2216 info!(ingress = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2217 delete_arecords_for_ingress(
2218 &ctx.remote_client,
2219 &ctx.target_namespace,
2220 &ctx.cluster_name,
2221 &namespace,
2222 &name,
2223 )
2224 .await
2225 .map_err(ScoutError::from)?;
2226 delete_stale_cluster_arecords(
2227 &ctx.remote_client,
2228 &ctx.target_namespace,
2229 &ctx.cluster_name,
2230 &namespace,
2231 &name,
2232 )
2233 .await
2234 .map_err(ScoutError::from)?;
2235 remove_finalizer(&ctx.client, &ingress)
2236 .await
2237 .map_err(ScoutError::from)?;
2238 }
2239 debug!(ingress = %name, ns = %namespace, "No arecord annotation — skipping");
2240 return Ok(Action::await_change());
2241 }
2242
2243 if !has_finalizer(&ingress) {
2247 add_finalizer(&ctx.client, &ingress)
2248 .await
2249 .map_err(ScoutError::from)?;
2250 debug!(ingress = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2251 return Ok(Action::await_change());
2252 }
2253
2254 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2256 Some(z) => z,
2257 None => {
2258 warn!(ingress = %name, ns = %namespace, "No DNS zone available (set bindy.firestoned.io/zone annotation or BINDY_SCOUT_DEFAULT_ZONE) — skipping");
2259 return Ok(Action::requeue(Duration::from_secs(
2260 SCOUT_ERROR_REQUEUE_SECS,
2261 )));
2262 }
2263 };
2264
2265 match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2269 ZoneAuthz::Authorized => {}
2270 ZoneAuthz::Forbidden => {
2271 warn!(
2272 ingress = %name, ns = %namespace, zone = %zone,
2273 "Ingress namespace not authorized for zone — the DNSZone must live in this \
2274 namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it \
2275 (or '*') — skipping"
2276 );
2277 return Ok(Action::requeue(Duration::from_secs(
2278 SCOUT_ERROR_REQUEUE_SECS,
2279 )));
2280 }
2281 ZoneAuthz::NotFound => {
2282 warn!(
2283 ingress = %name, ns = %namespace, zone = %zone,
2284 "Zone not found in DNSZone store — skipping until zone appears"
2285 );
2286 return Ok(Action::requeue(Duration::from_secs(
2287 SCOUT_ERROR_REQUEUE_SECS,
2288 )));
2289 }
2290 }
2291
2292 let ips = match resolve_ips(&annotations, &ctx.default_ips, &ingress) {
2294 Some(ips) => ips,
2295 None => {
2296 warn!(ingress = %name, ns = %namespace, "No IP available (no annotation override, no default IPs, no LB status IP) — requeuing");
2297 return Ok(Action::requeue(Duration::from_secs(
2298 SCOUT_ERROR_REQUEUE_SECS,
2299 )));
2300 }
2301 };
2302
2303 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2305
2306 let spec_rules = ingress
2307 .spec
2308 .as_ref()
2309 .and_then(|s| s.rules.as_ref())
2310 .cloned()
2311 .unwrap_or_default();
2312
2313 let arecord_api: Api<ARecord> =
2314 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2315
2316 for (idx, rule) in spec_rules.iter().enumerate() {
2317 let host = match rule.host.as_deref() {
2318 Some(h) if !h.is_empty() => h,
2319 _ => {
2320 debug!(ingress = %name, rule_index = idx, "Ingress rule has no host — skipping");
2321 continue;
2322 }
2323 };
2324
2325 let record_name = match resolve_record_name(&annotations, host, &zone) {
2326 Ok(n) => n,
2327 Err(e) => {
2328 warn!(ingress = %name, host = %host, zone = %zone, error = %e, "Host does not belong to zone — skipping rule");
2329 continue;
2330 }
2331 };
2332
2333 let cr_name = arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
2334 let arecord = build_arecord(ARecordParams {
2335 name: &cr_name,
2336 target_namespace: &ctx.target_namespace,
2337 record_name: &record_name,
2338 ips: &ips,
2339 ttl,
2340 cluster_name: &ctx.cluster_name,
2341 ingress_namespace: &namespace,
2342 ingress_name: &name,
2343 zone: &zone,
2344 });
2345
2346 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2348 match arecord_api
2349 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2350 .await
2351 {
2352 Ok(_) => {
2353 info!(arecord = %cr_name, ingress = %name, host = %host, ips = ?ips, "ARecord created/updated");
2354 }
2355 Err(e) => {
2356 error!(arecord = %cr_name, ingress = %name, error = %e, "Failed to apply ARecord");
2357 return Err(ScoutError::from(anyhow!(
2358 "Failed to apply ARecord {cr_name}: {e}"
2359 )));
2360 }
2361 }
2362 }
2363
2364 delete_stale_cluster_arecords(
2367 &ctx.remote_client,
2368 &ctx.target_namespace,
2369 &ctx.cluster_name,
2370 &namespace,
2371 &name,
2372 )
2373 .await
2374 .map_err(ScoutError::from)?;
2375
2376 Ok(Action::await_change())
2377}
2378
2379async fn reconcile_service(
2392 svc: Arc<Service>,
2393 ctx: Arc<ScoutContext>,
2394) -> Result<Action, ScoutError> {
2395 let name = svc.name_any();
2396 let namespace = svc.namespace().unwrap_or_default();
2397
2398 if ctx.excluded_namespaces.contains(&namespace) {
2399 debug!(service = %name, ns = %namespace, "Skipping excluded namespace");
2400 return Ok(Action::await_change());
2401 }
2402
2403 if svc.metadata.deletion_timestamp.is_some() {
2405 if svc
2406 .metadata
2407 .finalizers
2408 .as_ref()
2409 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2410 .unwrap_or(false)
2411 {
2412 info!(service = %name, ns = %namespace, "Service deleting — cleaning up ARecord");
2413 if let Err(e) = delete_arecords_for_service(
2414 &ctx.remote_client,
2415 &ctx.target_namespace,
2416 &ctx.cluster_name,
2417 &namespace,
2418 &name,
2419 )
2420 .await
2421 {
2422 if !cleanup_grace_expired(
2423 svc.metadata.deletion_timestamp.as_ref(),
2424 Timestamp::now(),
2425 ) {
2426 warn!(service = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Service deletion — retrying within grace period");
2427 return Ok(Action::requeue(Duration::from_secs(
2428 SCOUT_ERROR_REQUEUE_SECS,
2429 )));
2430 }
2431 error!(service = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock Service deletion; remote ARecords may be orphaned and must be reconciled separately");
2432 }
2433 remove_finalizer_from_service(&ctx.client, &svc)
2434 .await
2435 .map_err(ScoutError::from)?;
2436 info!(service = %name, ns = %namespace, "Finalizer removed — Service deletion unblocked");
2437 }
2438 return Ok(Action::await_change());
2439 }
2440
2441 let annotations = svc
2442 .metadata
2443 .annotations
2444 .as_ref()
2445 .cloned()
2446 .unwrap_or_default();
2447
2448 let namespace_eligible =
2450 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2451 .await
2452 .map_err(ScoutError::from)?;
2453
2454 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2455 let has_fin = svc
2456 .metadata
2457 .finalizers
2458 .as_ref()
2459 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2460 .unwrap_or(false);
2461 if has_fin {
2462 info!(service = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecord and finalizer");
2463 delete_arecords_for_service(
2464 &ctx.remote_client,
2465 &ctx.target_namespace,
2466 &ctx.cluster_name,
2467 &namespace,
2468 &name,
2469 )
2470 .await
2471 .map_err(ScoutError::from)?;
2472 remove_finalizer_from_service(&ctx.client, &svc)
2473 .await
2474 .map_err(ScoutError::from)?;
2475 }
2476 debug!(service = %name, ns = %namespace, "No scout-enabled annotation — skipping");
2477 return Ok(Action::await_change());
2478 }
2479
2480 if !is_loadbalancer_service(&svc) {
2482 debug!(service = %name, ns = %namespace, "Service is not LoadBalancer type — skipping");
2483 return Ok(Action::await_change());
2484 }
2485
2486 let has_fin = svc
2488 .metadata
2489 .finalizers
2490 .as_ref()
2491 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2492 .unwrap_or(false);
2493 if !has_fin {
2494 add_finalizer_to_service(&ctx.client, &svc)
2495 .await
2496 .map_err(ScoutError::from)?;
2497 debug!(service = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2498 return Ok(Action::await_change());
2499 }
2500
2501 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2503 Some(z) => z,
2504 None => {
2505 warn!(service = %name, ns = %namespace, "No DNS zone available — skipping");
2506 return Ok(Action::requeue(Duration::from_secs(
2507 SCOUT_ERROR_REQUEUE_SECS,
2508 )));
2509 }
2510 };
2511
2512 match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2515 ZoneAuthz::Authorized => {}
2516 ZoneAuthz::Forbidden => {
2517 warn!(service = %name, ns = %namespace, zone = %zone, "Service namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
2518 return Ok(Action::requeue(Duration::from_secs(
2519 SCOUT_ERROR_REQUEUE_SECS,
2520 )));
2521 }
2522 ZoneAuthz::NotFound => {
2523 warn!(service = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
2524 return Ok(Action::requeue(Duration::from_secs(
2525 SCOUT_ERROR_REQUEUE_SECS,
2526 )));
2527 }
2528 }
2529
2530 let ips = {
2532 let from_annotation = resolve_ips_from_annotation(&annotations);
2533 let from_defaults = if ctx.default_ips.is_empty() {
2534 None
2535 } else {
2536 Some(ctx.default_ips.clone())
2537 };
2538 let from_lb = resolve_ip_from_service_lb_status(&svc).map(|ip| vec![ip]);
2539
2540 match from_annotation.or(from_defaults).or(from_lb) {
2541 Some(ips) => ips,
2542 None => {
2543 warn!(service = %name, ns = %namespace, "No external IP yet — requeuing in {}s", SCOUT_ERROR_REQUEUE_SECS);
2544 return Ok(Action::requeue(Duration::from_secs(
2545 SCOUT_ERROR_REQUEUE_SECS,
2546 )));
2547 }
2548 }
2549 };
2550
2551 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2552
2553 let fqdn = format!("{name}.{zone}");
2555 let record_name = match resolve_record_name(&annotations, &fqdn, &zone) {
2556 Ok(n) => n,
2557 Err(e) => {
2558 warn!(service = %name, zone = %zone, error = %e, "Cannot derive record name — skipping");
2559 return Ok(Action::requeue(Duration::from_secs(
2560 SCOUT_ERROR_REQUEUE_SECS,
2561 )));
2562 }
2563 };
2564
2565 let cr_name = service_arecord_cr_name(&ctx.cluster_name, &namespace, &name);
2566 let arecord = build_service_arecord(ServiceARecordParams {
2567 name: &cr_name,
2568 target_namespace: &ctx.target_namespace,
2569 record_name: &record_name,
2570 ips: &ips,
2571 ttl,
2572 cluster_name: &ctx.cluster_name,
2573 service_namespace: &namespace,
2574 service_name: &name,
2575 zone: &zone,
2576 });
2577
2578 let arecord_api: Api<ARecord> =
2579 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2580 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2581 match arecord_api
2582 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2583 .await
2584 {
2585 Ok(_) => {
2586 info!(arecord = %cr_name, service = %name, ips = ?ips, "ARecord created/updated for Service");
2587 }
2588 Err(e) => {
2589 error!(arecord = %cr_name, service = %name, error = %e, "Failed to apply ARecord for Service");
2590 return Err(ScoutError::from(anyhow!(
2591 "Failed to apply ARecord {cr_name}: {e}"
2592 )));
2593 }
2594 }
2595
2596 Ok(Action::await_change())
2597}
2598
2599fn service_error_policy(_obj: Arc<Service>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2601 error!(error = %error, "Scout service reconcile error — requeuing");
2602 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2603}
2604
2605fn error_policy(_obj: Arc<Ingress>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2607 error!(error = %error, "Scout reconcile error — requeuing");
2608 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2609}
2610
2611async fn reconcile_httproute(
2636 route: Arc<HTTPRoute>,
2637 ctx: Arc<ScoutContext>,
2638) -> Result<Action, ScoutError> {
2639 let name = route.name_any();
2640 let namespace = route.namespace().unwrap_or_default();
2641
2642 if ctx.excluded_namespaces.contains(&namespace) {
2644 debug!(httproute = %name, ns = %namespace, "Skipping excluded namespace");
2645 return Ok(Action::await_change());
2646 }
2647
2648 if route.metadata.deletion_timestamp.is_some() {
2650 if route
2651 .metadata
2652 .finalizers
2653 .as_ref()
2654 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2655 .unwrap_or(false)
2656 {
2657 info!(httproute = %name, ns = %namespace, "HTTPRoute deleting — cleaning up ARecords");
2658 let cleanup: Result<()> = async {
2659 delete_arecords_for_httproute(
2660 &ctx.remote_client,
2661 &ctx.target_namespace,
2662 &ctx.cluster_name,
2663 &namespace,
2664 &name,
2665 )
2666 .await?;
2667 delete_stale_cluster_httproute_arecords(
2668 &ctx.remote_client,
2669 &ctx.target_namespace,
2670 &ctx.cluster_name,
2671 &namespace,
2672 &name,
2673 )
2674 .await
2675 }
2676 .await;
2677 if let Err(e) = cleanup {
2678 if !cleanup_grace_expired(
2679 route.metadata.deletion_timestamp.as_ref(),
2680 Timestamp::now(),
2681 ) {
2682 warn!(httproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during HTTPRoute deletion — retrying within grace period");
2683 return Ok(Action::requeue(Duration::from_secs(
2684 SCOUT_ERROR_REQUEUE_SECS,
2685 )));
2686 }
2687 error!(httproute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock HTTPRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
2688 }
2689 remove_finalizer_from_httproute(&ctx.client, &route)
2690 .await
2691 .map_err(ScoutError::from)?;
2692 info!(httproute = %name, ns = %namespace, "Finalizer removed — HTTPRoute deletion unblocked");
2693 }
2694 return Ok(Action::await_change());
2695 }
2696
2697 let annotations = route
2698 .metadata
2699 .annotations
2700 .as_ref()
2701 .cloned()
2702 .unwrap_or_default();
2703
2704 let namespace_eligible =
2706 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2707 .await
2708 .map_err(ScoutError::from)?;
2709
2710 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2711 let has_fin = route
2712 .metadata
2713 .finalizers
2714 .as_ref()
2715 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2716 .unwrap_or(false);
2717 if has_fin {
2718 info!(httproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2719 delete_arecords_for_httproute(
2720 &ctx.remote_client,
2721 &ctx.target_namespace,
2722 &ctx.cluster_name,
2723 &namespace,
2724 &name,
2725 )
2726 .await
2727 .map_err(ScoutError::from)?;
2728 delete_stale_cluster_httproute_arecords(
2729 &ctx.remote_client,
2730 &ctx.target_namespace,
2731 &ctx.cluster_name,
2732 &namespace,
2733 &name,
2734 )
2735 .await
2736 .map_err(ScoutError::from)?;
2737 remove_finalizer_from_httproute(&ctx.client, &route)
2738 .await
2739 .map_err(ScoutError::from)?;
2740 }
2741 debug!(httproute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
2742 return Ok(Action::await_change());
2743 }
2744
2745 let has_fin = route
2747 .metadata
2748 .finalizers
2749 .as_ref()
2750 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2751 .unwrap_or(false);
2752 if !has_fin {
2753 add_finalizer_to_httproute(&ctx.client, &route)
2754 .await
2755 .map_err(ScoutError::from)?;
2756 debug!(httproute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2757 return Ok(Action::await_change());
2758 }
2759
2760 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2762 Some(z) => z,
2763 None => {
2764 warn!(httproute = %name, ns = %namespace, "No DNS zone available — skipping");
2765 return Ok(Action::requeue(Duration::from_secs(
2766 SCOUT_ERROR_REQUEUE_SECS,
2767 )));
2768 }
2769 };
2770
2771 match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2774 ZoneAuthz::Authorized => {}
2775 ZoneAuthz::Forbidden => {
2776 warn!(httproute = %name, ns = %namespace, zone = %zone, "HTTPRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
2777 return Ok(Action::requeue(Duration::from_secs(
2778 SCOUT_ERROR_REQUEUE_SECS,
2779 )));
2780 }
2781 ZoneAuthz::NotFound => {
2782 warn!(httproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
2783 return Ok(Action::requeue(Duration::from_secs(
2784 SCOUT_ERROR_REQUEUE_SECS,
2785 )));
2786 }
2787 }
2788
2789 let ips = {
2792 let from_annotation = resolve_ips_from_annotation(&annotations);
2793 let from_gateway = if from_annotation.is_some() {
2794 None
2795 } else {
2796 let parent_refs = route
2797 .spec
2798 .as_ref()
2799 .and_then(|s| s.parent_refs.as_ref())
2800 .cloned()
2801 .unwrap_or_default();
2802 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
2803 .await
2804 };
2805 let from_defaults = if ctx.default_ips.is_empty() {
2806 None
2807 } else {
2808 Some(ctx.default_ips.clone())
2809 };
2810
2811 match from_annotation.or(from_gateway).or(from_defaults) {
2812 Some(ips) => ips,
2813 None => {
2814 warn!(httproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
2815 return Ok(Action::requeue(Duration::from_secs(
2816 SCOUT_ERROR_REQUEUE_SECS,
2817 )));
2818 }
2819 }
2820 };
2821
2822 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2823
2824 let hostnames = route
2826 .spec
2827 .as_ref()
2828 .and_then(|s| s.hostnames.as_ref())
2829 .cloned()
2830 .unwrap_or_default();
2831
2832 let arecord_api: Api<ARecord> =
2833 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2834
2835 for (idx, hostname) in hostnames.iter().enumerate() {
2836 if hostname.is_empty() {
2837 debug!(httproute = %name, hostname_index = idx, "HTTPRoute hostname is empty — skipping");
2838 continue;
2839 }
2840
2841 let record_name = match resolve_record_name(&annotations, hostname, &zone) {
2842 Ok(n) => n,
2843 Err(e) => {
2844 warn!(httproute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
2845 continue;
2846 }
2847 };
2848
2849 let cr_name = httproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
2850 let arecord = build_httproute_arecord(HTTPRouteARecordParams {
2851 name: &cr_name,
2852 target_namespace: &ctx.target_namespace,
2853 record_name: &record_name,
2854 ips: &ips,
2855 ttl,
2856 cluster_name: &ctx.cluster_name,
2857 route_namespace: &namespace,
2858 route_name: &name,
2859 zone: &zone,
2860 });
2861
2862 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2864 match arecord_api
2865 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2866 .await
2867 {
2868 Ok(_) => {
2869 info!(arecord = %cr_name, httproute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for HTTPRoute");
2870 }
2871 Err(e) => {
2872 error!(arecord = %cr_name, httproute = %name, error = %e, "Failed to apply ARecord for HTTPRoute");
2873 return Err(ScoutError::from(anyhow!(
2874 "Failed to apply ARecord {cr_name}: {e}"
2875 )));
2876 }
2877 }
2878 }
2879
2880 delete_stale_cluster_httproute_arecords(
2882 &ctx.remote_client,
2883 &ctx.target_namespace,
2884 &ctx.cluster_name,
2885 &namespace,
2886 &name,
2887 )
2888 .await
2889 .map_err(ScoutError::from)?;
2890
2891 Ok(Action::await_change())
2892}
2893
2894async fn reconcile_tlsroute(
2903 route: Arc<TLSRoute>,
2904 ctx: Arc<ScoutContext>,
2905) -> Result<Action, ScoutError> {
2906 let name = route.name_any();
2907 let namespace = route.namespace().unwrap_or_default();
2908
2909 if ctx.excluded_namespaces.contains(&namespace) {
2911 debug!(tlsroute = %name, ns = %namespace, "Skipping excluded namespace");
2912 return Ok(Action::await_change());
2913 }
2914
2915 if route.metadata.deletion_timestamp.is_some() {
2917 if route
2918 .metadata
2919 .finalizers
2920 .as_ref()
2921 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2922 .unwrap_or(false)
2923 {
2924 info!(tlsroute = %name, ns = %namespace, "TLSRoute deleting — cleaning up ARecords");
2925 let cleanup: Result<()> = async {
2926 delete_arecords_for_tlsroute(
2927 &ctx.remote_client,
2928 &ctx.target_namespace,
2929 &ctx.cluster_name,
2930 &namespace,
2931 &name,
2932 )
2933 .await?;
2934 delete_stale_cluster_tlsroute_arecords(
2935 &ctx.remote_client,
2936 &ctx.target_namespace,
2937 &ctx.cluster_name,
2938 &namespace,
2939 &name,
2940 )
2941 .await
2942 }
2943 .await;
2944 if let Err(e) = cleanup {
2945 if !cleanup_grace_expired(
2946 route.metadata.deletion_timestamp.as_ref(),
2947 Timestamp::now(),
2948 ) {
2949 warn!(tlsroute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TLSRoute deletion — retrying within grace period");
2950 return Ok(Action::requeue(Duration::from_secs(
2951 SCOUT_ERROR_REQUEUE_SECS,
2952 )));
2953 }
2954 error!(tlsroute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock TLSRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
2955 }
2956 remove_finalizer_from_tlsroute(&ctx.client, &route)
2957 .await
2958 .map_err(ScoutError::from)?;
2959 info!(tlsroute = %name, ns = %namespace, "Finalizer removed — TLSRoute deletion unblocked");
2960 }
2961 return Ok(Action::await_change());
2962 }
2963
2964 let annotations = route
2965 .metadata
2966 .annotations
2967 .as_ref()
2968 .cloned()
2969 .unwrap_or_default();
2970
2971 let namespace_eligible =
2973 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2974 .await
2975 .map_err(ScoutError::from)?;
2976
2977 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2978 let has_fin = route
2979 .metadata
2980 .finalizers
2981 .as_ref()
2982 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2983 .unwrap_or(false);
2984 if has_fin {
2985 info!(tlsroute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2986 delete_arecords_for_tlsroute(
2987 &ctx.remote_client,
2988 &ctx.target_namespace,
2989 &ctx.cluster_name,
2990 &namespace,
2991 &name,
2992 )
2993 .await
2994 .map_err(ScoutError::from)?;
2995 delete_stale_cluster_tlsroute_arecords(
2996 &ctx.remote_client,
2997 &ctx.target_namespace,
2998 &ctx.cluster_name,
2999 &namespace,
3000 &name,
3001 )
3002 .await
3003 .map_err(ScoutError::from)?;
3004 remove_finalizer_from_tlsroute(&ctx.client, &route)
3005 .await
3006 .map_err(ScoutError::from)?;
3007 }
3008 debug!(tlsroute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3009 return Ok(Action::await_change());
3010 }
3011
3012 let has_fin = route
3014 .metadata
3015 .finalizers
3016 .as_ref()
3017 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3018 .unwrap_or(false);
3019 if !has_fin {
3020 add_finalizer_to_tlsroute(&ctx.client, &route)
3021 .await
3022 .map_err(ScoutError::from)?;
3023 debug!(tlsroute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3024 return Ok(Action::await_change());
3025 }
3026
3027 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3029 Some(z) => z,
3030 None => {
3031 warn!(tlsroute = %name, ns = %namespace, "No DNS zone available — skipping");
3032 return Ok(Action::requeue(Duration::from_secs(
3033 SCOUT_ERROR_REQUEUE_SECS,
3034 )));
3035 }
3036 };
3037
3038 match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
3041 ZoneAuthz::Authorized => {}
3042 ZoneAuthz::Forbidden => {
3043 warn!(tlsroute = %name, ns = %namespace, zone = %zone, "TLSRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
3044 return Ok(Action::requeue(Duration::from_secs(
3045 SCOUT_ERROR_REQUEUE_SECS,
3046 )));
3047 }
3048 ZoneAuthz::NotFound => {
3049 warn!(tlsroute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3050 return Ok(Action::requeue(Duration::from_secs(
3051 SCOUT_ERROR_REQUEUE_SECS,
3052 )));
3053 }
3054 }
3055
3056 let ips = {
3059 let from_annotation = resolve_ips_from_annotation(&annotations);
3060 let from_gateway = if from_annotation.is_some() {
3061 None
3062 } else {
3063 let parent_refs = route
3064 .spec
3065 .as_ref()
3066 .and_then(|s| s.parent_refs.as_ref())
3067 .cloned()
3068 .unwrap_or_default();
3069 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3070 .await
3071 };
3072 let from_defaults = if ctx.default_ips.is_empty() {
3073 None
3074 } else {
3075 Some(ctx.default_ips.clone())
3076 };
3077
3078 match from_annotation.or(from_gateway).or(from_defaults) {
3079 Some(ips) => ips,
3080 None => {
3081 warn!(tlsroute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3082 return Ok(Action::requeue(Duration::from_secs(
3083 SCOUT_ERROR_REQUEUE_SECS,
3084 )));
3085 }
3086 }
3087 };
3088
3089 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3090
3091 let hostnames = route
3093 .spec
3094 .as_ref()
3095 .and_then(|s| s.hostnames.as_ref())
3096 .cloned()
3097 .unwrap_or_default();
3098
3099 let arecord_api: Api<ARecord> =
3100 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3101
3102 for (idx, hostname) in hostnames.iter().enumerate() {
3103 if hostname.is_empty() {
3104 debug!(tlsroute = %name, hostname_index = idx, "TLSRoute hostname is empty — skipping");
3105 continue;
3106 }
3107
3108 let record_name = match resolve_record_name(&annotations, hostname, &zone) {
3109 Ok(n) => n,
3110 Err(e) => {
3111 warn!(tlsroute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
3112 continue;
3113 }
3114 };
3115
3116 let cr_name = tlsroute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
3117 let arecord = build_tlsroute_arecord(TLSRouteARecordParams {
3118 name: &cr_name,
3119 target_namespace: &ctx.target_namespace,
3120 record_name: &record_name,
3121 ips: &ips,
3122 ttl,
3123 cluster_name: &ctx.cluster_name,
3124 route_namespace: &namespace,
3125 route_name: &name,
3126 zone: &zone,
3127 });
3128
3129 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3131 match arecord_api
3132 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3133 .await
3134 {
3135 Ok(_) => {
3136 info!(arecord = %cr_name, tlsroute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for TLSRoute");
3137 }
3138 Err(e) => {
3139 error!(arecord = %cr_name, tlsroute = %name, error = %e, "Failed to apply ARecord for TLSRoute");
3140 return Err(ScoutError::from(anyhow!(
3141 "Failed to apply ARecord {cr_name}: {e}"
3142 )));
3143 }
3144 }
3145 }
3146
3147 delete_stale_cluster_tlsroute_arecords(
3149 &ctx.remote_client,
3150 &ctx.target_namespace,
3151 &ctx.cluster_name,
3152 &namespace,
3153 &name,
3154 )
3155 .await
3156 .map_err(ScoutError::from)?;
3157
3158 Ok(Action::await_change())
3159}
3160
3161async fn reconcile_tcproute(
3163 route: Arc<TCPRoute>,
3164 ctx: Arc<ScoutContext>,
3165) -> Result<Action, ScoutError> {
3166 let name = route.name_any();
3167 let namespace = route.namespace().unwrap_or_default();
3168
3169 if ctx.excluded_namespaces.contains(&namespace) {
3170 debug!(tcproute = %name, ns = %namespace, "Skipping excluded namespace");
3171 return Ok(Action::await_change());
3172 }
3173
3174 if route.metadata.deletion_timestamp.is_some() {
3175 if route
3176 .metadata
3177 .finalizers
3178 .as_ref()
3179 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3180 .unwrap_or(false)
3181 {
3182 info!(tcproute = %name, ns = %namespace, "TCPRoute deleting — cleaning up ARecords");
3183 let cleanup: Result<()> = async {
3184 delete_arecords_for_tcproute(
3185 &ctx.remote_client,
3186 &ctx.target_namespace,
3187 &ctx.cluster_name,
3188 &namespace,
3189 &name,
3190 )
3191 .await?;
3192 delete_stale_cluster_tcproute_arecords(
3193 &ctx.remote_client,
3194 &ctx.target_namespace,
3195 &ctx.cluster_name,
3196 &namespace,
3197 &name,
3198 )
3199 .await
3200 }
3201 .await;
3202 if let Err(e) = cleanup {
3203 if !cleanup_grace_expired(
3204 route.metadata.deletion_timestamp.as_ref(),
3205 Timestamp::now(),
3206 ) {
3207 warn!(tcproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TCPRoute deletion — retrying within grace period");
3208 return Ok(Action::requeue(Duration::from_secs(
3209 SCOUT_ERROR_REQUEUE_SECS,
3210 )));
3211 }
3212 error!(tcproute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock TCPRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
3213 }
3214 remove_finalizer_from_tcproute(&ctx.client, &route)
3215 .await
3216 .map_err(ScoutError::from)?;
3217 info!(tcproute = %name, ns = %namespace, "Finalizer removed — TCPRoute deletion unblocked");
3218 }
3219 return Ok(Action::await_change());
3220 }
3221
3222 let annotations = route
3223 .metadata
3224 .annotations
3225 .as_ref()
3226 .cloned()
3227 .unwrap_or_default();
3228
3229 let namespace_eligible =
3230 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
3231 .await
3232 .map_err(ScoutError::from)?;
3233
3234 if !is_scout_opted_in(&annotations) || !namespace_eligible {
3235 let has_fin = route
3236 .metadata
3237 .finalizers
3238 .as_ref()
3239 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3240 .unwrap_or(false);
3241 if has_fin {
3242 info!(tcproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
3243 delete_arecords_for_tcproute(
3244 &ctx.remote_client,
3245 &ctx.target_namespace,
3246 &ctx.cluster_name,
3247 &namespace,
3248 &name,
3249 )
3250 .await
3251 .map_err(ScoutError::from)?;
3252 delete_stale_cluster_tcproute_arecords(
3253 &ctx.remote_client,
3254 &ctx.target_namespace,
3255 &ctx.cluster_name,
3256 &namespace,
3257 &name,
3258 )
3259 .await
3260 .map_err(ScoutError::from)?;
3261 remove_finalizer_from_tcproute(&ctx.client, &route)
3262 .await
3263 .map_err(ScoutError::from)?;
3264 }
3265 debug!(tcproute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3266 return Ok(Action::await_change());
3267 }
3268
3269 let has_fin = route
3270 .metadata
3271 .finalizers
3272 .as_ref()
3273 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3274 .unwrap_or(false);
3275 if !has_fin {
3276 add_finalizer_to_tcproute(&ctx.client, &route)
3277 .await
3278 .map_err(ScoutError::from)?;
3279 debug!(tcproute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3280 return Ok(Action::await_change());
3281 }
3282
3283 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3284 Some(z) => z,
3285 None => {
3286 warn!(tcproute = %name, ns = %namespace, "No DNS zone available — skipping");
3287 return Ok(Action::requeue(Duration::from_secs(
3288 SCOUT_ERROR_REQUEUE_SECS,
3289 )));
3290 }
3291 };
3292
3293 match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
3294 ZoneAuthz::Authorized => {}
3295 ZoneAuthz::Forbidden => {
3296 warn!(tcproute = %name, ns = %namespace, zone = %zone, "TCPRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
3297 return Ok(Action::requeue(Duration::from_secs(
3298 SCOUT_ERROR_REQUEUE_SECS,
3299 )));
3300 }
3301 ZoneAuthz::NotFound => {
3302 warn!(tcproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3303 return Ok(Action::requeue(Duration::from_secs(
3304 SCOUT_ERROR_REQUEUE_SECS,
3305 )));
3306 }
3307 }
3308
3309 let ips = {
3310 let from_annotation = resolve_ips_from_annotation(&annotations);
3311 let from_gateway = if from_annotation.is_some() {
3312 None
3313 } else {
3314 let parent_refs = route
3315 .spec
3316 .as_ref()
3317 .and_then(|s| s.parent_refs.as_ref())
3318 .cloned()
3319 .unwrap_or_default();
3320 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3321 .await
3322 };
3323 let from_defaults = if ctx.default_ips.is_empty() {
3324 None
3325 } else {
3326 Some(ctx.default_ips.clone())
3327 };
3328
3329 match from_annotation.or(from_gateway).or(from_defaults) {
3330 Some(ips) => ips,
3331 None => {
3332 warn!(tcproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3333 return Ok(Action::requeue(Duration::from_secs(
3334 SCOUT_ERROR_REQUEUE_SECS,
3335 )));
3336 }
3337 }
3338 };
3339
3340 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3341
3342 let Some(record_name) = get_record_name_annotation(&annotations) else {
3343 warn!(tcproute = %name, ns = %namespace, "TCPRoute has no record-name override — skipping (add bindy.firestoned.io/record-name annotation)");
3344 return Ok(Action::requeue(Duration::from_secs(
3345 SCOUT_ERROR_REQUEUE_SECS,
3346 )));
3347 };
3348
3349 let arecord_api: Api<ARecord> =
3350 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3351
3352 let cr_name = tcproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, 0);
3353 let arecord = build_tcproute_arecord(TCPRouteARecordParams {
3354 name: &cr_name,
3355 target_namespace: &ctx.target_namespace,
3356 record_name: &record_name,
3357 ips: &ips,
3358 ttl,
3359 cluster_name: &ctx.cluster_name,
3360 route_namespace: &namespace,
3361 route_name: &name,
3362 zone: &zone,
3363 });
3364
3365 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3366 match arecord_api
3367 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3368 .await
3369 {
3370 Ok(_) => {
3371 info!(arecord = %cr_name, tcproute = %name, record_name = %record_name, ips = ?ips, "ARecord created/updated for TCPRoute");
3372 }
3373 Err(e) => {
3374 error!(arecord = %cr_name, tcproute = %name, error = %e, "Failed to apply ARecord for TCPRoute");
3375 return Err(ScoutError::from(anyhow!(
3376 "Failed to apply ARecord {cr_name}: {e}"
3377 )));
3378 }
3379 }
3380
3381 delete_stale_cluster_tcproute_arecords(
3382 &ctx.remote_client,
3383 &ctx.target_namespace,
3384 &ctx.cluster_name,
3385 &namespace,
3386 &name,
3387 )
3388 .await
3389 .map_err(ScoutError::from)?;
3390
3391 Ok(Action::await_change())
3392}
3393
3394fn gateway_route_error_policy(
3396 _obj: Arc<HTTPRoute>,
3397 error: &ScoutError,
3398 _ctx: Arc<ScoutContext>,
3399) -> Action {
3400 error!(error = %error, "Scout HTTPRoute reconcile error — requeuing");
3401 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3402}
3403
3404fn tlsroute_error_policy(
3406 _obj: Arc<TLSRoute>,
3407 error: &ScoutError,
3408 _ctx: Arc<ScoutContext>,
3409) -> Action {
3410 error!(error = %error, "Scout TLSRoute reconcile error — requeuing");
3411 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3412}
3413
3414fn tcproute_error_policy(
3416 _obj: Arc<TCPRoute>,
3417 error: &ScoutError,
3418 _ctx: Arc<ScoutContext>,
3419) -> Action {
3420 error!(error = %error, "Scout TCPRoute reconcile error — requeuing");
3421 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3422}
3423
3424async fn build_remote_client(
3439 local_client: &Client,
3440 secret_name: &str,
3441 secret_namespace: &str,
3442) -> Result<Client> {
3443 let api: Api<Secret> = Api::namespaced(local_client.clone(), secret_namespace);
3444 let secret = api.get(secret_name).await.map_err(|e| {
3445 anyhow!("Failed to read kubeconfig Secret {secret_namespace}/{secret_name}: {e}")
3446 })?;
3447
3448 let kubeconfig_bytes = secret
3449 .data
3450 .as_ref()
3451 .and_then(|d| d.get("kubeconfig"))
3452 .ok_or_else(|| {
3453 anyhow!("Secret {secret_namespace}/{secret_name} has no 'kubeconfig' key in .data")
3454 })?;
3455
3456 let kubeconfig_str = std::str::from_utf8(&kubeconfig_bytes.0)
3457 .map_err(|e| anyhow!("kubeconfig in Secret is not valid UTF-8: {e}"))?;
3458
3459 let kubeconfig = Kubeconfig::from_yaml(kubeconfig_str)
3460 .map_err(|e| anyhow!("Failed to parse kubeconfig from Secret: {e}"))?;
3461
3462 let config = kube::Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
3463 .await
3464 .map_err(|e| anyhow!("Failed to build client config from kubeconfig: {e}"))?;
3465
3466 Client::try_from(config).map_err(|e| anyhow!("Failed to create remote Kubernetes client: {e}"))
3467}
3468
3469struct ScoutConfig {
3475 target_namespace: String,
3476 cluster_name: String,
3477 excluded_namespaces: Vec<String>,
3478 default_ips: Vec<String>,
3481 gateway_services: BTreeMap<String, GatewayServiceTarget>,
3484 default_zone: Option<String>,
3487 namespace_selector: Option<String>,
3491 remote_secret_name: Option<String>,
3494 remote_secret_namespace: String,
3496}
3497
3498impl ScoutConfig {
3499 fn from_env(
3503 cli_cluster_name: Option<String>,
3504 cli_namespace: Option<String>,
3505 cli_default_ips: Vec<String>,
3506 cli_gateway_services: Vec<String>,
3507 cli_default_zone: Option<String>,
3508 cli_namespace_selector: Option<String>,
3509 ) -> Result<Self> {
3510 let target_namespace = cli_namespace
3511 .filter(|s| !s.is_empty())
3512 .or_else(|| std::env::var("BINDY_SCOUT_NAMESPACE").ok())
3513 .unwrap_or_else(|| DEFAULT_SCOUT_NAMESPACE.to_string());
3514
3515 let cluster_name = cli_cluster_name
3516 .filter(|s| !s.is_empty())
3517 .or_else(|| std::env::var("BINDY_SCOUT_CLUSTER_NAME").ok())
3518 .ok_or_else(|| {
3519 anyhow!("BINDY_SCOUT_CLUSTER_NAME is required (set via --cluster-name or env var)")
3520 })?;
3521
3522 let own_namespace =
3523 std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string());
3524
3525 let mut excluded_namespaces: Vec<String> = std::env::var("BINDY_SCOUT_EXCLUDE_NAMESPACES")
3526 .unwrap_or_default()
3527 .split(',')
3528 .map(str::trim)
3529 .filter(|s| !s.is_empty())
3530 .map(ToString::to_string)
3531 .collect();
3532
3533 if !excluded_namespaces.contains(&own_namespace) {
3535 excluded_namespaces.push(own_namespace.clone());
3536 }
3537
3538 let default_ips = if !cli_default_ips.is_empty() {
3540 cli_default_ips
3541 } else {
3542 std::env::var("BINDY_SCOUT_DEFAULT_IPS")
3543 .unwrap_or_default()
3544 .split(',')
3545 .map(str::trim)
3546 .filter(|s| !s.is_empty())
3547 .map(ToString::to_string)
3548 .collect()
3549 };
3550
3551 let gateway_services = if cli_gateway_services.is_empty() {
3556 parse_gateway_services(
3557 &std::env::var("BINDY_SCOUT_GATEWAY_SERVICES").unwrap_or_default(),
3558 )
3559 } else {
3560 cli_gateway_services
3561 .iter()
3562 .filter_map(|e| parse_gateway_service_entry(e))
3563 .collect()
3564 };
3565
3566 let default_zone = cli_default_zone.filter(|s| !s.is_empty()).or_else(|| {
3568 std::env::var("BINDY_SCOUT_DEFAULT_ZONE")
3569 .ok()
3570 .filter(|s| !s.is_empty())
3571 });
3572
3573 let namespace_selector = cli_namespace_selector
3576 .filter(|s| !s.is_empty())
3577 .or_else(|| {
3578 std::env::var("BINDY_SCOUT_NAMESPACE_SELECTOR")
3579 .ok()
3580 .filter(|s| !s.is_empty())
3581 });
3582
3583 let remote_secret_name = std::env::var("BINDY_SCOUT_REMOTE_SECRET")
3584 .ok()
3585 .filter(|s| !s.is_empty());
3586
3587 let remote_secret_namespace =
3588 std::env::var("BINDY_SCOUT_REMOTE_SECRET_NAMESPACE").unwrap_or(own_namespace);
3589
3590 Ok(Self {
3591 target_namespace,
3592 cluster_name,
3593 excluded_namespaces,
3594 default_ips,
3595 gateway_services,
3596 default_zone,
3597 namespace_selector,
3598 remote_secret_name,
3599 remote_secret_namespace,
3600 })
3601 }
3602}
3603
3604fn diagnose_reflector_error(e: &watcher::Error) -> String {
3614 let (phase, client_err) = match e {
3617 watcher::Error::InitialListFailed(e) => ("initial list", e),
3618 watcher::Error::WatchStartFailed(e) => ("watch start", e),
3619 watcher::Error::WatchFailed(e) => ("watch stream", e),
3620 watcher::Error::WatchError(status) => {
3621 return format!(
3622 "API server returned error during watch: {} (HTTP {})",
3623 status.message, status.code
3624 );
3625 }
3626 watcher::Error::NoResourceVersion => {
3627 return "resource does not support watch (no resourceVersion returned)".to_string();
3628 }
3629 };
3630
3631 let detail = match client_err {
3632 KubeError::Api(status) => match status.code {
3633 401 => format!(
3634 "unauthorized — check credentials/token ({})",
3635 status.message
3636 ),
3637 403 => format!("forbidden — check RBAC permissions ({})", status.message),
3638 code => format!("API error HTTP {code} — {}", status.message),
3639 },
3640 KubeError::Auth(e) => format!("authentication error — {e}"),
3641 KubeError::Service(e) => format!("cannot connect to API server — {e}"),
3642 KubeError::HyperError(e) => format!("HTTP transport error — {e}"),
3643 other => format!("{other}"),
3644 };
3645
3646 format!("{phase} failed: {detail}")
3647}
3648
3649pub async fn run_scout(
3659 cli_cluster_name: Option<String>,
3660 cli_namespace: Option<String>,
3661 cli_default_ips: Vec<String>,
3662 cli_gateway_services: Vec<String>,
3663 cli_default_zone: Option<String>,
3664 cli_namespace_selector: Option<String>,
3665) -> Result<()> {
3666 let config = ScoutConfig::from_env(
3667 cli_cluster_name,
3668 cli_namespace,
3669 cli_default_ips,
3670 cli_gateway_services,
3671 cli_default_zone,
3672 cli_namespace_selector,
3673 )?;
3674
3675 let local_client = Client::try_default().await?;
3676
3677 let remote_client = if let Some(ref secret_name) = config.remote_secret_name {
3678 info!(
3679 cluster = %config.cluster_name,
3680 target_ns = %config.target_namespace,
3681 secret = %secret_name,
3682 secret_ns = %config.remote_secret_namespace,
3683 excluded = ?config.excluded_namespaces,
3684 default_ips = ?config.default_ips,
3685 default_zone = ?config.default_zone,
3686 namespace_selector = ?config.namespace_selector,
3687 "Starting bindy scout in remote cluster mode"
3688 );
3689 build_remote_client(&local_client, secret_name, &config.remote_secret_namespace).await?
3690 } else {
3691 info!(
3692 cluster = %config.cluster_name,
3693 target_ns = %config.target_namespace,
3694 excluded = ?config.excluded_namespaces,
3695 default_ips = ?config.default_ips,
3696 default_zone = ?config.default_zone,
3697 namespace_selector = ?config.namespace_selector,
3698 "Starting bindy scout in same-cluster mode"
3699 );
3700 local_client.clone()
3701 };
3702
3703 if config.namespace_selector.is_none() {
3704 warn!(
3705 "No --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR configured — scout will \
3706 act in EVERY namespace in the cluster (subject only to each source object's own \
3707 opt-in annotation and --exclude-namespaces). Setting a namespace-selector so scout \
3708 only considers explicitly-whitelisted namespaces is strongly recommended for \
3709 production deployments; running without one is not recommended."
3710 );
3711 }
3712
3713 let dnszone_api: Api<DNSZone> =
3719 Api::namespaced(remote_client.clone(), &config.target_namespace);
3720 let (dnszone_reader, dnszone_writer) = reflector::store();
3721 let dnszone_reflector = reflector(
3722 dnszone_writer,
3723 watcher(dnszone_api, WatcherConfig::default()),
3724 );
3725
3726 tokio::spawn(async move {
3731 dnszone_reflector
3732 .for_each(|event| async move {
3733 match event {
3734 Ok(_) => {}
3735 Err(e) => {
3736 error!(diagnosis = %diagnose_reflector_error(&e), "DNSZone reflector error");
3737 tokio::time::sleep(tokio::time::Duration::from_secs(
3738 REFLECTOR_ERROR_BACKOFF_SECS,
3739 ))
3740 .await;
3741 }
3742 }
3743 })
3744 .await;
3745 });
3746
3747 let ctx = Arc::new(ScoutContext {
3748 client: local_client.clone(),
3749 remote_client,
3750 target_namespace: config.target_namespace,
3751 cluster_name: config.cluster_name,
3752 excluded_namespaces: config.excluded_namespaces,
3753 default_ips: config.default_ips,
3754 gateway_services: config.gateway_services,
3755 default_zone: config.default_zone,
3756 namespace_selector: config.namespace_selector,
3757 zone_store: dnszone_reader,
3758 });
3759
3760 let ingress_api: Api<Ingress> = Api::all(local_client.clone());
3762 let svc_api: Api<Service> = Api::all(local_client.clone());
3764 let httproute_api: Api<HTTPRoute> = Api::all(local_client.clone());
3766 let tlsroute_api: Api<TLSRoute> = Api::all(local_client.clone());
3768 let tcproute_api: Api<TCPRoute> = Api::all(local_client.clone());
3770
3771 info!("Scout controller running — watching Ingresses, Services, HTTPRoutes, TLSRoutes, and TCPRoutes");
3772
3773 let ingress_controller = Controller::new(ingress_api, WatcherConfig::default())
3774 .run(reconcile, error_policy, ctx.clone())
3775 .for_each(|res| async move {
3776 match res {
3777 Ok(obj) => debug!(obj = ?obj, "Reconciled Ingress"),
3778 Err(e) => error!(error = %e, "Ingress reconcile failed"),
3779 }
3780 });
3781
3782 let service_controller = Controller::new(svc_api, WatcherConfig::default())
3783 .run(reconcile_service, service_error_policy, ctx.clone())
3784 .for_each(|res| async move {
3785 match res {
3786 Ok(obj) => debug!(obj = ?obj, "Reconciled Service"),
3787 Err(e) => error!(error = %e, "Service reconcile failed"),
3788 }
3789 });
3790
3791 let httproute_controller = Controller::new(httproute_api, WatcherConfig::default())
3792 .run(reconcile_httproute, gateway_route_error_policy, ctx.clone())
3793 .for_each(|res| async move {
3794 match res {
3795 Ok(obj) => debug!(obj = ?obj, "Reconciled HTTPRoute"),
3796 Err(e) => error!(error = %e, "HTTPRoute reconcile failed"),
3797 }
3798 });
3799
3800 let tlsroute_controller = Controller::new(tlsroute_api, WatcherConfig::default())
3801 .run(reconcile_tlsroute, tlsroute_error_policy, ctx.clone())
3802 .for_each(|res| async move {
3803 match res {
3804 Ok(obj) => debug!(obj = ?obj, "Reconciled TLSRoute"),
3805 Err(e) => error!(error = %e, "TLSRoute reconcile failed"),
3806 }
3807 });
3808
3809 let tcproute_controller = Controller::new(tcproute_api, WatcherConfig::default())
3810 .run(reconcile_tcproute, tcproute_error_policy, ctx)
3811 .for_each(|res| async move {
3812 match res {
3813 Ok(obj) => debug!(obj = ?obj, "Reconciled TCPRoute"),
3814 Err(e) => error!(error = %e, "TCPRoute reconcile failed"),
3815 }
3816 });
3817
3818 futures::future::join5(
3819 ingress_controller,
3820 service_controller,
3821 httproute_controller,
3822 tlsroute_controller,
3823 tcproute_controller,
3824 )
3825 .await;
3826
3827 Ok(())
3828}