1use crate::constants::{
25 ALLOW_ZONE_NAMESPACES_WILDCARD, ANNOTATION_ALLOW_ZONE_NAMESPACES, HTTP_NOT_FOUND,
26};
27use crate::crd::{ARecord, ARecordSpec, DNSZone};
28use anyhow::{anyhow, Context, Result};
29use k8s_openapi::api::core::v1::{Namespace, Secret, Service};
30use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
31use k8s_openapi::jiff::Timestamp;
32use kube::api::{DeleteParams, ListParams, Patch, PatchParams};
33use kube::config::{KubeConfigOptions, Kubeconfig};
34
35#[derive(Debug, thiserror::Error)]
38#[error(transparent)]
39pub struct ScoutError(#[from] anyhow::Error);
40use futures::StreamExt;
41use k8s_openapi::api::networking::v1::Ingress;
42use kube::{
43 runtime::{
44 controller::Action, reflector, watcher, watcher::Config as WatcherConfig, Controller,
45 },
46 Api, Client, Error as KubeError, ResourceExt,
47};
48use serde::de::DeserializeOwned;
49use std::fmt::Debug;
50use std::{collections::BTreeMap, future::Future, pin::Pin, sync::Arc, time::Duration};
51use tracing::{debug, error, info, warn};
52
53#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ParentReference {
69 #[serde(default, skip_serializing_if = "Option::is_none")]
71 pub group: Option<String>,
72 #[serde(default, skip_serializing_if = "Option::is_none")]
74 pub kind: Option<String>,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
77 pub namespace: Option<String>,
78 pub name: String,
80}
81
82#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct HTTPRouteSpec {
86 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub hostnames: Option<Vec<String>>,
89 #[serde(default, skip_serializing_if = "Option::is_none")]
92 pub parent_refs: Option<Vec<ParentReference>>,
93}
94
95#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
97pub struct HTTPRoute {
98 #[serde(rename = "apiVersion")]
99 pub api_version: String,
100 pub kind: String,
101 pub metadata: kube::api::ObjectMeta,
102 #[serde(default)]
103 pub spec: Option<HTTPRouteSpec>,
104}
105
106#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct TLSRouteSpec {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
112 pub hostnames: Option<Vec<String>>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub rules: Option<Vec<serde_json::Value>>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub parent_refs: Option<Vec<ParentReference>>,
120}
121
122#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
124pub struct TLSRoute {
125 #[serde(rename = "apiVersion")]
126 pub api_version: String,
127 pub kind: String,
128 pub metadata: kube::api::ObjectMeta,
129 #[serde(default)]
130 pub spec: Option<TLSRouteSpec>,
131}
132
133#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct TCPRouteSpec {
137 #[serde(default, skip_serializing_if = "Option::is_none")]
142 pub rules: Option<Vec<serde_json::Value>>,
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub parent_refs: Option<Vec<ParentReference>>,
147}
148
149#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
151pub struct TCPRoute {
152 #[serde(rename = "apiVersion")]
153 pub api_version: String,
154 pub kind: String,
155 pub metadata: kube::api::ObjectMeta,
156 #[serde(default)]
157 pub spec: Option<TCPRouteSpec>,
158}
159
160impl k8s_openapi::Metadata for HTTPRoute {
162 type Ty = kube::api::ObjectMeta;
163 fn metadata(&self) -> &kube::api::ObjectMeta {
164 &self.metadata
165 }
166 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
167 &mut self.metadata
168 }
169}
170
171impl k8s_openapi::Metadata for TLSRoute {
172 type Ty = kube::api::ObjectMeta;
173 fn metadata(&self) -> &kube::api::ObjectMeta {
174 &self.metadata
175 }
176 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
177 &mut self.metadata
178 }
179}
180
181impl k8s_openapi::Resource for HTTPRoute {
183 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
184 const GROUP: &'static str = "gateway.networking.k8s.io";
185 const KIND: &'static str = "HTTPRoute";
186 const VERSION: &'static str = "v1";
187 const URL_PATH_SEGMENT: &'static str = "httproutes";
188 type Scope = k8s_openapi::NamespaceResourceScope;
189}
190
191impl k8s_openapi::Resource for TLSRoute {
192 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
193 const GROUP: &'static str = "gateway.networking.k8s.io";
194 const KIND: &'static str = "TLSRoute";
195 const VERSION: &'static str = "v1alpha2";
196 const URL_PATH_SEGMENT: &'static str = "tlsroutes";
197 type Scope = k8s_openapi::NamespaceResourceScope;
198}
199
200impl k8s_openapi::Metadata for TCPRoute {
201 type Ty = kube::api::ObjectMeta;
202 fn metadata(&self) -> &kube::api::ObjectMeta {
203 &self.metadata
204 }
205 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
206 &mut self.metadata
207 }
208}
209
210impl k8s_openapi::Resource for TCPRoute {
211 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
212 const GROUP: &'static str = "gateway.networking.k8s.io";
213 const KIND: &'static str = "TCPRoute";
214 const VERSION: &'static str = "v1alpha2";
215 const URL_PATH_SEGMENT: &'static str = "tcproutes";
216 type Scope = k8s_openapi::NamespaceResourceScope;
217}
218
219pub const GATEWAY_API_GROUP: &str = "gateway.networking.k8s.io";
221
222pub const GATEWAY_KIND: &str = "Gateway";
224
225#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct NamespacedName {
231 pub namespace: String,
233 pub name: String,
235}
236
237#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum GatewayServiceTarget {
244 Name(NamespacedName),
246 Labeled {
250 namespace: String,
252 selector: String,
254 },
255}
256
257impl GatewayServiceTarget {
258 #[must_use]
260 pub fn namespace(&self) -> &str {
261 match self {
262 Self::Name(nn) => &nn.namespace,
263 Self::Labeled { namespace, .. } => namespace,
264 }
265 }
266}
267
268#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct GatewaySpec {
272 pub gateway_class_name: String,
274}
275
276#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct GatewayStatusAddress {
280 #[serde(default, skip_serializing_if = "Option::is_none")]
282 pub r#type: Option<String>,
283 pub value: String,
285}
286
287#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct GatewayStatus {
291 #[serde(default, skip_serializing_if = "Option::is_none")]
294 pub addresses: Option<Vec<GatewayStatusAddress>>,
295}
296
297#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
299pub struct Gateway {
300 #[serde(rename = "apiVersion")]
301 pub api_version: String,
302 pub kind: String,
303 pub metadata: kube::api::ObjectMeta,
304 pub spec: GatewaySpec,
305 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub status: Option<GatewayStatus>,
307}
308
309impl k8s_openapi::Metadata for Gateway {
310 type Ty = kube::api::ObjectMeta;
311 fn metadata(&self) -> &kube::api::ObjectMeta {
312 &self.metadata
313 }
314 fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
315 &mut self.metadata
316 }
317}
318
319impl k8s_openapi::Resource for Gateway {
320 const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
321 const GROUP: &'static str = "gateway.networking.k8s.io";
322 const KIND: &'static str = "Gateway";
323 const VERSION: &'static str = "v1";
324 const URL_PATH_SEGMENT: &'static str = "gateways";
325 type Scope = k8s_openapi::NamespaceResourceScope;
326}
327
328pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
335
336pub const RECORD_KIND_ARECORD: &str = "ARecord";
338
339pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
341
342pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
346
347pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
355
356pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
359
360pub const ANNOTATION_RECORD_NAME: &str = "bindy.firestoned.io/record-name";
369
370pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
372
373pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
375
376pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
378
379pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
381
382pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
384
385pub const LABEL_SOURCE_NAME: &str = "bindy.firestoned.io/source-name";
388
389pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
391
392pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
394
395const MAX_K8S_NAME_LEN: usize = 253;
397
398const ARECORD_NAME_PREFIX: &str = "scout";
400
401const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
403
404pub(crate) const REMOTE_CLEANUP_GRACE_SECS: i64 = 300;
419
420const REFLECTOR_ERROR_BACKOFF_SECS: u64 = 5;
424
425pub struct ScoutContext {
431 pub client: Client,
434 pub remote_client: Client,
438 pub target_namespace: String,
440 pub cluster_name: String,
442 pub excluded_namespaces: Vec<String>,
444 pub default_ips: Vec<String>,
448 pub gateway_services: BTreeMap<String, GatewayServiceTarget>,
453 pub default_zone: Option<String>,
456 pub namespace_selector: Option<String>,
465 pub zone_store: reflector::Store<DNSZone>,
468}
469
470async fn namespace_matches_selector(
493 client: &Client,
494 namespace: &str,
495 selector: &str,
496) -> Result<bool> {
497 let ns_api: Api<Namespace> = Api::all(client.clone());
498 let lp = ListParams::default()
499 .labels(selector)
500 .fields(&format!("metadata.name={namespace}"));
501 let list = ns_api.list(&lp).await.with_context(|| {
502 format!("failed to check namespace '{namespace}' against selector '{selector}'")
503 })?;
504 Ok(!list.items.is_empty())
505}
506
507async fn source_namespace_eligible(
522 client: &Client,
523 namespace: &str,
524 selector: Option<&str>,
525) -> Result<bool> {
526 match selector {
527 None => Ok(true),
528 Some(sel) => namespace_matches_selector(client, namespace, sel).await,
529 }
530}
531
532pub(crate) fn cleanup_grace_expired(deletion_timestamp: Option<&Time>, now: Timestamp) -> bool {
548 match deletion_timestamp {
549 Some(Time(started)) => now.duration_since(*started).as_secs() >= REMOTE_CLEANUP_GRACE_SECS,
550 None => false,
551 }
552}
553
554pub fn is_arecord_enabled(annotations: &BTreeMap<String, String>) -> bool {
559 annotations
560 .get(ANNOTATION_RECORD_KIND)
561 .map(|v| v == RECORD_KIND_ARECORD)
562 .unwrap_or(false)
563}
564
565pub fn is_scout_opted_in(annotations: &BTreeMap<String, String>) -> bool {
575 annotations
576 .get(ANNOTATION_SCOUT_ENABLED)
577 .map(|v| v == "true")
578 .unwrap_or(false)
579 || is_arecord_enabled(annotations)
580}
581
582pub fn resolve_zone(
589 annotations: &BTreeMap<String, String>,
590 default_zone: Option<&str>,
591) -> Option<String> {
592 get_zone_annotation(annotations).or_else(|| default_zone.map(ToString::to_string))
593}
594
595pub fn get_zone_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
599 annotations
600 .get(ANNOTATION_ZONE)
601 .filter(|v| !v.is_empty())
602 .cloned()
603}
604
605pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
617 let host = host.trim_end_matches('.');
619
620 if host == zone {
622 return Ok("@".to_string());
623 }
624
625 let zone_suffix = format!(".{zone}");
626 if !host.ends_with(&zone_suffix) {
627 return Err(anyhow!(
628 "host \"{host}\" does not belong to zone \"{zone}\""
629 ));
630 }
631
632 let record_name = &host[..host.len() - zone_suffix.len()];
633 Ok(record_name.to_string())
634}
635
636const MAX_DNS_LABEL_LEN: usize = 63;
638
639const MAX_DNS_NAME_LEN: usize = 253;
641
642fn validate_record_name_override(name: &str) -> Result<()> {
660 if name == "@" {
662 return Ok(());
663 }
664
665 if name.len() > MAX_DNS_NAME_LEN {
666 return Err(anyhow!(
667 "record-name override {name:?} is {} characters; the DNS limit is {MAX_DNS_NAME_LEN}",
668 name.len()
669 ));
670 }
671
672 for label in name.split('.') {
673 if label.is_empty() {
674 return Err(anyhow!(
675 "record-name override {name:?} has an empty label (leading, trailing or doubled '.')"
676 ));
677 }
678 if label == "*" {
679 continue;
680 }
681 if label.len() > MAX_DNS_LABEL_LEN {
682 return Err(anyhow!(
683 "record-name override {name:?} has a {}-character label; the DNS limit is {MAX_DNS_LABEL_LEN}",
684 label.len()
685 ));
686 }
687 if let Some(bad) = label
688 .chars()
689 .find(|c| !c.is_ascii_alphanumeric() && *c != '-' && *c != '_')
690 {
691 return Err(anyhow!(
692 "record-name override {name:?} contains illegal character {bad:?} \
693 (allowed: ASCII letters, digits, '-', '_')"
694 ));
695 }
696 let first = label.chars().next().unwrap_or_default();
698 let last = label.chars().next_back().unwrap_or_default();
699 if !(first.is_ascii_alphanumeric() || first == '_')
700 || !(last.is_ascii_alphanumeric() || last == '_')
701 {
702 return Err(anyhow!(
703 "record-name override {name:?} has a label that starts or ends with '-'"
704 ));
705 }
706 }
707
708 Ok(())
709}
710
711pub fn get_record_name_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
716 annotations
717 .get(ANNOTATION_RECORD_NAME)
718 .map(|v| v.trim().to_string())
719 .filter(|v| !v.is_empty())
720}
721
722pub fn resolve_record_name(
738 annotations: &BTreeMap<String, String>,
739 host: &str,
740 zone: &str,
741) -> Result<String> {
742 if let Some(override_name) = get_record_name_annotation(annotations) {
743 validate_record_name_override(&override_name)?;
746 return Ok(override_name);
747 }
748 derive_record_name(host, zone)
749}
750
751pub fn resolve_ips_from_annotation(annotations: &BTreeMap<String, String>) -> Option<Vec<String>> {
773 let raw = annotations.get(ANNOTATION_IP)?;
774 let ips: Vec<String> = raw
775 .split(',')
776 .map(str::trim)
777 .filter(|s| !s.is_empty())
778 .filter(|entry| {
779 if entry.parse::<std::net::Ipv4Addr>().is_ok() {
780 return true;
781 }
782 warn!(
783 annotation = ANNOTATION_IP,
784 value = %entry,
785 "Ignoring invalid IPv4 address in annotation — entries must be dotted-quad IPv4 (ARecord is IPv4-only)"
786 );
787 false
788 })
789 .map(ToString::to_string)
790 .collect();
791 if ips.is_empty() {
792 None
793 } else {
794 Some(ips)
795 }
796}
797
798#[must_use]
811pub fn zone_allows_source_namespace(zone: &DNSZone, source_namespace: &str) -> bool {
812 let grant = zone_namespace_grant(zone, source_namespace);
813
814 if grant == NamespaceGrant::Wildcard {
818 warn!(
819 zone = %zone.name_any(),
820 zone_namespace = %zone.namespace().unwrap_or_default(),
821 source_namespace = %source_namespace,
822 annotation = ANNOTATION_ALLOW_ZONE_NAMESPACES,
823 "Cross-namespace DNS grant allowed by WILDCARD '*' — any namespace in the cluster \
824 may create records in this zone. Replace '*' with an explicit namespace list \
825 unless this zone is deliberately cluster-public."
826 );
827 }
828
829 grant.is_authorized()
830}
831
832#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum NamespaceGrant {
838 SameNamespace,
840 ExplicitlyListed,
842 Wildcard,
844 Denied,
846}
847
848impl NamespaceGrant {
849 #[must_use]
851 pub fn is_authorized(self) -> bool {
852 !matches!(self, Self::Denied)
853 }
854}
855
856#[must_use]
862pub fn zone_namespace_grant(zone: &DNSZone, source_namespace: &str) -> NamespaceGrant {
863 if zone.namespace().as_deref() == Some(source_namespace) {
864 return NamespaceGrant::SameNamespace;
865 }
866 let Some(annotations) = zone.metadata.annotations.as_ref() else {
867 return NamespaceGrant::Denied;
868 };
869 let Some(value) = annotations.get(ANNOTATION_ALLOW_ZONE_NAMESPACES) else {
870 return NamespaceGrant::Denied;
871 };
872
873 let mut wildcard = false;
874 for entry in value.split(',').map(str::trim) {
875 if entry == source_namespace {
876 return NamespaceGrant::ExplicitlyListed;
877 }
878 if entry == ALLOW_ZONE_NAMESPACES_WILDCARD {
879 wildcard = true;
880 }
881 }
882
883 if wildcard {
884 NamespaceGrant::Wildcard
885 } else {
886 NamespaceGrant::Denied
887 }
888}
889
890#[derive(Debug, PartialEq, Eq)]
893pub(crate) enum ZoneAuthz {
894 Authorized,
896 Forbidden,
898 NotFound,
900}
901
902pub(crate) fn check_zone_authorization(
909 zones: &[Arc<DNSZone>],
910 zone_name: &str,
911 source_namespace: &str,
912) -> ZoneAuthz {
913 let mut found = false;
914 for zone in zones {
915 if zone.spec.zone_name != zone_name {
916 continue;
917 }
918 found = true;
919 if zone_allows_source_namespace(zone, source_namespace) {
920 return ZoneAuthz::Authorized;
921 }
922 }
923 if found {
924 ZoneAuthz::Forbidden
925 } else {
926 ZoneAuthz::NotFound
927 }
928}
929
930#[must_use]
935pub(crate) fn authorizing_zone(
936 zones: &[Arc<DNSZone>],
937 zone_name: &str,
938 source_namespace: &str,
939) -> Option<Arc<DNSZone>> {
940 zones
941 .iter()
942 .find(|zone| {
943 zone.spec.zone_name == zone_name && zone_allows_source_namespace(zone, source_namespace)
944 })
945 .map(Arc::clone)
946}
947
948pub(crate) async fn check_zone_authorization_live(
966 client: &Client,
967 zones: &[Arc<DNSZone>],
968 zone_name: &str,
969 source_namespace: &str,
970) -> ZoneAuthz {
971 let cached = check_zone_authorization(zones, zone_name, source_namespace);
972 if cached != ZoneAuthz::Authorized {
973 return cached;
974 }
975
976 let Some(granting) = authorizing_zone(zones, zone_name, source_namespace) else {
977 return cached;
978 };
979 let (Some(ns), Some(name)) = (granting.namespace(), granting.metadata.name.clone()) else {
980 return cached;
981 };
982
983 let api: Api<DNSZone> = Api::namespaced(client.clone(), &ns);
984 match api.get(&name).await {
985 Ok(live) => {
986 if zone_allows_source_namespace(&live, source_namespace) {
987 return ZoneAuthz::Authorized;
988 }
989 warn!(
990 zone = %zone_name,
991 dnszone = %name,
992 dnszone_namespace = %ns,
993 source_namespace = %source_namespace,
994 "Zone authorization was revoked between the cached check and the write — \
995 refusing to publish (audit finding P3-4)"
996 );
997 ZoneAuthz::Forbidden
998 }
999 Err(e) => {
1000 warn!(
1001 zone = %zone_name,
1002 dnszone = %name,
1003 error = %e,
1004 "Could not re-verify zone authorization live; proceeding on the cached grant"
1005 );
1006 ZoneAuthz::Authorized
1007 }
1008 }
1009}
1010
1011pub fn resolve_ips(
1019 annotations: &BTreeMap<String, String>,
1020 default_ips: &[String],
1021 ingress: &Ingress,
1022) -> Option<Vec<String>> {
1023 if let Some(ips) = resolve_ips_from_annotation(annotations) {
1024 return Some(ips);
1025 }
1026 if !default_ips.is_empty() {
1027 return Some(default_ips.to_vec());
1028 }
1029 resolve_ip_from_lb_status(ingress).map(|ip| vec![ip])
1030}
1031
1032pub fn resolve_ip_from_lb_status(ingress: &Ingress) -> Option<String> {
1037 let lb_ingresses = ingress
1038 .status
1039 .as_ref()?
1040 .load_balancer
1041 .as_ref()?
1042 .ingress
1043 .as_ref()?;
1044
1045 for lb in lb_ingresses {
1046 if let Some(ip) = &lb.ip {
1047 if !ip.is_empty() {
1048 return Some(ip.clone());
1049 }
1050 }
1051 if lb.hostname.is_some() {
1052 warn!(
1053 ingress = %ingress.name_any(),
1054 "Ingress LB status has hostname but no IP — A record requires an IP address; skipping"
1055 );
1056 }
1057 }
1058 None
1059}
1060
1061pub fn arecord_cr_name(
1069 cluster: &str,
1070 namespace: &str,
1071 ingress_name: &str,
1072 host_index: usize,
1073) -> String {
1074 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{ingress_name}-{host_index}");
1075 let sanitized = sanitize_k8s_name(&raw);
1076 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1077}
1078
1079fn sanitize_k8s_name(s: &str) -> String {
1086 let lower = s.to_lowercase();
1087 let mut result = String::with_capacity(lower.len());
1088 let mut last_was_hyphen = false;
1089
1090 for ch in lower.chars() {
1091 if ch.is_ascii_alphanumeric() {
1092 result.push(ch);
1093 last_was_hyphen = false;
1094 } else {
1095 if !last_was_hyphen {
1097 result.push('-');
1098 last_was_hyphen = true;
1099 }
1100 }
1101 }
1102
1103 let trimmed = result.trim_end_matches('-');
1105 trimmed.trim_start_matches('-').to_string()
1107}
1108
1109pub fn has_finalizer(ingress: &Ingress) -> bool {
1111 ingress
1112 .metadata
1113 .finalizers
1114 .as_ref()
1115 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
1116 .unwrap_or(false)
1117}
1118
1119pub fn is_being_deleted(ingress: &Ingress) -> bool {
1121 ingress.metadata.deletion_timestamp.is_some()
1122}
1123
1124pub fn arecord_label_selector(cluster: &str, namespace: &str, ingress_name: &str) -> String {
1130 format!(
1131 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={ingress_name}",
1132 LABEL_MANAGED_BY,
1133 LABEL_MANAGED_BY_SCOUT,
1134 cluster_key = LABEL_SOURCE_CLUSTER,
1135 ns_key = LABEL_SOURCE_NAMESPACE,
1136 name_key = LABEL_SOURCE_NAME,
1137 )
1138}
1139
1140pub fn stale_arecord_label_selector(
1147 current_cluster: &str,
1148 namespace: &str,
1149 ingress_name: &str,
1150) -> String {
1151 format!(
1152 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={ingress_name}",
1153 LABEL_MANAGED_BY,
1154 LABEL_MANAGED_BY_SCOUT,
1155 cluster_key = LABEL_SOURCE_CLUSTER,
1156 ns_key = LABEL_SOURCE_NAMESPACE,
1157 name_key = LABEL_SOURCE_NAME,
1158 )
1159}
1160
1161pub struct ARecordParams<'a> {
1167 pub name: &'a str,
1169 pub target_namespace: &'a str,
1171 pub record_name: &'a str,
1173 pub ips: &'a [String],
1175 pub ttl: Option<i32>,
1177 pub cluster_name: &'a str,
1179 pub ingress_namespace: &'a str,
1181 pub ingress_name: &'a str,
1183 pub zone: &'a str,
1185}
1186
1187pub fn build_arecord(params: ARecordParams<'_>) -> ARecord {
1189 let mut labels = BTreeMap::new();
1190 labels.insert(
1191 LABEL_MANAGED_BY.to_string(),
1192 LABEL_MANAGED_BY_SCOUT.to_string(),
1193 );
1194 labels.insert(
1195 LABEL_SOURCE_CLUSTER.to_string(),
1196 params.cluster_name.to_string(),
1197 );
1198 labels.insert(
1199 LABEL_SOURCE_NAMESPACE.to_string(),
1200 params.ingress_namespace.to_string(),
1201 );
1202 labels.insert(
1203 LABEL_SOURCE_NAME.to_string(),
1204 params.ingress_name.to_string(),
1205 );
1206 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1207
1208 let meta = kube::api::ObjectMeta {
1209 name: Some(params.name.to_string()),
1210 namespace: Some(params.target_namespace.to_string()),
1211 labels: Some(labels),
1212 ..Default::default()
1213 };
1214
1215 ARecord {
1216 metadata: meta,
1217 spec: ARecordSpec {
1218 name: params.record_name.to_string(),
1219 ipv4_addresses: params.ips.to_vec(),
1220 ttl: params.ttl,
1221 },
1222 status: None,
1223 }
1224}
1225
1226pub fn is_loadbalancer_service(svc: &Service) -> bool {
1235 svc.spec
1236 .as_ref()
1237 .and_then(|s| s.type_.as_deref())
1238 .map(|t| t == "LoadBalancer")
1239 .unwrap_or(false)
1240}
1241
1242pub fn resolve_ip_from_service_lb_status(svc: &Service) -> Option<String> {
1248 svc.status
1249 .as_ref()?
1250 .load_balancer
1251 .as_ref()?
1252 .ingress
1253 .as_ref()?
1254 .iter()
1255 .find_map(|entry| entry.ip.clone().filter(|ip| !ip.is_empty()))
1256}
1257
1258#[must_use]
1263pub fn service_ref_from_str(s: &str) -> Option<NamespacedName> {
1264 let mut parts = s.split('/');
1265 let namespace = parts.next()?.trim();
1266 let name = parts.next()?.trim();
1267 if namespace.is_empty() || name.is_empty() || parts.next().is_some() {
1268 return None;
1269 }
1270 Some(NamespacedName {
1271 namespace: namespace.to_string(),
1272 name: name.to_string(),
1273 })
1274}
1275
1276#[must_use]
1284pub fn gateway_service_target_from_str(s: &str) -> Option<GatewayServiceTarget> {
1285 let (namespace, rest) = s.split_once('/')?;
1286 let namespace = namespace.trim();
1287 let rest = rest.trim();
1288 if namespace.is_empty() || rest.is_empty() {
1289 return None;
1290 }
1291 if rest.contains('=') {
1292 return Some(GatewayServiceTarget::Labeled {
1293 namespace: namespace.to_string(),
1294 selector: rest.to_string(),
1295 });
1296 }
1297 service_ref_from_str(s).map(GatewayServiceTarget::Name)
1300}
1301
1302#[must_use]
1309pub fn parse_gateway_service_entry(entry: &str) -> Option<(String, GatewayServiceTarget)> {
1310 let (class, target) = entry.trim().split_once('=')?;
1311 let class = class.trim();
1312 if class.is_empty() {
1313 return None;
1314 }
1315 let target = gateway_service_target_from_str(target)?;
1316 Some((class.to_string(), target))
1317}
1318
1319#[must_use]
1330pub fn parse_gateway_services(raw: &str) -> BTreeMap<String, GatewayServiceTarget> {
1331 raw.split(',')
1332 .filter(|e| !e.trim().is_empty())
1333 .filter_map(parse_gateway_service_entry)
1334 .collect()
1335}
1336
1337#[must_use]
1344pub fn gateway_addresses_as_ips(gw: &Gateway) -> Vec<String> {
1345 let Some(addresses) = gw.status.as_ref().and_then(|s| s.addresses.as_ref()) else {
1346 return Vec::new();
1347 };
1348 addresses
1349 .iter()
1350 .filter(|addr| match addr.r#type.as_deref() {
1351 Some("IPAddress") => true,
1352 Some("Hostname") => false,
1353 _ => addr.value.parse::<std::net::IpAddr>().is_ok(),
1354 })
1355 .map(|addr| addr.value.clone())
1356 .filter(|v| !v.is_empty())
1357 .collect()
1358}
1359
1360#[must_use]
1366pub fn gateway_parent_refs(
1367 parent_refs: &[ParentReference],
1368 route_namespace: &str,
1369) -> Vec<NamespacedName> {
1370 parent_refs
1371 .iter()
1372 .filter(|r| {
1373 let group_ok = r
1374 .group
1375 .as_deref()
1376 .is_none_or(|g| g.is_empty() || g == GATEWAY_API_GROUP);
1377 let kind_ok = r.kind.as_deref().is_none_or(|k| k == GATEWAY_KIND);
1378 group_ok && kind_ok
1379 })
1380 .map(|r| NamespacedName {
1381 namespace: r
1382 .namespace
1383 .clone()
1384 .filter(|ns| !ns.is_empty())
1385 .unwrap_or_else(|| route_namespace.to_string()),
1386 name: r.name.clone(),
1387 })
1388 .collect()
1389}
1390
1391async fn resolve_ip_from_gateway_service(
1399 client: &Client,
1400 target: &GatewayServiceTarget,
1401) -> Option<String> {
1402 match target {
1403 GatewayServiceTarget::Name(svc_ref) => {
1404 let svc_api: Api<Service> = Api::namespaced(client.clone(), &svc_ref.namespace);
1405 match svc_api.get(&svc_ref.name).await {
1406 Ok(svc) => resolve_ip_from_service_lb_status(&svc).or_else(|| {
1407 debug!(service = %svc_ref.name, ns = %svc_ref.namespace,
1408 "Gateway LoadBalancer Service has no external IP yet");
1409 None
1410 }),
1411 Err(e) => {
1412 debug!(service = %svc_ref.name, ns = %svc_ref.namespace, error = %e,
1413 "Could not fetch Gateway's LoadBalancer Service");
1414 None
1415 }
1416 }
1417 }
1418 GatewayServiceTarget::Labeled {
1419 namespace,
1420 selector,
1421 } => {
1422 let svc_api: Api<Service> = Api::namespaced(client.clone(), namespace);
1423 let lp = kube::api::ListParams::default().labels(selector);
1424 match svc_api.list(&lp).await {
1425 Ok(list) => list
1426 .items
1427 .iter()
1428 .filter(|svc| is_loadbalancer_service(svc))
1429 .find_map(resolve_ip_from_service_lb_status)
1430 .or_else(|| {
1431 debug!(ns = %namespace, selector = %selector,
1432 "No LoadBalancer Service with an external IP matched the selector");
1433 None
1434 }),
1435 Err(e) => {
1436 debug!(ns = %namespace, selector = %selector, error = %e,
1437 "Could not list Gateway LoadBalancer Services by selector");
1438 None
1439 }
1440 }
1441 }
1442 }
1443}
1444
1445pub async fn resolve_ips_from_gateways(
1467 client: &Client,
1468 route_namespace: &str,
1469 parent_refs: &[ParentReference],
1470 gateway_services: &BTreeMap<String, GatewayServiceTarget>,
1471) -> Option<Vec<String>> {
1472 if parent_refs.is_empty() {
1473 return None;
1474 }
1475
1476 let mut ips: Vec<String> = Vec::new();
1477 for gw_ref in gateway_parent_refs(parent_refs, route_namespace) {
1478 let gw_api: Api<Gateway> = Api::namespaced(client.clone(), &gw_ref.namespace);
1479 let gateway = match gw_api.get(&gw_ref.name).await {
1480 Ok(gw) => gw,
1481 Err(e) => {
1482 debug!(gateway = %gw_ref.name, ns = %gw_ref.namespace, error = %e,
1483 "Skipping parentRef Gateway that could not be fetched");
1484 continue;
1485 }
1486 };
1487
1488 let gw_ips = gateway_addresses_as_ips(&gateway);
1491 if !gw_ips.is_empty() {
1492 ips.extend(gw_ips);
1493 continue;
1494 }
1495
1496 let class = &gateway.spec.gateway_class_name;
1499 let Some(target) = gateway_services.get(class) else {
1500 debug!(gateway = %gw_ref.name, class = %class,
1501 "Gateway has no status.addresses and class not in configured gateway-services — skipping");
1502 continue;
1503 };
1504 if let Some(ip) = resolve_ip_from_gateway_service(client, target).await {
1505 ips.push(ip);
1506 }
1507 }
1508
1509 let mut seen = std::collections::HashSet::new();
1511 ips.retain(|ip| seen.insert(ip.clone()));
1512
1513 if ips.is_empty() {
1514 None
1515 } else {
1516 Some(ips)
1517 }
1518}
1519
1520pub fn service_arecord_cr_name(cluster: &str, namespace: &str, service_name: &str) -> String {
1527 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{service_name}");
1528 let sanitized = sanitize_k8s_name(&raw);
1529 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1530}
1531
1532pub fn service_arecord_label_selector(
1535 cluster: &str,
1536 namespace: &str,
1537 service_name: &str,
1538) -> String {
1539 format!(
1540 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={service_name}",
1541 LABEL_MANAGED_BY,
1542 LABEL_MANAGED_BY_SCOUT,
1543 cluster_key = LABEL_SOURCE_CLUSTER,
1544 ns_key = LABEL_SOURCE_NAMESPACE,
1545 name_key = LABEL_SOURCE_NAME,
1546 )
1547}
1548
1549pub struct ServiceARecordParams<'a> {
1551 pub name: &'a str,
1553 pub target_namespace: &'a str,
1555 pub record_name: &'a str,
1557 pub ips: &'a [String],
1559 pub ttl: Option<i32>,
1561 pub cluster_name: &'a str,
1563 pub service_namespace: &'a str,
1565 pub service_name: &'a str,
1567 pub zone: &'a str,
1569}
1570
1571pub fn build_service_arecord(params: ServiceARecordParams<'_>) -> ARecord {
1573 let mut labels = BTreeMap::new();
1574 labels.insert(
1575 LABEL_MANAGED_BY.to_string(),
1576 LABEL_MANAGED_BY_SCOUT.to_string(),
1577 );
1578 labels.insert(
1579 LABEL_SOURCE_CLUSTER.to_string(),
1580 params.cluster_name.to_string(),
1581 );
1582 labels.insert(
1583 LABEL_SOURCE_NAMESPACE.to_string(),
1584 params.service_namespace.to_string(),
1585 );
1586 labels.insert(
1587 LABEL_SOURCE_NAME.to_string(),
1588 params.service_name.to_string(),
1589 );
1590 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1591
1592 let meta = kube::api::ObjectMeta {
1593 name: Some(params.name.to_string()),
1594 namespace: Some(params.target_namespace.to_string()),
1595 labels: Some(labels),
1596 ..Default::default()
1597 };
1598
1599 ARecord {
1600 metadata: meta,
1601 spec: ARecordSpec {
1602 name: params.record_name.to_string(),
1603 ipv4_addresses: params.ips.to_vec(),
1604 ttl: params.ttl,
1605 },
1606 status: None,
1607 }
1608}
1609
1610pub fn httproute_arecord_cr_name(
1622 cluster: &str,
1623 namespace: &str,
1624 route_name: &str,
1625 hostname_index: usize,
1626) -> String {
1627 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1628 let sanitized = sanitize_k8s_name(&raw);
1629 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1630}
1631
1632pub fn tlsroute_arecord_cr_name(
1639 cluster: &str,
1640 namespace: &str,
1641 route_name: &str,
1642 hostname_index: usize,
1643) -> String {
1644 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1645 let sanitized = sanitize_k8s_name(&raw);
1646 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1647}
1648
1649pub fn httproute_arecord_label_selector(
1652 cluster: &str,
1653 namespace: &str,
1654 route_name: &str,
1655) -> String {
1656 format!(
1657 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1658 LABEL_MANAGED_BY,
1659 LABEL_MANAGED_BY_SCOUT,
1660 cluster_key = LABEL_SOURCE_CLUSTER,
1661 ns_key = LABEL_SOURCE_NAMESPACE,
1662 name_key = LABEL_SOURCE_NAME,
1663 )
1664}
1665
1666pub fn tlsroute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1669 format!(
1670 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1671 LABEL_MANAGED_BY,
1672 LABEL_MANAGED_BY_SCOUT,
1673 cluster_key = LABEL_SOURCE_CLUSTER,
1674 ns_key = LABEL_SOURCE_NAMESPACE,
1675 name_key = LABEL_SOURCE_NAME,
1676 )
1677}
1678
1679pub fn tcproute_arecord_cr_name(
1681 cluster: &str,
1682 namespace: &str,
1683 route_name: &str,
1684 hostname_index: usize,
1685) -> String {
1686 let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1687 let sanitized = sanitize_k8s_name(&raw);
1688 sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1689}
1690
1691pub fn tcproute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1694 format!(
1695 "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1696 LABEL_MANAGED_BY,
1697 LABEL_MANAGED_BY_SCOUT,
1698 cluster_key = LABEL_SOURCE_CLUSTER,
1699 ns_key = LABEL_SOURCE_NAMESPACE,
1700 name_key = LABEL_SOURCE_NAME,
1701 )
1702}
1703
1704pub fn stale_httproute_arecord_label_selector(
1710 current_cluster: &str,
1711 namespace: &str,
1712 route_name: &str,
1713) -> String {
1714 format!(
1715 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1716 LABEL_MANAGED_BY,
1717 LABEL_MANAGED_BY_SCOUT,
1718 cluster_key = LABEL_SOURCE_CLUSTER,
1719 ns_key = LABEL_SOURCE_NAMESPACE,
1720 name_key = LABEL_SOURCE_NAME,
1721 )
1722}
1723
1724pub fn stale_tlsroute_arecord_label_selector(
1727 current_cluster: &str,
1728 namespace: &str,
1729 route_name: &str,
1730) -> String {
1731 format!(
1732 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1733 LABEL_MANAGED_BY,
1734 LABEL_MANAGED_BY_SCOUT,
1735 cluster_key = LABEL_SOURCE_CLUSTER,
1736 ns_key = LABEL_SOURCE_NAMESPACE,
1737 name_key = LABEL_SOURCE_NAME,
1738 )
1739}
1740
1741pub fn stale_tcproute_arecord_label_selector(
1744 current_cluster: &str,
1745 namespace: &str,
1746 route_name: &str,
1747) -> String {
1748 format!(
1749 "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1750 LABEL_MANAGED_BY,
1751 LABEL_MANAGED_BY_SCOUT,
1752 cluster_key = LABEL_SOURCE_CLUSTER,
1753 ns_key = LABEL_SOURCE_NAMESPACE,
1754 name_key = LABEL_SOURCE_NAME,
1755 )
1756}
1757
1758pub struct HTTPRouteARecordParams<'a> {
1760 pub name: &'a str,
1762 pub target_namespace: &'a str,
1764 pub record_name: &'a str,
1766 pub ips: &'a [String],
1768 pub ttl: Option<i32>,
1770 pub cluster_name: &'a str,
1772 pub route_namespace: &'a str,
1774 pub route_name: &'a str,
1776 pub zone: &'a str,
1778}
1779
1780pub fn build_httproute_arecord(params: HTTPRouteARecordParams<'_>) -> ARecord {
1782 let mut labels = BTreeMap::new();
1783 labels.insert(
1784 LABEL_MANAGED_BY.to_string(),
1785 LABEL_MANAGED_BY_SCOUT.to_string(),
1786 );
1787 labels.insert(
1788 LABEL_SOURCE_CLUSTER.to_string(),
1789 params.cluster_name.to_string(),
1790 );
1791 labels.insert(
1792 LABEL_SOURCE_NAMESPACE.to_string(),
1793 params.route_namespace.to_string(),
1794 );
1795 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1796 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1797
1798 let meta = kube::api::ObjectMeta {
1799 name: Some(params.name.to_string()),
1800 namespace: Some(params.target_namespace.to_string()),
1801 labels: Some(labels),
1802 ..Default::default()
1803 };
1804
1805 ARecord {
1806 metadata: meta,
1807 spec: ARecordSpec {
1808 name: params.record_name.to_string(),
1809 ipv4_addresses: params.ips.to_vec(),
1810 ttl: params.ttl,
1811 },
1812 status: None,
1813 }
1814}
1815
1816pub struct TLSRouteARecordParams<'a> {
1818 pub name: &'a str,
1820 pub target_namespace: &'a str,
1822 pub record_name: &'a str,
1824 pub ips: &'a [String],
1826 pub ttl: Option<i32>,
1828 pub cluster_name: &'a str,
1830 pub route_namespace: &'a str,
1832 pub route_name: &'a str,
1834 pub zone: &'a str,
1836}
1837
1838pub fn build_tlsroute_arecord(params: TLSRouteARecordParams<'_>) -> ARecord {
1840 let mut labels = BTreeMap::new();
1841 labels.insert(
1842 LABEL_MANAGED_BY.to_string(),
1843 LABEL_MANAGED_BY_SCOUT.to_string(),
1844 );
1845 labels.insert(
1846 LABEL_SOURCE_CLUSTER.to_string(),
1847 params.cluster_name.to_string(),
1848 );
1849 labels.insert(
1850 LABEL_SOURCE_NAMESPACE.to_string(),
1851 params.route_namespace.to_string(),
1852 );
1853 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1854 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1855
1856 let meta = kube::api::ObjectMeta {
1857 name: Some(params.name.to_string()),
1858 namespace: Some(params.target_namespace.to_string()),
1859 labels: Some(labels),
1860 ..Default::default()
1861 };
1862
1863 ARecord {
1864 metadata: meta,
1865 spec: ARecordSpec {
1866 name: params.record_name.to_string(),
1867 ipv4_addresses: params.ips.to_vec(),
1868 ttl: params.ttl,
1869 },
1870 status: None,
1871 }
1872}
1873
1874pub struct TCPRouteARecordParams<'a> {
1876 pub name: &'a str,
1878 pub target_namespace: &'a str,
1880 pub record_name: &'a str,
1882 pub ips: &'a [String],
1884 pub ttl: Option<i32>,
1886 pub cluster_name: &'a str,
1888 pub route_namespace: &'a str,
1890 pub route_name: &'a str,
1892 pub zone: &'a str,
1894}
1895
1896pub fn build_tcproute_arecord(params: TCPRouteARecordParams<'_>) -> ARecord {
1898 let mut labels = BTreeMap::new();
1899 labels.insert(
1900 LABEL_MANAGED_BY.to_string(),
1901 LABEL_MANAGED_BY_SCOUT.to_string(),
1902 );
1903 labels.insert(
1904 LABEL_SOURCE_CLUSTER.to_string(),
1905 params.cluster_name.to_string(),
1906 );
1907 labels.insert(
1908 LABEL_SOURCE_NAMESPACE.to_string(),
1909 params.route_namespace.to_string(),
1910 );
1911 labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1912 labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1913
1914 let meta = kube::api::ObjectMeta {
1915 name: Some(params.name.to_string()),
1916 namespace: Some(params.target_namespace.to_string()),
1917 labels: Some(labels),
1918 ..Default::default()
1919 };
1920
1921 ARecord {
1922 metadata: meta,
1923 spec: ARecordSpec {
1924 name: params.record_name.to_string(),
1925 ipv4_addresses: params.ips.to_vec(),
1926 ttl: params.ttl,
1927 },
1928 status: None,
1929 }
1930}
1931
1932async fn add_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1941 let namespace = ingress.namespace().unwrap_or_default();
1942 let name = ingress.name_any();
1943 let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1944
1945 let mut finalizers = ingress.metadata.finalizers.clone().unwrap_or_default();
1946 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1947 finalizers.push(FINALIZER_SCOUT.to_string());
1948 }
1949
1950 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1951 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1952 .await?;
1953 Ok(())
1954}
1955
1956async fn remove_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1960 let namespace = ingress.namespace().unwrap_or_default();
1961 let name = ingress.name_any();
1962 let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1963
1964 let finalizers: Vec<String> = ingress
1965 .metadata
1966 .finalizers
1967 .clone()
1968 .unwrap_or_default()
1969 .into_iter()
1970 .filter(|f| f != FINALIZER_SCOUT)
1971 .collect();
1972
1973 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1974 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1975 .await?;
1976 Ok(())
1977}
1978
1979async fn add_finalizer_to_service(client: &Client, svc: &Service) -> Result<()> {
1981 let namespace = svc.namespace().unwrap_or_default();
1982 let name = svc.name_any();
1983 let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
1984
1985 let mut finalizers = svc.metadata.finalizers.clone().unwrap_or_default();
1986 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1987 finalizers.push(FINALIZER_SCOUT.to_string());
1988 }
1989
1990 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1991 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1992 .await?;
1993 Ok(())
1994}
1995
1996async fn remove_finalizer_from_service(client: &Client, svc: &Service) -> Result<()> {
1998 let namespace = svc.namespace().unwrap_or_default();
1999 let name = svc.name_any();
2000 let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
2001
2002 let finalizers: Vec<String> = svc
2003 .metadata
2004 .finalizers
2005 .clone()
2006 .unwrap_or_default()
2007 .into_iter()
2008 .filter(|f| f != FINALIZER_SCOUT)
2009 .collect();
2010
2011 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2012 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2013 .await?;
2014 Ok(())
2015}
2016
2017async fn add_finalizer_to_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
2019 let namespace = route.namespace().unwrap_or_default();
2020 let name = route.name_any();
2021 let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
2022
2023 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
2024 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
2025 finalizers.push(FINALIZER_SCOUT.to_string());
2026 }
2027
2028 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2029 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2030 .await?;
2031 Ok(())
2032}
2033
2034async fn remove_finalizer_from_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
2036 let namespace = route.namespace().unwrap_or_default();
2037 let name = route.name_any();
2038 let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
2039
2040 let finalizers: Vec<String> = route
2041 .metadata
2042 .finalizers
2043 .clone()
2044 .unwrap_or_default()
2045 .into_iter()
2046 .filter(|f| f != FINALIZER_SCOUT)
2047 .collect();
2048
2049 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2050 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2051 .await?;
2052 Ok(())
2053}
2054
2055async fn add_finalizer_to_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
2057 let namespace = route.namespace().unwrap_or_default();
2058 let name = route.name_any();
2059 let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
2060
2061 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
2062 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
2063 finalizers.push(FINALIZER_SCOUT.to_string());
2064 }
2065
2066 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2067 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2068 .await?;
2069 Ok(())
2070}
2071
2072async fn remove_finalizer_from_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
2074 let namespace = route.namespace().unwrap_or_default();
2075 let name = route.name_any();
2076 let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
2077
2078 let finalizers: Vec<String> = route
2079 .metadata
2080 .finalizers
2081 .clone()
2082 .unwrap_or_default()
2083 .into_iter()
2084 .filter(|f| f != FINALIZER_SCOUT)
2085 .collect();
2086
2087 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2088 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2089 .await?;
2090 Ok(())
2091}
2092
2093async fn add_finalizer_to_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
2095 let namespace = route.namespace().unwrap_or_default();
2096 let name = route.name_any();
2097 let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
2098
2099 let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
2100 if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
2101 finalizers.push(FINALIZER_SCOUT.to_string());
2102 }
2103
2104 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2105 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2106 .await?;
2107 Ok(())
2108}
2109
2110async fn remove_finalizer_from_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
2112 let namespace = route.namespace().unwrap_or_default();
2113 let name = route.name_any();
2114 let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
2115
2116 let finalizers: Vec<String> = route
2117 .metadata
2118 .finalizers
2119 .clone()
2120 .unwrap_or_default()
2121 .into_iter()
2122 .filter(|f| f != FINALIZER_SCOUT)
2123 .collect();
2124
2125 let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
2126 api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
2127 .await?;
2128 Ok(())
2129}
2130
2131async fn delete_arecords_for_ingress(
2137 remote_client: &Client,
2138 target_namespace: &str,
2139 cluster: &str,
2140 ingress_namespace: &str,
2141 ingress_name: &str,
2142) -> Result<()> {
2143 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2144 let selector = arecord_label_selector(cluster, ingress_namespace, ingress_name);
2145 let lp = ListParams::default().labels(&selector);
2146
2147 let arecords = api.list(&lp).await?;
2148 for ar in arecords.items {
2149 let ar_name = ar.name_any();
2150 api.delete(&ar_name, &DeleteParams::default()).await?;
2151 info!(
2152 arecord = %ar_name,
2153 ingress = %ingress_name,
2154 ns = %ingress_namespace,
2155 "Deleted ARecord during Ingress cleanup"
2156 );
2157 }
2158 Ok(())
2159}
2160
2161async fn delete_stale_cluster_arecords(
2169 remote_client: &Client,
2170 target_namespace: &str,
2171 current_cluster: &str,
2172 ingress_namespace: &str,
2173 ingress_name: &str,
2174) -> Result<()> {
2175 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2176 let selector = stale_arecord_label_selector(current_cluster, ingress_namespace, ingress_name);
2177 let lp = ListParams::default().labels(&selector);
2178
2179 let arecords = api.list(&lp).await?;
2180 for ar in arecords.items {
2181 let ar_name = ar.name_any();
2182 let old_cluster = ar
2183 .metadata
2184 .labels
2185 .as_ref()
2186 .and_then(|l| l.get(LABEL_SOURCE_CLUSTER))
2187 .map(String::as_str)
2188 .unwrap_or("unknown");
2189 api.delete(&ar_name, &DeleteParams::default()).await?;
2190 info!(
2191 arecord = %ar_name,
2192 old_cluster = %old_cluster,
2193 new_cluster = %current_cluster,
2194 ingress = %ingress_name,
2195 ns = %ingress_namespace,
2196 "Deleted stale ARecord after cluster-name change"
2197 );
2198 }
2199 Ok(())
2200}
2201
2202async fn delete_arecords_for_service(
2206 remote_client: &Client,
2207 target_namespace: &str,
2208 cluster: &str,
2209 svc_namespace: &str,
2210 svc_name: &str,
2211) -> Result<()> {
2212 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2213 let selector = service_arecord_label_selector(cluster, svc_namespace, svc_name);
2214 let lp = ListParams::default().labels(&selector);
2215
2216 let arecords = api.list(&lp).await?;
2217 for ar in arecords.items {
2218 let ar_name = ar.name_any();
2219 api.delete(&ar_name, &DeleteParams::default()).await?;
2220 info!(
2221 arecord = %ar_name,
2222 service = %svc_name,
2223 ns = %svc_namespace,
2224 "Deleted ARecord during Service cleanup"
2225 );
2226 }
2227 Ok(())
2228}
2229
2230async fn delete_arecords_for_httproute(
2232 remote_client: &Client,
2233 target_namespace: &str,
2234 cluster: &str,
2235 route_namespace: &str,
2236 route_name: &str,
2237) -> Result<()> {
2238 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2239 let selector = httproute_arecord_label_selector(cluster, route_namespace, route_name);
2240 let lp = ListParams::default().labels(&selector);
2241
2242 let arecords = api.list(&lp).await?;
2243 for ar in arecords.items {
2244 let ar_name = ar.name_any();
2245 api.delete(&ar_name, &DeleteParams::default()).await?;
2246 info!(
2247 arecord = %ar_name,
2248 httproute = %route_name,
2249 ns = %route_namespace,
2250 "Deleted ARecord during HTTPRoute cleanup"
2251 );
2252 }
2253 Ok(())
2254}
2255
2256async fn delete_arecords_for_tlsroute(
2258 remote_client: &Client,
2259 target_namespace: &str,
2260 cluster: &str,
2261 route_namespace: &str,
2262 route_name: &str,
2263) -> Result<()> {
2264 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2265 let selector = tlsroute_arecord_label_selector(cluster, route_namespace, route_name);
2266 let lp = ListParams::default().labels(&selector);
2267
2268 let arecords = api.list(&lp).await?;
2269 for ar in arecords.items {
2270 let ar_name = ar.name_any();
2271 api.delete(&ar_name, &DeleteParams::default()).await?;
2272 info!(
2273 arecord = %ar_name,
2274 tlsroute = %route_name,
2275 ns = %route_namespace,
2276 "Deleted ARecord during TLSRoute cleanup"
2277 );
2278 }
2279 Ok(())
2280}
2281
2282async fn delete_stale_cluster_httproute_arecords(
2284 remote_client: &Client,
2285 target_namespace: &str,
2286 current_cluster: &str,
2287 route_namespace: &str,
2288 route_name: &str,
2289) -> Result<()> {
2290 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2291 let selector =
2292 stale_httproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2293 let lp = ListParams::default().labels(&selector);
2294
2295 let arecords = api.list(&lp).await?;
2296 for ar in arecords.items {
2297 let ar_name = ar.name_any();
2298 api.delete(&ar_name, &DeleteParams::default()).await?;
2299 info!(
2300 arecord = %ar_name,
2301 httproute = %route_name,
2302 "Deleted stale HTTPRoute ARecord from previous cluster name"
2303 );
2304 }
2305 Ok(())
2306}
2307
2308async fn delete_stale_cluster_tlsroute_arecords(
2310 remote_client: &Client,
2311 target_namespace: &str,
2312 current_cluster: &str,
2313 route_namespace: &str,
2314 route_name: &str,
2315) -> Result<()> {
2316 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2317 let selector =
2318 stale_tlsroute_arecord_label_selector(current_cluster, route_namespace, route_name);
2319 let lp = ListParams::default().labels(&selector);
2320
2321 let arecords = api.list(&lp).await?;
2322 for ar in arecords.items {
2323 let ar_name = ar.name_any();
2324 api.delete(&ar_name, &DeleteParams::default()).await?;
2325 info!(
2326 arecord = %ar_name,
2327 tlsroute = %route_name,
2328 "Deleted stale TLSRoute ARecord from previous cluster name"
2329 );
2330 }
2331 Ok(())
2332}
2333
2334async fn delete_arecords_for_tcproute(
2336 remote_client: &Client,
2337 target_namespace: &str,
2338 cluster: &str,
2339 route_namespace: &str,
2340 route_name: &str,
2341) -> Result<()> {
2342 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2343 let selector = tcproute_arecord_label_selector(cluster, route_namespace, route_name);
2344 let lp = ListParams::default().labels(&selector);
2345
2346 let arecords = api.list(&lp).await?;
2347 for ar in arecords.items {
2348 let ar_name = ar.name_any();
2349 api.delete(&ar_name, &DeleteParams::default()).await?;
2350 info!(
2351 arecord = %ar_name,
2352 tcproute = %route_name,
2353 ns = %route_namespace,
2354 "Deleted ARecord during TCPRoute cleanup"
2355 );
2356 }
2357 Ok(())
2358}
2359
2360async fn delete_stale_cluster_tcproute_arecords(
2362 remote_client: &Client,
2363 target_namespace: &str,
2364 current_cluster: &str,
2365 route_namespace: &str,
2366 route_name: &str,
2367) -> Result<()> {
2368 let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2369 let selector =
2370 stale_tcproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2371 let lp = ListParams::default().labels(&selector);
2372
2373 let arecords = api.list(&lp).await?;
2374 for ar in arecords.items {
2375 let ar_name = ar.name_any();
2376 api.delete(&ar_name, &DeleteParams::default()).await?;
2377 info!(
2378 arecord = %ar_name,
2379 tcproute = %route_name,
2380 "Deleted stale TCPRoute ARecord from previous cluster name"
2381 );
2382 }
2383 Ok(())
2384}
2385
2386async fn reconcile(ingress: Arc<Ingress>, ctx: Arc<ScoutContext>) -> Result<Action, ScoutError> {
2401 let name = ingress.name_any();
2402 let namespace = ingress.namespace().unwrap_or_default();
2403
2404 if ctx.excluded_namespaces.contains(&namespace) {
2406 debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
2407 return Ok(Action::await_change());
2408 }
2409
2410 if is_being_deleted(&ingress) {
2412 if has_finalizer(&ingress) {
2413 info!(ingress = %name, ns = %namespace, "Ingress deleting — cleaning up ARecords");
2414 let cleanup: Result<()> = async {
2415 delete_arecords_for_ingress(
2416 &ctx.remote_client,
2417 &ctx.target_namespace,
2418 &ctx.cluster_name,
2419 &namespace,
2420 &name,
2421 )
2422 .await?;
2423 delete_stale_cluster_arecords(
2424 &ctx.remote_client,
2425 &ctx.target_namespace,
2426 &ctx.cluster_name,
2427 &namespace,
2428 &name,
2429 )
2430 .await
2431 }
2432 .await;
2433 if let Err(e) = cleanup {
2434 if !cleanup_grace_expired(
2435 ingress.metadata.deletion_timestamp.as_ref(),
2436 Timestamp::now(),
2437 ) {
2438 warn!(ingress = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Ingress deletion — retrying within grace period");
2439 return Ok(Action::requeue(Duration::from_secs(
2440 SCOUT_ERROR_REQUEUE_SECS,
2441 )));
2442 }
2443 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");
2444 }
2445 remove_finalizer(&ctx.client, &ingress)
2446 .await
2447 .map_err(ScoutError::from)?;
2448 info!(ingress = %name, ns = %namespace, "Finalizer removed — Ingress deletion unblocked");
2449 }
2450 return Ok(Action::await_change());
2451 }
2452
2453 let annotations = ingress
2454 .metadata
2455 .annotations
2456 .as_ref()
2457 .cloned()
2458 .unwrap_or_default();
2459
2460 let namespace_eligible =
2462 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2463 .await
2464 .map_err(ScoutError::from)?;
2465
2466 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2467 if has_finalizer(&ingress) {
2469 info!(ingress = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2470 delete_arecords_for_ingress(
2471 &ctx.remote_client,
2472 &ctx.target_namespace,
2473 &ctx.cluster_name,
2474 &namespace,
2475 &name,
2476 )
2477 .await
2478 .map_err(ScoutError::from)?;
2479 delete_stale_cluster_arecords(
2480 &ctx.remote_client,
2481 &ctx.target_namespace,
2482 &ctx.cluster_name,
2483 &namespace,
2484 &name,
2485 )
2486 .await
2487 .map_err(ScoutError::from)?;
2488 remove_finalizer(&ctx.client, &ingress)
2489 .await
2490 .map_err(ScoutError::from)?;
2491 }
2492 debug!(ingress = %name, ns = %namespace, "No arecord annotation — skipping");
2493 return Ok(Action::await_change());
2494 }
2495
2496 if !has_finalizer(&ingress) {
2500 add_finalizer(&ctx.client, &ingress)
2501 .await
2502 .map_err(ScoutError::from)?;
2503 debug!(ingress = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2504 return Ok(Action::await_change());
2505 }
2506
2507 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2509 Some(z) => z,
2510 None => {
2511 warn!(ingress = %name, ns = %namespace, "No DNS zone available (set bindy.firestoned.io/zone annotation or BINDY_SCOUT_DEFAULT_ZONE) — skipping");
2512 return Ok(Action::requeue(Duration::from_secs(
2513 SCOUT_ERROR_REQUEUE_SECS,
2514 )));
2515 }
2516 };
2517
2518 match check_zone_authorization_live(
2522 &ctx.remote_client,
2523 &ctx.zone_store.state(),
2524 &zone,
2525 &namespace,
2526 )
2527 .await
2528 {
2529 ZoneAuthz::Authorized => {}
2530 ZoneAuthz::Forbidden => {
2531 warn!(
2532 ingress = %name, ns = %namespace, zone = %zone,
2533 "Ingress namespace not authorized for zone — the DNSZone must live in this \
2534 namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it \
2535 (or '*') — skipping"
2536 );
2537 return Ok(Action::requeue(Duration::from_secs(
2538 SCOUT_ERROR_REQUEUE_SECS,
2539 )));
2540 }
2541 ZoneAuthz::NotFound => {
2542 warn!(
2543 ingress = %name, ns = %namespace, zone = %zone,
2544 "Zone not found in DNSZone store — skipping until zone appears"
2545 );
2546 return Ok(Action::requeue(Duration::from_secs(
2547 SCOUT_ERROR_REQUEUE_SECS,
2548 )));
2549 }
2550 }
2551
2552 let ips = match resolve_ips(&annotations, &ctx.default_ips, &ingress) {
2554 Some(ips) => ips,
2555 None => {
2556 warn!(ingress = %name, ns = %namespace, "No IP available (no annotation override, no default IPs, no LB status IP) — requeuing");
2557 return Ok(Action::requeue(Duration::from_secs(
2558 SCOUT_ERROR_REQUEUE_SECS,
2559 )));
2560 }
2561 };
2562
2563 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2565
2566 let spec_rules = ingress
2567 .spec
2568 .as_ref()
2569 .and_then(|s| s.rules.as_ref())
2570 .cloned()
2571 .unwrap_or_default();
2572
2573 let arecord_api: Api<ARecord> =
2574 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2575
2576 for (idx, rule) in spec_rules.iter().enumerate() {
2577 let host = match rule.host.as_deref() {
2578 Some(h) if !h.is_empty() => h,
2579 _ => {
2580 debug!(ingress = %name, rule_index = idx, "Ingress rule has no host — skipping");
2581 continue;
2582 }
2583 };
2584
2585 let record_name = match resolve_record_name(&annotations, host, &zone) {
2586 Ok(n) => n,
2587 Err(e) => {
2588 warn!(ingress = %name, host = %host, zone = %zone, error = %e, "Host does not belong to zone — skipping rule");
2589 continue;
2590 }
2591 };
2592
2593 let cr_name = arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
2594 let arecord = build_arecord(ARecordParams {
2595 name: &cr_name,
2596 target_namespace: &ctx.target_namespace,
2597 record_name: &record_name,
2598 ips: &ips,
2599 ttl,
2600 cluster_name: &ctx.cluster_name,
2601 ingress_namespace: &namespace,
2602 ingress_name: &name,
2603 zone: &zone,
2604 });
2605
2606 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2608 match arecord_api
2609 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2610 .await
2611 {
2612 Ok(_) => {
2613 info!(arecord = %cr_name, ingress = %name, host = %host, ips = ?ips, "ARecord created/updated");
2614 }
2615 Err(e) => {
2616 error!(arecord = %cr_name, ingress = %name, error = %e, "Failed to apply ARecord");
2617 return Err(ScoutError::from(anyhow!(
2618 "Failed to apply ARecord {cr_name}: {e}"
2619 )));
2620 }
2621 }
2622 }
2623
2624 delete_stale_cluster_arecords(
2627 &ctx.remote_client,
2628 &ctx.target_namespace,
2629 &ctx.cluster_name,
2630 &namespace,
2631 &name,
2632 )
2633 .await
2634 .map_err(ScoutError::from)?;
2635
2636 Ok(Action::await_change())
2637}
2638
2639async fn reconcile_service(
2652 svc: Arc<Service>,
2653 ctx: Arc<ScoutContext>,
2654) -> Result<Action, ScoutError> {
2655 let name = svc.name_any();
2656 let namespace = svc.namespace().unwrap_or_default();
2657
2658 if ctx.excluded_namespaces.contains(&namespace) {
2659 debug!(service = %name, ns = %namespace, "Skipping excluded namespace");
2660 return Ok(Action::await_change());
2661 }
2662
2663 if svc.metadata.deletion_timestamp.is_some() {
2665 if svc
2666 .metadata
2667 .finalizers
2668 .as_ref()
2669 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2670 .unwrap_or(false)
2671 {
2672 info!(service = %name, ns = %namespace, "Service deleting — cleaning up ARecord");
2673 if let Err(e) = delete_arecords_for_service(
2674 &ctx.remote_client,
2675 &ctx.target_namespace,
2676 &ctx.cluster_name,
2677 &namespace,
2678 &name,
2679 )
2680 .await
2681 {
2682 if !cleanup_grace_expired(
2683 svc.metadata.deletion_timestamp.as_ref(),
2684 Timestamp::now(),
2685 ) {
2686 warn!(service = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Service deletion — retrying within grace period");
2687 return Ok(Action::requeue(Duration::from_secs(
2688 SCOUT_ERROR_REQUEUE_SECS,
2689 )));
2690 }
2691 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");
2692 }
2693 remove_finalizer_from_service(&ctx.client, &svc)
2694 .await
2695 .map_err(ScoutError::from)?;
2696 info!(service = %name, ns = %namespace, "Finalizer removed — Service deletion unblocked");
2697 }
2698 return Ok(Action::await_change());
2699 }
2700
2701 let annotations = svc
2702 .metadata
2703 .annotations
2704 .as_ref()
2705 .cloned()
2706 .unwrap_or_default();
2707
2708 let namespace_eligible =
2710 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2711 .await
2712 .map_err(ScoutError::from)?;
2713
2714 if !is_scout_opted_in(&annotations) || !namespace_eligible {
2715 let has_fin = svc
2716 .metadata
2717 .finalizers
2718 .as_ref()
2719 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2720 .unwrap_or(false);
2721 if has_fin {
2722 info!(service = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecord and finalizer");
2723 delete_arecords_for_service(
2724 &ctx.remote_client,
2725 &ctx.target_namespace,
2726 &ctx.cluster_name,
2727 &namespace,
2728 &name,
2729 )
2730 .await
2731 .map_err(ScoutError::from)?;
2732 remove_finalizer_from_service(&ctx.client, &svc)
2733 .await
2734 .map_err(ScoutError::from)?;
2735 }
2736 debug!(service = %name, ns = %namespace, "No scout-enabled annotation — skipping");
2737 return Ok(Action::await_change());
2738 }
2739
2740 if !is_loadbalancer_service(&svc) {
2742 debug!(service = %name, ns = %namespace, "Service is not LoadBalancer type — skipping");
2743 return Ok(Action::await_change());
2744 }
2745
2746 let has_fin = svc
2748 .metadata
2749 .finalizers
2750 .as_ref()
2751 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2752 .unwrap_or(false);
2753 if !has_fin {
2754 add_finalizer_to_service(&ctx.client, &svc)
2755 .await
2756 .map_err(ScoutError::from)?;
2757 debug!(service = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2758 return Ok(Action::await_change());
2759 }
2760
2761 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2763 Some(z) => z,
2764 None => {
2765 warn!(service = %name, ns = %namespace, "No DNS zone available — skipping");
2766 return Ok(Action::requeue(Duration::from_secs(
2767 SCOUT_ERROR_REQUEUE_SECS,
2768 )));
2769 }
2770 };
2771
2772 match check_zone_authorization_live(
2775 &ctx.remote_client,
2776 &ctx.zone_store.state(),
2777 &zone,
2778 &namespace,
2779 )
2780 .await
2781 {
2782 ZoneAuthz::Authorized => {}
2783 ZoneAuthz::Forbidden => {
2784 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");
2785 return Ok(Action::requeue(Duration::from_secs(
2786 SCOUT_ERROR_REQUEUE_SECS,
2787 )));
2788 }
2789 ZoneAuthz::NotFound => {
2790 warn!(service = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
2791 return Ok(Action::requeue(Duration::from_secs(
2792 SCOUT_ERROR_REQUEUE_SECS,
2793 )));
2794 }
2795 }
2796
2797 let ips = {
2799 let from_annotation = resolve_ips_from_annotation(&annotations);
2800 let from_defaults = if ctx.default_ips.is_empty() {
2801 None
2802 } else {
2803 Some(ctx.default_ips.clone())
2804 };
2805 let from_lb = resolve_ip_from_service_lb_status(&svc).map(|ip| vec![ip]);
2806
2807 match from_annotation.or(from_defaults).or(from_lb) {
2808 Some(ips) => ips,
2809 None => {
2810 warn!(service = %name, ns = %namespace, "No external IP yet — requeuing in {}s", SCOUT_ERROR_REQUEUE_SECS);
2811 return Ok(Action::requeue(Duration::from_secs(
2812 SCOUT_ERROR_REQUEUE_SECS,
2813 )));
2814 }
2815 }
2816 };
2817
2818 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2819
2820 let fqdn = format!("{name}.{zone}");
2822 let record_name = match resolve_record_name(&annotations, &fqdn, &zone) {
2823 Ok(n) => n,
2824 Err(e) => {
2825 warn!(service = %name, zone = %zone, error = %e, "Cannot derive record name — skipping");
2826 return Ok(Action::requeue(Duration::from_secs(
2827 SCOUT_ERROR_REQUEUE_SECS,
2828 )));
2829 }
2830 };
2831
2832 let cr_name = service_arecord_cr_name(&ctx.cluster_name, &namespace, &name);
2833 let arecord = build_service_arecord(ServiceARecordParams {
2834 name: &cr_name,
2835 target_namespace: &ctx.target_namespace,
2836 record_name: &record_name,
2837 ips: &ips,
2838 ttl,
2839 cluster_name: &ctx.cluster_name,
2840 service_namespace: &namespace,
2841 service_name: &name,
2842 zone: &zone,
2843 });
2844
2845 let arecord_api: Api<ARecord> =
2846 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2847 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2848 match arecord_api
2849 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2850 .await
2851 {
2852 Ok(_) => {
2853 info!(arecord = %cr_name, service = %name, ips = ?ips, "ARecord created/updated for Service");
2854 }
2855 Err(e) => {
2856 error!(arecord = %cr_name, service = %name, error = %e, "Failed to apply ARecord for Service");
2857 return Err(ScoutError::from(anyhow!(
2858 "Failed to apply ARecord {cr_name}: {e}"
2859 )));
2860 }
2861 }
2862
2863 Ok(Action::await_change())
2864}
2865
2866fn service_error_policy(_obj: Arc<Service>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2868 error!(error = %error, "Scout service reconcile error — requeuing");
2869 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2870}
2871
2872fn error_policy(_obj: Arc<Ingress>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2874 error!(error = %error, "Scout reconcile error — requeuing");
2875 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2876}
2877
2878async fn reconcile_httproute(
2903 route: Arc<HTTPRoute>,
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!(httproute = %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!(httproute = %name, ns = %namespace, "HTTPRoute deleting — cleaning up ARecords");
2925 let cleanup: Result<()> = async {
2926 delete_arecords_for_httproute(
2927 &ctx.remote_client,
2928 &ctx.target_namespace,
2929 &ctx.cluster_name,
2930 &namespace,
2931 &name,
2932 )
2933 .await?;
2934 delete_stale_cluster_httproute_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!(httproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during HTTPRoute deletion — retrying within grace period");
2950 return Ok(Action::requeue(Duration::from_secs(
2951 SCOUT_ERROR_REQUEUE_SECS,
2952 )));
2953 }
2954 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");
2955 }
2956 remove_finalizer_from_httproute(&ctx.client, &route)
2957 .await
2958 .map_err(ScoutError::from)?;
2959 info!(httproute = %name, ns = %namespace, "Finalizer removed — HTTPRoute 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!(httproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2986 delete_arecords_for_httproute(
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_httproute_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_httproute(&ctx.client, &route)
3005 .await
3006 .map_err(ScoutError::from)?;
3007 }
3008 debug!(httproute = %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_httproute(&ctx.client, &route)
3021 .await
3022 .map_err(ScoutError::from)?;
3023 debug!(httproute = %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!(httproute = %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_live(
3041 &ctx.remote_client,
3042 &ctx.zone_store.state(),
3043 &zone,
3044 &namespace,
3045 )
3046 .await
3047 {
3048 ZoneAuthz::Authorized => {}
3049 ZoneAuthz::Forbidden => {
3050 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");
3051 return Ok(Action::requeue(Duration::from_secs(
3052 SCOUT_ERROR_REQUEUE_SECS,
3053 )));
3054 }
3055 ZoneAuthz::NotFound => {
3056 warn!(httproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3057 return Ok(Action::requeue(Duration::from_secs(
3058 SCOUT_ERROR_REQUEUE_SECS,
3059 )));
3060 }
3061 }
3062
3063 let ips = {
3066 let from_annotation = resolve_ips_from_annotation(&annotations);
3067 let from_gateway = if from_annotation.is_some() {
3068 None
3069 } else {
3070 let parent_refs = route
3071 .spec
3072 .as_ref()
3073 .and_then(|s| s.parent_refs.as_ref())
3074 .cloned()
3075 .unwrap_or_default();
3076 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3077 .await
3078 };
3079 let from_defaults = if ctx.default_ips.is_empty() {
3080 None
3081 } else {
3082 Some(ctx.default_ips.clone())
3083 };
3084
3085 match from_annotation.or(from_gateway).or(from_defaults) {
3086 Some(ips) => ips,
3087 None => {
3088 warn!(httproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3089 return Ok(Action::requeue(Duration::from_secs(
3090 SCOUT_ERROR_REQUEUE_SECS,
3091 )));
3092 }
3093 }
3094 };
3095
3096 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3097
3098 let hostnames = route
3100 .spec
3101 .as_ref()
3102 .and_then(|s| s.hostnames.as_ref())
3103 .cloned()
3104 .unwrap_or_default();
3105
3106 let arecord_api: Api<ARecord> =
3107 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3108
3109 for (idx, hostname) in hostnames.iter().enumerate() {
3110 if hostname.is_empty() {
3111 debug!(httproute = %name, hostname_index = idx, "HTTPRoute hostname is empty — skipping");
3112 continue;
3113 }
3114
3115 let record_name = match resolve_record_name(&annotations, hostname, &zone) {
3116 Ok(n) => n,
3117 Err(e) => {
3118 warn!(httproute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
3119 continue;
3120 }
3121 };
3122
3123 let cr_name = httproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
3124 let arecord = build_httproute_arecord(HTTPRouteARecordParams {
3125 name: &cr_name,
3126 target_namespace: &ctx.target_namespace,
3127 record_name: &record_name,
3128 ips: &ips,
3129 ttl,
3130 cluster_name: &ctx.cluster_name,
3131 route_namespace: &namespace,
3132 route_name: &name,
3133 zone: &zone,
3134 });
3135
3136 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3138 match arecord_api
3139 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3140 .await
3141 {
3142 Ok(_) => {
3143 info!(arecord = %cr_name, httproute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for HTTPRoute");
3144 }
3145 Err(e) => {
3146 error!(arecord = %cr_name, httproute = %name, error = %e, "Failed to apply ARecord for HTTPRoute");
3147 return Err(ScoutError::from(anyhow!(
3148 "Failed to apply ARecord {cr_name}: {e}"
3149 )));
3150 }
3151 }
3152 }
3153
3154 delete_stale_cluster_httproute_arecords(
3156 &ctx.remote_client,
3157 &ctx.target_namespace,
3158 &ctx.cluster_name,
3159 &namespace,
3160 &name,
3161 )
3162 .await
3163 .map_err(ScoutError::from)?;
3164
3165 Ok(Action::await_change())
3166}
3167
3168async fn reconcile_tlsroute(
3177 route: Arc<TLSRoute>,
3178 ctx: Arc<ScoutContext>,
3179) -> Result<Action, ScoutError> {
3180 let name = route.name_any();
3181 let namespace = route.namespace().unwrap_or_default();
3182
3183 if ctx.excluded_namespaces.contains(&namespace) {
3185 debug!(tlsroute = %name, ns = %namespace, "Skipping excluded namespace");
3186 return Ok(Action::await_change());
3187 }
3188
3189 if route.metadata.deletion_timestamp.is_some() {
3191 if route
3192 .metadata
3193 .finalizers
3194 .as_ref()
3195 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3196 .unwrap_or(false)
3197 {
3198 info!(tlsroute = %name, ns = %namespace, "TLSRoute deleting — cleaning up ARecords");
3199 let cleanup: Result<()> = async {
3200 delete_arecords_for_tlsroute(
3201 &ctx.remote_client,
3202 &ctx.target_namespace,
3203 &ctx.cluster_name,
3204 &namespace,
3205 &name,
3206 )
3207 .await?;
3208 delete_stale_cluster_tlsroute_arecords(
3209 &ctx.remote_client,
3210 &ctx.target_namespace,
3211 &ctx.cluster_name,
3212 &namespace,
3213 &name,
3214 )
3215 .await
3216 }
3217 .await;
3218 if let Err(e) = cleanup {
3219 if !cleanup_grace_expired(
3220 route.metadata.deletion_timestamp.as_ref(),
3221 Timestamp::now(),
3222 ) {
3223 warn!(tlsroute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TLSRoute deletion — retrying within grace period");
3224 return Ok(Action::requeue(Duration::from_secs(
3225 SCOUT_ERROR_REQUEUE_SECS,
3226 )));
3227 }
3228 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");
3229 }
3230 remove_finalizer_from_tlsroute(&ctx.client, &route)
3231 .await
3232 .map_err(ScoutError::from)?;
3233 info!(tlsroute = %name, ns = %namespace, "Finalizer removed — TLSRoute deletion unblocked");
3234 }
3235 return Ok(Action::await_change());
3236 }
3237
3238 let annotations = route
3239 .metadata
3240 .annotations
3241 .as_ref()
3242 .cloned()
3243 .unwrap_or_default();
3244
3245 let namespace_eligible =
3247 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
3248 .await
3249 .map_err(ScoutError::from)?;
3250
3251 if !is_scout_opted_in(&annotations) || !namespace_eligible {
3252 let has_fin = route
3253 .metadata
3254 .finalizers
3255 .as_ref()
3256 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3257 .unwrap_or(false);
3258 if has_fin {
3259 info!(tlsroute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
3260 delete_arecords_for_tlsroute(
3261 &ctx.remote_client,
3262 &ctx.target_namespace,
3263 &ctx.cluster_name,
3264 &namespace,
3265 &name,
3266 )
3267 .await
3268 .map_err(ScoutError::from)?;
3269 delete_stale_cluster_tlsroute_arecords(
3270 &ctx.remote_client,
3271 &ctx.target_namespace,
3272 &ctx.cluster_name,
3273 &namespace,
3274 &name,
3275 )
3276 .await
3277 .map_err(ScoutError::from)?;
3278 remove_finalizer_from_tlsroute(&ctx.client, &route)
3279 .await
3280 .map_err(ScoutError::from)?;
3281 }
3282 debug!(tlsroute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3283 return Ok(Action::await_change());
3284 }
3285
3286 let has_fin = route
3288 .metadata
3289 .finalizers
3290 .as_ref()
3291 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3292 .unwrap_or(false);
3293 if !has_fin {
3294 add_finalizer_to_tlsroute(&ctx.client, &route)
3295 .await
3296 .map_err(ScoutError::from)?;
3297 debug!(tlsroute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3298 return Ok(Action::await_change());
3299 }
3300
3301 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3303 Some(z) => z,
3304 None => {
3305 warn!(tlsroute = %name, ns = %namespace, "No DNS zone available — skipping");
3306 return Ok(Action::requeue(Duration::from_secs(
3307 SCOUT_ERROR_REQUEUE_SECS,
3308 )));
3309 }
3310 };
3311
3312 match check_zone_authorization_live(
3315 &ctx.remote_client,
3316 &ctx.zone_store.state(),
3317 &zone,
3318 &namespace,
3319 )
3320 .await
3321 {
3322 ZoneAuthz::Authorized => {}
3323 ZoneAuthz::Forbidden => {
3324 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");
3325 return Ok(Action::requeue(Duration::from_secs(
3326 SCOUT_ERROR_REQUEUE_SECS,
3327 )));
3328 }
3329 ZoneAuthz::NotFound => {
3330 warn!(tlsroute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3331 return Ok(Action::requeue(Duration::from_secs(
3332 SCOUT_ERROR_REQUEUE_SECS,
3333 )));
3334 }
3335 }
3336
3337 let ips = {
3340 let from_annotation = resolve_ips_from_annotation(&annotations);
3341 let from_gateway = if from_annotation.is_some() {
3342 None
3343 } else {
3344 let parent_refs = route
3345 .spec
3346 .as_ref()
3347 .and_then(|s| s.parent_refs.as_ref())
3348 .cloned()
3349 .unwrap_or_default();
3350 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3351 .await
3352 };
3353 let from_defaults = if ctx.default_ips.is_empty() {
3354 None
3355 } else {
3356 Some(ctx.default_ips.clone())
3357 };
3358
3359 match from_annotation.or(from_gateway).or(from_defaults) {
3360 Some(ips) => ips,
3361 None => {
3362 warn!(tlsroute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3363 return Ok(Action::requeue(Duration::from_secs(
3364 SCOUT_ERROR_REQUEUE_SECS,
3365 )));
3366 }
3367 }
3368 };
3369
3370 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3371
3372 let hostnames = route
3374 .spec
3375 .as_ref()
3376 .and_then(|s| s.hostnames.as_ref())
3377 .cloned()
3378 .unwrap_or_default();
3379
3380 let arecord_api: Api<ARecord> =
3381 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3382
3383 for (idx, hostname) in hostnames.iter().enumerate() {
3384 if hostname.is_empty() {
3385 debug!(tlsroute = %name, hostname_index = idx, "TLSRoute hostname is empty — skipping");
3386 continue;
3387 }
3388
3389 let record_name = match resolve_record_name(&annotations, hostname, &zone) {
3390 Ok(n) => n,
3391 Err(e) => {
3392 warn!(tlsroute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
3393 continue;
3394 }
3395 };
3396
3397 let cr_name = tlsroute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
3398 let arecord = build_tlsroute_arecord(TLSRouteARecordParams {
3399 name: &cr_name,
3400 target_namespace: &ctx.target_namespace,
3401 record_name: &record_name,
3402 ips: &ips,
3403 ttl,
3404 cluster_name: &ctx.cluster_name,
3405 route_namespace: &namespace,
3406 route_name: &name,
3407 zone: &zone,
3408 });
3409
3410 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3412 match arecord_api
3413 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3414 .await
3415 {
3416 Ok(_) => {
3417 info!(arecord = %cr_name, tlsroute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for TLSRoute");
3418 }
3419 Err(e) => {
3420 error!(arecord = %cr_name, tlsroute = %name, error = %e, "Failed to apply ARecord for TLSRoute");
3421 return Err(ScoutError::from(anyhow!(
3422 "Failed to apply ARecord {cr_name}: {e}"
3423 )));
3424 }
3425 }
3426 }
3427
3428 delete_stale_cluster_tlsroute_arecords(
3430 &ctx.remote_client,
3431 &ctx.target_namespace,
3432 &ctx.cluster_name,
3433 &namespace,
3434 &name,
3435 )
3436 .await
3437 .map_err(ScoutError::from)?;
3438
3439 Ok(Action::await_change())
3440}
3441
3442async fn reconcile_tcproute(
3444 route: Arc<TCPRoute>,
3445 ctx: Arc<ScoutContext>,
3446) -> Result<Action, ScoutError> {
3447 let name = route.name_any();
3448 let namespace = route.namespace().unwrap_or_default();
3449
3450 if ctx.excluded_namespaces.contains(&namespace) {
3451 debug!(tcproute = %name, ns = %namespace, "Skipping excluded namespace");
3452 return Ok(Action::await_change());
3453 }
3454
3455 if route.metadata.deletion_timestamp.is_some() {
3456 if route
3457 .metadata
3458 .finalizers
3459 .as_ref()
3460 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3461 .unwrap_or(false)
3462 {
3463 info!(tcproute = %name, ns = %namespace, "TCPRoute deleting — cleaning up ARecords");
3464 let cleanup: Result<()> = async {
3465 delete_arecords_for_tcproute(
3466 &ctx.remote_client,
3467 &ctx.target_namespace,
3468 &ctx.cluster_name,
3469 &namespace,
3470 &name,
3471 )
3472 .await?;
3473 delete_stale_cluster_tcproute_arecords(
3474 &ctx.remote_client,
3475 &ctx.target_namespace,
3476 &ctx.cluster_name,
3477 &namespace,
3478 &name,
3479 )
3480 .await
3481 }
3482 .await;
3483 if let Err(e) = cleanup {
3484 if !cleanup_grace_expired(
3485 route.metadata.deletion_timestamp.as_ref(),
3486 Timestamp::now(),
3487 ) {
3488 warn!(tcproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TCPRoute deletion — retrying within grace period");
3489 return Ok(Action::requeue(Duration::from_secs(
3490 SCOUT_ERROR_REQUEUE_SECS,
3491 )));
3492 }
3493 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");
3494 }
3495 remove_finalizer_from_tcproute(&ctx.client, &route)
3496 .await
3497 .map_err(ScoutError::from)?;
3498 info!(tcproute = %name, ns = %namespace, "Finalizer removed — TCPRoute deletion unblocked");
3499 }
3500 return Ok(Action::await_change());
3501 }
3502
3503 let annotations = route
3504 .metadata
3505 .annotations
3506 .as_ref()
3507 .cloned()
3508 .unwrap_or_default();
3509
3510 let namespace_eligible =
3511 source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
3512 .await
3513 .map_err(ScoutError::from)?;
3514
3515 if !is_scout_opted_in(&annotations) || !namespace_eligible {
3516 let has_fin = route
3517 .metadata
3518 .finalizers
3519 .as_ref()
3520 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3521 .unwrap_or(false);
3522 if has_fin {
3523 info!(tcproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
3524 delete_arecords_for_tcproute(
3525 &ctx.remote_client,
3526 &ctx.target_namespace,
3527 &ctx.cluster_name,
3528 &namespace,
3529 &name,
3530 )
3531 .await
3532 .map_err(ScoutError::from)?;
3533 delete_stale_cluster_tcproute_arecords(
3534 &ctx.remote_client,
3535 &ctx.target_namespace,
3536 &ctx.cluster_name,
3537 &namespace,
3538 &name,
3539 )
3540 .await
3541 .map_err(ScoutError::from)?;
3542 remove_finalizer_from_tcproute(&ctx.client, &route)
3543 .await
3544 .map_err(ScoutError::from)?;
3545 }
3546 debug!(tcproute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3547 return Ok(Action::await_change());
3548 }
3549
3550 let has_fin = route
3551 .metadata
3552 .finalizers
3553 .as_ref()
3554 .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3555 .unwrap_or(false);
3556 if !has_fin {
3557 add_finalizer_to_tcproute(&ctx.client, &route)
3558 .await
3559 .map_err(ScoutError::from)?;
3560 debug!(tcproute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3561 return Ok(Action::await_change());
3562 }
3563
3564 let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3565 Some(z) => z,
3566 None => {
3567 warn!(tcproute = %name, ns = %namespace, "No DNS zone available — skipping");
3568 return Ok(Action::requeue(Duration::from_secs(
3569 SCOUT_ERROR_REQUEUE_SECS,
3570 )));
3571 }
3572 };
3573
3574 match check_zone_authorization_live(
3575 &ctx.remote_client,
3576 &ctx.zone_store.state(),
3577 &zone,
3578 &namespace,
3579 )
3580 .await
3581 {
3582 ZoneAuthz::Authorized => {}
3583 ZoneAuthz::Forbidden => {
3584 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");
3585 return Ok(Action::requeue(Duration::from_secs(
3586 SCOUT_ERROR_REQUEUE_SECS,
3587 )));
3588 }
3589 ZoneAuthz::NotFound => {
3590 warn!(tcproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3591 return Ok(Action::requeue(Duration::from_secs(
3592 SCOUT_ERROR_REQUEUE_SECS,
3593 )));
3594 }
3595 }
3596
3597 let ips = {
3598 let from_annotation = resolve_ips_from_annotation(&annotations);
3599 let from_gateway = if from_annotation.is_some() {
3600 None
3601 } else {
3602 let parent_refs = route
3603 .spec
3604 .as_ref()
3605 .and_then(|s| s.parent_refs.as_ref())
3606 .cloned()
3607 .unwrap_or_default();
3608 resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3609 .await
3610 };
3611 let from_defaults = if ctx.default_ips.is_empty() {
3612 None
3613 } else {
3614 Some(ctx.default_ips.clone())
3615 };
3616
3617 match from_annotation.or(from_gateway).or(from_defaults) {
3618 Some(ips) => ips,
3619 None => {
3620 warn!(tcproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3621 return Ok(Action::requeue(Duration::from_secs(
3622 SCOUT_ERROR_REQUEUE_SECS,
3623 )));
3624 }
3625 }
3626 };
3627
3628 let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3629
3630 let Some(record_name) = get_record_name_annotation(&annotations) else {
3631 warn!(tcproute = %name, ns = %namespace, "TCPRoute has no record-name override — skipping (add bindy.firestoned.io/record-name annotation)");
3632 return Ok(Action::requeue(Duration::from_secs(
3633 SCOUT_ERROR_REQUEUE_SECS,
3634 )));
3635 };
3636
3637 let arecord_api: Api<ARecord> =
3638 Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3639
3640 let cr_name = tcproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, 0);
3641 let arecord = build_tcproute_arecord(TCPRouteARecordParams {
3642 name: &cr_name,
3643 target_namespace: &ctx.target_namespace,
3644 record_name: &record_name,
3645 ips: &ips,
3646 ttl,
3647 cluster_name: &ctx.cluster_name,
3648 route_namespace: &namespace,
3649 route_name: &name,
3650 zone: &zone,
3651 });
3652
3653 let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3654 match arecord_api
3655 .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3656 .await
3657 {
3658 Ok(_) => {
3659 info!(arecord = %cr_name, tcproute = %name, record_name = %record_name, ips = ?ips, "ARecord created/updated for TCPRoute");
3660 }
3661 Err(e) => {
3662 error!(arecord = %cr_name, tcproute = %name, error = %e, "Failed to apply ARecord for TCPRoute");
3663 return Err(ScoutError::from(anyhow!(
3664 "Failed to apply ARecord {cr_name}: {e}"
3665 )));
3666 }
3667 }
3668
3669 delete_stale_cluster_tcproute_arecords(
3670 &ctx.remote_client,
3671 &ctx.target_namespace,
3672 &ctx.cluster_name,
3673 &namespace,
3674 &name,
3675 )
3676 .await
3677 .map_err(ScoutError::from)?;
3678
3679 Ok(Action::await_change())
3680}
3681
3682fn gateway_route_error_policy(
3684 _obj: Arc<HTTPRoute>,
3685 error: &ScoutError,
3686 _ctx: Arc<ScoutContext>,
3687) -> Action {
3688 error!(error = %error, "Scout HTTPRoute reconcile error — requeuing");
3689 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3690}
3691
3692fn tlsroute_error_policy(
3694 _obj: Arc<TLSRoute>,
3695 error: &ScoutError,
3696 _ctx: Arc<ScoutContext>,
3697) -> Action {
3698 error!(error = %error, "Scout TLSRoute reconcile error — requeuing");
3699 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3700}
3701
3702fn tcproute_error_policy(
3704 _obj: Arc<TCPRoute>,
3705 error: &ScoutError,
3706 _ctx: Arc<ScoutContext>,
3707) -> Action {
3708 error!(error = %error, "Scout TCPRoute reconcile error — requeuing");
3709 Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3710}
3711
3712async fn build_remote_client(
3727 local_client: &Client,
3728 secret_name: &str,
3729 secret_namespace: &str,
3730) -> Result<Client> {
3731 let api: Api<Secret> = Api::namespaced(local_client.clone(), secret_namespace);
3732 let secret = api.get(secret_name).await.map_err(|e| {
3733 anyhow!("Failed to read kubeconfig Secret {secret_namespace}/{secret_name}: {e}")
3734 })?;
3735
3736 let kubeconfig_bytes = secret
3737 .data
3738 .as_ref()
3739 .and_then(|d| d.get("kubeconfig"))
3740 .ok_or_else(|| {
3741 anyhow!("Secret {secret_namespace}/{secret_name} has no 'kubeconfig' key in .data")
3742 })?;
3743
3744 let kubeconfig_str = std::str::from_utf8(&kubeconfig_bytes.0)
3745 .map_err(|e| anyhow!("kubeconfig in Secret is not valid UTF-8: {e}"))?;
3746
3747 let kubeconfig = Kubeconfig::from_yaml(kubeconfig_str)
3748 .map_err(|e| anyhow!("Failed to parse kubeconfig from Secret: {e}"))?;
3749
3750 let config = kube::Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
3751 .await
3752 .map_err(|e| anyhow!("Failed to build client config from kubeconfig: {e}"))?;
3753
3754 Client::try_from(config).map_err(|e| anyhow!("Failed to create remote Kubernetes client: {e}"))
3755}
3756
3757struct ScoutConfig {
3763 target_namespace: String,
3764 cluster_name: String,
3765 excluded_namespaces: Vec<String>,
3766 default_ips: Vec<String>,
3769 gateway_services: BTreeMap<String, GatewayServiceTarget>,
3772 default_zone: Option<String>,
3775 namespace_selector: Option<String>,
3779 remote_secret_name: Option<String>,
3782 remote_secret_namespace: String,
3784}
3785
3786impl ScoutConfig {
3787 fn from_env(
3791 cli_cluster_name: Option<String>,
3792 cli_namespace: Option<String>,
3793 cli_default_ips: Vec<String>,
3794 cli_gateway_services: Vec<String>,
3795 cli_default_zone: Option<String>,
3796 cli_namespace_selector: Option<String>,
3797 ) -> Result<Self> {
3798 let target_namespace = cli_namespace
3799 .filter(|s| !s.is_empty())
3800 .or_else(|| std::env::var("BINDY_SCOUT_NAMESPACE").ok())
3801 .unwrap_or_else(|| DEFAULT_SCOUT_NAMESPACE.to_string());
3802
3803 let cluster_name = cli_cluster_name
3804 .filter(|s| !s.is_empty())
3805 .or_else(|| std::env::var("BINDY_SCOUT_CLUSTER_NAME").ok())
3806 .ok_or_else(|| {
3807 anyhow!("BINDY_SCOUT_CLUSTER_NAME is required (set via --cluster-name or env var)")
3808 })?;
3809
3810 let own_namespace =
3811 std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string());
3812
3813 let mut excluded_namespaces: Vec<String> = std::env::var("BINDY_SCOUT_EXCLUDE_NAMESPACES")
3814 .unwrap_or_default()
3815 .split(',')
3816 .map(str::trim)
3817 .filter(|s| !s.is_empty())
3818 .map(ToString::to_string)
3819 .collect();
3820
3821 if !excluded_namespaces.contains(&own_namespace) {
3823 excluded_namespaces.push(own_namespace.clone());
3824 }
3825
3826 let default_ips = if !cli_default_ips.is_empty() {
3828 cli_default_ips
3829 } else {
3830 std::env::var("BINDY_SCOUT_DEFAULT_IPS")
3831 .unwrap_or_default()
3832 .split(',')
3833 .map(str::trim)
3834 .filter(|s| !s.is_empty())
3835 .map(ToString::to_string)
3836 .collect()
3837 };
3838
3839 let gateway_services = if cli_gateway_services.is_empty() {
3844 parse_gateway_services(
3845 &std::env::var("BINDY_SCOUT_GATEWAY_SERVICES").unwrap_or_default(),
3846 )
3847 } else {
3848 cli_gateway_services
3849 .iter()
3850 .filter_map(|e| parse_gateway_service_entry(e))
3851 .collect()
3852 };
3853
3854 let default_zone = cli_default_zone.filter(|s| !s.is_empty()).or_else(|| {
3856 std::env::var("BINDY_SCOUT_DEFAULT_ZONE")
3857 .ok()
3858 .filter(|s| !s.is_empty())
3859 });
3860
3861 let namespace_selector = cli_namespace_selector
3864 .filter(|s| !s.is_empty())
3865 .or_else(|| {
3866 std::env::var("BINDY_SCOUT_NAMESPACE_SELECTOR")
3867 .ok()
3868 .filter(|s| !s.is_empty())
3869 });
3870
3871 let remote_secret_name = std::env::var("BINDY_SCOUT_REMOTE_SECRET")
3872 .ok()
3873 .filter(|s| !s.is_empty());
3874
3875 let remote_secret_namespace =
3876 std::env::var("BINDY_SCOUT_REMOTE_SECRET_NAMESPACE").unwrap_or(own_namespace);
3877
3878 Ok(Self {
3879 target_namespace,
3880 cluster_name,
3881 excluded_namespaces,
3882 default_ips,
3883 gateway_services,
3884 default_zone,
3885 namespace_selector,
3886 remote_secret_name,
3887 remote_secret_namespace,
3888 })
3889 }
3890}
3891
3892fn diagnose_reflector_error(e: &watcher::Error) -> String {
3902 let (phase, client_err) = match e {
3905 watcher::Error::InitialListFailed(e) => ("initial list", e),
3906 watcher::Error::WatchStartFailed(e) => ("watch start", e),
3907 watcher::Error::WatchFailed(e) => ("watch stream", e),
3908 watcher::Error::WatchError(status) => {
3909 return format!(
3910 "API server returned error during watch: {} (HTTP {})",
3911 status.message, status.code
3912 );
3913 }
3914 watcher::Error::NoResourceVersion => {
3915 return "resource does not support watch (no resourceVersion returned)".to_string();
3916 }
3917 };
3918
3919 let detail = match client_err {
3920 KubeError::Api(status) => match status.code {
3921 401 => format!(
3922 "unauthorized — check credentials/token ({})",
3923 status.message
3924 ),
3925 403 => format!("forbidden — check RBAC permissions ({})", status.message),
3926 code => format!("API error HTTP {code} — {}", status.message),
3927 },
3928 KubeError::Auth(e) => format!("authentication error — {e}"),
3929 KubeError::Service(e) => format!("cannot connect to API server — {e}"),
3930 KubeError::HyperError(e) => format!("HTTP transport error — {e}"),
3931 other => format!("{other}"),
3932 };
3933
3934 format!("{phase} failed: {detail}")
3935}
3936
3937pub async fn run_scout(
3947 cli_cluster_name: Option<String>,
3948 cli_namespace: Option<String>,
3949 cli_default_ips: Vec<String>,
3950 cli_gateway_services: Vec<String>,
3951 cli_default_zone: Option<String>,
3952 cli_namespace_selector: Option<String>,
3953) -> Result<()> {
3954 let config = ScoutConfig::from_env(
3955 cli_cluster_name,
3956 cli_namespace,
3957 cli_default_ips,
3958 cli_gateway_services,
3959 cli_default_zone,
3960 cli_namespace_selector,
3961 )?;
3962
3963 let local_client = Client::try_default().await?;
3964
3965 let remote_client = if let Some(ref secret_name) = config.remote_secret_name {
3966 info!(
3967 cluster = %config.cluster_name,
3968 target_ns = %config.target_namespace,
3969 secret = %secret_name,
3970 secret_ns = %config.remote_secret_namespace,
3971 excluded = ?config.excluded_namespaces,
3972 default_ips = ?config.default_ips,
3973 default_zone = ?config.default_zone,
3974 namespace_selector = ?config.namespace_selector,
3975 "Starting bindy scout in remote cluster mode"
3976 );
3977 build_remote_client(&local_client, secret_name, &config.remote_secret_namespace).await?
3978 } else {
3979 info!(
3980 cluster = %config.cluster_name,
3981 target_ns = %config.target_namespace,
3982 excluded = ?config.excluded_namespaces,
3983 default_ips = ?config.default_ips,
3984 default_zone = ?config.default_zone,
3985 namespace_selector = ?config.namespace_selector,
3986 "Starting bindy scout in same-cluster mode"
3987 );
3988 local_client.clone()
3989 };
3990
3991 if config.namespace_selector.is_none() {
3992 warn!(
3993 "No --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR configured — scout will \
3994 act in EVERY namespace in the cluster (subject only to each source object's own \
3995 opt-in annotation and --exclude-namespaces). Setting a namespace-selector so scout \
3996 only considers explicitly-whitelisted namespaces is strongly recommended for \
3997 production deployments; running without one is not recommended."
3998 );
3999 }
4000
4001 let dnszone_api: Api<DNSZone> =
4007 Api::namespaced(remote_client.clone(), &config.target_namespace);
4008 let (dnszone_reader, dnszone_writer) = reflector::store();
4009 let dnszone_reflector = reflector(
4010 dnszone_writer,
4011 watcher(dnszone_api, WatcherConfig::default()),
4012 );
4013
4014 tokio::spawn(async move {
4019 dnszone_reflector
4020 .for_each(|event| async move {
4021 match event {
4022 Ok(_) => {}
4023 Err(e) => {
4024 error!(diagnosis = %diagnose_reflector_error(&e), "DNSZone reflector error");
4025 tokio::time::sleep(tokio::time::Duration::from_secs(
4026 REFLECTOR_ERROR_BACKOFF_SECS,
4027 ))
4028 .await;
4029 }
4030 }
4031 })
4032 .await;
4033 });
4034
4035 let ctx = Arc::new(ScoutContext {
4036 client: local_client.clone(),
4037 remote_client,
4038 target_namespace: config.target_namespace,
4039 cluster_name: config.cluster_name,
4040 excluded_namespaces: config.excluded_namespaces,
4041 default_ips: config.default_ips,
4042 gateway_services: config.gateway_services,
4043 default_zone: config.default_zone,
4044 namespace_selector: config.namespace_selector,
4045 zone_store: dnszone_reader,
4046 });
4047
4048 let ingress_api: Api<Ingress> = Api::all(local_client.clone());
4050 let svc_api: Api<Service> = Api::all(local_client.clone());
4052 let httproute_api: Api<HTTPRoute> = Api::all(local_client.clone());
4054 let tlsroute_api: Api<TLSRoute> = Api::all(local_client.clone());
4056 let tcproute_api: Api<TCPRoute> = Api::all(local_client.clone());
4058
4059 let httproute_enabled = kind_served(&httproute_api).await;
4069 let tlsroute_enabled = kind_served(&tlsroute_api).await;
4070 let tcproute_enabled = kind_served(&tcproute_api).await;
4071
4072 let mut watching = vec!["Ingresses", "Services"];
4073 if httproute_enabled {
4074 watching.push("HTTPRoutes");
4075 }
4076 if tlsroute_enabled {
4077 watching.push("TLSRoutes");
4078 }
4079 if tcproute_enabled {
4080 watching.push("TCPRoutes");
4081 }
4082 info!(
4083 "Scout controller running — watching {}",
4084 watching.join(", ")
4085 );
4086
4087 let mut disabled = vec![];
4088 if !httproute_enabled {
4089 disabled.push("HTTPRoute");
4090 }
4091 if !tlsroute_enabled {
4092 disabled.push("TLSRoute");
4093 }
4094 if !tcproute_enabled {
4095 disabled.push("TCPRoute");
4096 }
4097 if !disabled.is_empty() {
4098 info!(
4099 "Gateway API CRDs not found for {}; watching disabled for those kinds",
4100 disabled.join("/")
4101 );
4102 }
4103
4104 let ingress_controller = Controller::new(ingress_api, WatcherConfig::default())
4105 .run(reconcile, error_policy, ctx.clone())
4106 .for_each(|res| async move {
4107 match res {
4108 Ok(obj) => debug!(obj = ?obj, "Reconciled Ingress"),
4109 Err(e) => error!(error = %e, "Ingress reconcile failed"),
4110 }
4111 });
4112
4113 let service_controller = Controller::new(svc_api, WatcherConfig::default())
4114 .run(reconcile_service, service_error_policy, ctx.clone())
4115 .for_each(|res| async move {
4116 match res {
4117 Ok(obj) => debug!(obj = ?obj, "Reconciled Service"),
4118 Err(e) => error!(error = %e, "Service reconcile failed"),
4119 }
4120 });
4121
4122 let httproute_controller = Controller::new(httproute_api, WatcherConfig::default())
4123 .run(reconcile_httproute, gateway_route_error_policy, ctx.clone())
4124 .for_each(|res| async move {
4125 match res {
4126 Ok(obj) => debug!(obj = ?obj, "Reconciled HTTPRoute"),
4127 Err(e) => error!(error = %e, "HTTPRoute reconcile failed"),
4128 }
4129 });
4130
4131 let tlsroute_controller = Controller::new(tlsroute_api, WatcherConfig::default())
4132 .run(reconcile_tlsroute, tlsroute_error_policy, ctx.clone())
4133 .for_each(|res| async move {
4134 match res {
4135 Ok(obj) => debug!(obj = ?obj, "Reconciled TLSRoute"),
4136 Err(e) => error!(error = %e, "TLSRoute reconcile failed"),
4137 }
4138 });
4139
4140 let tcproute_controller = Controller::new(tcproute_api, WatcherConfig::default())
4141 .run(reconcile_tcproute, tcproute_error_policy, ctx)
4142 .for_each(|res| async move {
4143 match res {
4144 Ok(obj) => debug!(obj = ?obj, "Reconciled TCPRoute"),
4145 Err(e) => error!(error = %e, "TCPRoute reconcile failed"),
4146 }
4147 });
4148
4149 let mut controllers: Vec<Pin<Box<dyn Future<Output = ()> + Send>>> =
4153 vec![Box::pin(ingress_controller), Box::pin(service_controller)];
4154 if httproute_enabled {
4155 controllers.push(Box::pin(httproute_controller));
4156 }
4157 if tlsroute_enabled {
4158 controllers.push(Box::pin(tlsroute_controller));
4159 }
4160 if tcproute_enabled {
4161 controllers.push(Box::pin(tcproute_controller));
4162 }
4163
4164 futures::future::join_all(controllers).await;
4165
4166 Ok(())
4167}
4168
4169pub async fn kind_served<R>(api: &Api<R>) -> bool
4195where
4196 R: Clone + DeserializeOwned + Debug + k8s_openapi::Resource,
4197{
4198 match api.list(&ListParams::default().limit(1)).await {
4199 Ok(_) => true,
4200 Err(kube::Error::Api(err)) if err.code == HTTP_NOT_FOUND => false,
4201 Err(err) => {
4202 warn!(
4203 kind = R::KIND,
4204 error = %err,
4205 "could not determine whether this kind is served; assuming it is"
4206 );
4207 true
4208 }
4209 }
4210}