bindy/
scout.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Bindy Scout — Ingress-to-ARecord controller.
5//!
6//! Scout watches Kubernetes Ingresses across all namespaces (except its own and any
7//! configured exclusions). When an Ingress is annotated with
8//! `bindy.firestoned.io/recordKind: "ARecord"`, Scout creates an [`ARecord`] CR in the
9//! configured target namespace.
10//!
11//! See `.github/community/30-scout-ingress-controller.md` for the full design.
12//!
13//! ## Phase 1 / 1.5 — Same-cluster mode (current)
14//!
15//! Scout uses a single in-cluster client. ARecords are created in the same cluster.
16//!
17//! ## Phase 2 — Remote cluster mode
18//!
19//! When `BINDY_SCOUT_REMOTE_SECRET` is set, Scout reads a kubeconfig from a Kubernetes
20//! Secret and builds a second client (`remote_client`) targeting the dedicated Bindy cluster.
21//! The local client still handles Ingress watching and finalizer management.
22//! The remote client handles ARecord creation/deletion and DNSZone validation.
23
24use 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/// Reconcile error type — wraps `anyhow::Error` so that it satisfies the
36/// `std::error::Error` bound required by `kube::runtime::Controller::run`.
37#[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// ============================================================================
54// Gateway API Type Definitions
55//
56// HTTPRoute and TLSRoute are not in k8s_openapi yet, so we define minimal structs.
57// We only care about metadata and spec.hostnames[] for Scout's reconciliation.
58// ============================================================================
59
60/// A minimal Gateway API `parentRef` — the reference from a route back to the
61/// Gateway (or other parent) that serves it.
62///
63/// Only the fields Scout needs to walk route → Gateway are modelled. Per the
64/// Gateway API defaults, an omitted `group` means `gateway.networking.k8s.io`
65/// and an omitted `kind` means `Gateway`.
66#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
67#[serde(rename_all = "camelCase")]
68pub struct ParentReference {
69    /// API group of the parent. Defaults to `gateway.networking.k8s.io` when absent.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub group: Option<String>,
72    /// Kind of the parent. Defaults to `Gateway` when absent.
73    #[serde(default, skip_serializing_if = "Option::is_none")]
74    pub kind: Option<String>,
75    /// Namespace of the parent. Defaults to the route's own namespace when absent.
76    #[serde(default, skip_serializing_if = "Option::is_none")]
77    pub namespace: Option<String>,
78    /// Name of the parent Gateway.
79    pub name: String,
80}
81
82/// Minimal HTTPRoute spec for Scout's use case.
83#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
84#[serde(rename_all = "camelCase")]
85pub struct HTTPRouteSpec {
86    /// Hostnames matching this HTTPRoute
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub hostnames: Option<Vec<String>>,
89    /// Gateways this route attaches to. Scout follows these to discover the
90    /// serving Gateway's external IP when no explicit IP annotation is set.
91    #[serde(default, skip_serializing_if = "Option::is_none")]
92    pub parent_refs: Option<Vec<ParentReference>>,
93}
94
95/// Minimal HTTPRoute definition for Scout's use case.
96#[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/// Minimal TLSRoute spec for Scout's use case.
107#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
108#[serde(rename_all = "camelCase")]
109pub struct TLSRouteSpec {
110    /// Hostnames matching this TLSRoute
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub hostnames: Option<Vec<String>>,
113    /// Rules for this TLSRoute (required by API, but Scout only uses hostnames)
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub rules: Option<Vec<serde_json::Value>>,
116    /// Gateways this route attaches to. Scout follows these to discover the
117    /// serving Gateway's external IP when no explicit IP annotation is set.
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub parent_refs: Option<Vec<ParentReference>>,
120}
121
122/// Minimal TLSRoute definition for Scout's use case.
123#[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/// Minimal TCPRoute spec for Scout's use case.
134#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
135#[serde(rename_all = "camelCase")]
136pub struct TCPRouteSpec {
137    /// Rules for this TCPRoute (kept for schema compatibility with the
138    /// Gateway API). Scout does not currently parse individual rule contents
139    /// for TCPRoute (L4), but keeping the field avoids schema drift when the
140    /// CR is round-tripped by the controller.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub rules: Option<Vec<serde_json::Value>>,
143    /// Gateways this route attaches to. Scout follows these to discover the
144    /// serving Gateway's external IP when no explicit IP annotation is set.
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub parent_refs: Option<Vec<ParentReference>>,
147}
148
149/// Minimal TCPRoute definition for Scout's use case.
150#[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
160// Implement k8s_openapi::Metadata for HTTPRoute and TLSRoute
161impl 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
181// Implement k8s_openapi::Resource for HTTPRoute and TLSRoute
182impl 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
219/// Gateway API group used for `parentRefs` and Gateway lookups.
220pub const GATEWAY_API_GROUP: &str = "gateway.networking.k8s.io";
221
222/// Gateway API `kind` for a Gateway parent reference.
223pub const GATEWAY_KIND: &str = "Gateway";
224
225/// A namespaced object reference (`namespace` + `name`).
226///
227/// Used both for the operator-configured `gatewayClass → LoadBalancer Service`
228/// map and for the Gateways a route's `parentRefs` point at.
229#[derive(Debug, Clone, PartialEq, Eq)]
230pub struct NamespacedName {
231    /// Object namespace.
232    pub namespace: String,
233    /// Object name.
234    pub name: String,
235}
236
237/// How Scout locates the LoadBalancer Service backing a gateway class.
238///
239/// Configured per `gatewayClass` so operators can pin the exact Service — either
240/// by an explicit `namespace/name`, or by a label selector scoped to a namespace
241/// (useful when the Service name is generated but carries stable labels).
242#[derive(Debug, Clone, PartialEq, Eq)]
243pub enum GatewayServiceTarget {
244    /// A specific LoadBalancer Service, addressed by namespace and name.
245    Name(NamespacedName),
246    /// A LoadBalancer Service found by label selector within a namespace.
247    /// `selector` is a standard Kubernetes label-selector string, e.g.
248    /// `app.kubernetes.io/name=traefik`.
249    Labeled {
250        /// Namespace to search for the Service.
251        namespace: String,
252        /// Kubernetes label selector identifying the Service.
253        selector: String,
254    },
255}
256
257impl GatewayServiceTarget {
258    /// Namespace the target Service lives in.
259    #[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/// Minimal Gateway spec — only `gatewayClassName`, used to match the running class.
269#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
270#[serde(rename_all = "camelCase")]
271pub struct GatewaySpec {
272    /// Name of the GatewayClass implementing this Gateway.
273    pub gateway_class_name: String,
274}
275
276/// A single entry in `Gateway.status.addresses`.
277#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
278#[serde(rename_all = "camelCase")]
279pub struct GatewayStatusAddress {
280    /// Address type, e.g. `IPAddress` or `Hostname`. Absent is treated as unknown.
281    #[serde(default, skip_serializing_if = "Option::is_none")]
282    pub r#type: Option<String>,
283    /// The address value (an IP for `IPAddress`, a DNS name for `Hostname`).
284    pub value: String,
285}
286
287/// Minimal Gateway status — only the assigned addresses.
288#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
289#[serde(rename_all = "camelCase")]
290pub struct GatewayStatus {
291    /// Addresses the controller has assigned to this Gateway (often empty when
292    /// the controller publishes the external IP only on its LoadBalancer Service).
293    #[serde(default, skip_serializing_if = "Option::is_none")]
294    pub addresses: Option<Vec<GatewayStatusAddress>>,
295}
296
297/// Minimal Gateway definition for Scout's chain-following.
298#[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
328// ============================================================================
329// Constants
330// ============================================================================
331
332/// Annotation specifying the DNS record kind Scout should create for this Ingress.
333/// Set to `"ARecord"` to create an A record. Any other value (or absent) is ignored.
334pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
335
336/// Expected value of [`ANNOTATION_RECORD_KIND`] for A record creation.
337pub const RECORD_KIND_ARECORD: &str = "ARecord";
338
339/// Annotation specifying which DNS zone owns this Ingress host
340pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
341
342/// Simplified opt-in annotation — set to `"true"` to enable Scout for this Ingress.
343/// Takes precedence over (and is preferred to) [`ANNOTATION_RECORD_KIND`] for new users.
344/// Both annotations are accepted for backward compatibility.
345pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
346
347/// Annotation for explicitly overriding the IP(s) used in the ARecord.
348///
349/// Accepts a single IP (`"10.0.0.1"`) or a comma-separated list of IPs
350/// (`"10.0.0.1,10.0.0.2"`) — every entry becomes an address on the resulting
351/// `ARecord`, in the order given. Whitespace around each entry is trimmed and
352/// empty entries are skipped. When set, takes precedence over `--default-ips`
353/// and any LoadBalancer status IP.
354pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
355
356/// Annotation for overriding the TTL (in seconds) on the created ARecord.
357/// When absent, the ARecord inherits the TTL from the DNSZone spec.
358pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
359
360/// Annotation for overriding the DNS record name (`spec.name`) on the created ARecord.
361///
362/// When set, the value replaces the name normally derived from the source resource's
363/// host/hostname. Use `"@"` to target the zone apex. When absent or empty, Scout falls
364/// back to deriving the name from the host stripped of the zone suffix.
365///
366/// On multi-host resources (Ingress / HTTPRoute / TLSRoute) the override is applied to
367/// every ARecord produced from that resource, so it is intended for single-host use cases.
368pub const ANNOTATION_RECORD_NAME: &str = "bindy.firestoned.io/record-name";
369
370/// Finalizer added to Ingresses managed by Scout to ensure cleanup on deletion
371pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
372
373/// Label placed on created ARecords identifying Scout as the manager
374pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
375
376/// Label value for ARecords created by Scout
377pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
378
379/// Label identifying the source cluster on created ARecords
380pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
381
382/// Label identifying the source namespace on created ARecords
383pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
384
385/// Label identifying the source resource name on created ARecords.
386/// Used for all resource kinds (Ingress, Service, HTTPRoute, TLSRoute).
387pub const LABEL_SOURCE_NAME: &str = "bindy.firestoned.io/source-name";
388
389/// Label carrying the DNS zone name on created ARecords (for DNSZone selector matching)
390pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
391
392/// Default namespace where ARecords are created when `BINDY_SCOUT_NAMESPACE` is not set
393pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
394
395/// Maximum Kubernetes resource name length in characters
396const MAX_K8S_NAME_LEN: usize = 253;
397
398/// Prefix applied to all ARecord CR names created by Scout
399const ARECORD_NAME_PREFIX: &str = "scout";
400
401/// Requeue delay for non-fatal errors (seconds)
402const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
403
404/// Grace period, in seconds, that Scout keeps retrying remote ARecord cleanup
405/// for an object that is being deleted before it releases its finalizer anyway.
406///
407/// The finalizer exists so Scout gets a chance to delete the ARecords it created
408/// on the (possibly remote, Phase 2) Bindy cluster before the source object
409/// disappears. But if that remote cluster is unreachable — a broken/expired
410/// kubeconfig, a network partition — blocking finalizer removal on the remote
411/// call would strand the tenant's Ingress/Service/route in `Terminating`
412/// indefinitely, and because Scout adds this finalizer cluster-wide a single
413/// broken remote connection could block deletions across every namespace.
414///
415/// Within the grace period Scout requeues and retries (so transient failures
416/// still get cleaned up); past it, Scout releases the finalizer and logs that
417/// the remote ARecords may be orphaned and must be reconciled separately.
418pub(crate) const REMOTE_CLEANUP_GRACE_SECS: i64 = 300;
419
420/// Backoff delay before re-polling the DNSZone reflector after a connection error (seconds).
421/// The kube-runtime watcher has no built-in backoff — consumers must apply their own by
422/// delaying the next poll. Without this, a failed LIST/WATCH causes a tight retry loop.
423const REFLECTOR_ERROR_BACKOFF_SECS: u64 = 5;
424
425// ============================================================================
426// Context
427// ============================================================================
428
429/// Shared context passed to every reconciler invocation.
430pub struct ScoutContext {
431    /// Local Kubernetes client — Ingress watching and finalizer management.
432    /// Always the in-cluster client regardless of mode.
433    pub client: Client,
434    /// Remote Kubernetes client — ARecord creation/deletion and DNSZone validation.
435    /// In same-cluster mode (Phase 1) this is identical to `client`.
436    /// In remote mode (Phase 2+) this targets the dedicated Bindy cluster.
437    pub remote_client: Client,
438    /// Namespace where ARecords are created (on the remote/target cluster)
439    pub target_namespace: String,
440    /// Logical cluster name stamped on created ARecord labels
441    pub cluster_name: String,
442    /// Namespaces excluded from Ingress watching (always includes Scout's own namespace)
443    pub excluded_namespaces: Vec<String>,
444    /// Default IPs used when no annotation override and no LB status IP is available.
445    /// Intended for shared-ingress topologies (e.g. Traefik) where all Ingresses resolve
446    /// to the same IP(s). Set via `BINDY_SCOUT_DEFAULT_IPS` or `--default-ips`.
447    pub default_ips: Vec<String>,
448    /// Operator-configured `gatewayClass → LoadBalancer Service` map. When an HTTPRoute
449    /// or TLSRoute has no explicit IP annotation, Scout follows its `parentRefs` to a
450    /// Gateway of one of these classes and reads the mapped Service's external IP.
451    /// Set via `BINDY_SCOUT_GATEWAY_SERVICES` or `--gateway-service`.
452    pub gateway_services: BTreeMap<String, GatewayServiceTarget>,
453    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
454    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone`.
455    pub default_zone: Option<String>,
456    /// Kubernetes label selector restricting which namespaces Scout will act in (e.g.
457    /// `"bindy.firestoned.io/scout-enabled=true"`). A source object's own per-resource opt-in
458    /// annotation is still required in addition — the namespace must match this selector AND
459    /// the object must carry the annotation. `None` means every namespace is eligible, matching
460    /// pre-selector behavior; this is the default for backward compatibility, but production
461    /// deployments are strongly encouraged to set it rather than run with cluster-wide scope
462    /// (see `namespace_matches_selector` and the Scout guide). Set via
463    /// `BINDY_SCOUT_NAMESPACE_SELECTOR` or `--namespace-selector`.
464    pub namespace_selector: Option<String>,
465    /// Read-only store of DNSZone resources for zone validation.
466    /// Populated from the remote client so zones are validated against the bindy cluster.
467    pub zone_store: reflector::Store<DNSZone>,
468}
469
470// ============================================================================
471// Namespace-selector helpers (async — require Kubernetes API access)
472// ============================================================================
473
474/// Returns `true` if `namespace` currently carries labels matching `selector`.
475///
476/// `selector` is a standard Kubernetes label selector expression (e.g.
477/// `"bindy.firestoned.io/scout-enabled=true"`, or any expression accepted by
478/// `kubectl -l`). Rather than re-implement label-selector parsing/matching
479/// client-side, this delegates to the API server: it issues a `List` on
480/// `Namespace` scoped to the single namespace by name (`fieldSelector:
481/// metadata.name=<namespace>`) combined with the caller's label selector, and
482/// checks whether the (0-or-1-item) result is non-empty. This guarantees
483/// exactly the same selector semantics as `kubectl get ns -l <selector>`.
484///
485/// # Errors
486///
487/// Returns an error if the `List` call fails (e.g. RBAC-forbidden, transport
488/// error, or a malformed selector rejected by the API server) — callers should
489/// propagate this as a reconcile error (triggering a requeue with backoff)
490/// rather than treat a failed check as "not matched", to avoid spuriously
491/// cleaning up ARecords/finalizers on a transient API hiccup.
492async 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
507/// Returns whether `namespace` is eligible for Scout to act in.
508///
509/// When `selector` is `None` (the default), every namespace is eligible —
510/// preserving pre-selector behavior for backward compatibility. When `selector`
511/// is `Some`, this delegates to [`namespace_matches_selector`], so only
512/// namespaces whose labels match the configured selector are eligible.
513///
514/// This is combined (AND) with the existing per-resource opt-in annotation
515/// check (`is_scout_opted_in`) in each reconciler: both the namespace and the
516/// individual Ingress/Service/route object must opt in.
517///
518/// # Errors
519///
520/// Propagates any error from [`namespace_matches_selector`].
521async 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
532// ============================================================================
533// Pure helper functions (tested in scout_tests.rs)
534// ============================================================================
535
536/// Returns `true` when an object that entered `Terminating` at
537/// `deletion_timestamp` has been terminating for at least
538/// [`REMOTE_CLEANUP_GRACE_SECS`] as of `now`.
539///
540/// Used during finalizer handling: when remote ARecord cleanup keeps failing,
541/// this decides whether Scout should give up and release its finalizer (grace
542/// expired) rather than strand the source object in `Terminating` forever.
543///
544/// Returns `false` when `deletion_timestamp` is `None` (the object is not being
545/// deleted, so there is nothing to time out), when the grace period has not yet
546/// elapsed (keep retrying), or when the timestamp is in the future (clock skew).
547pub(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
554/// Returns `true` if the Ingress is annotated for ARecord creation.
555///
556/// The annotation `bindy.firestoned.io/recordKind` must have the value `"ARecord"` (case-sensitive).
557/// Any other value (or absence of the annotation) returns `false`.
558pub 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
565/// Returns `true` if Scout should manage this Ingress.
566///
567/// Accepts either the simplified opt-in annotation:
568/// - `bindy.firestoned.io/scout-enabled: "true"` (preferred for new deployments)
569///
570/// Or the legacy annotation for backward compatibility:
571/// - `bindy.firestoned.io/recordKind: "ARecord"`
572///
573/// The record kind always defaults to `ARecord` — no further annotation is needed.
574pub 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
582/// Resolves the DNS zone for an Ingress, in priority order:
583///
584/// 1. `bindy.firestoned.io/zone` annotation — per-Ingress explicit override
585/// 2. `default_zone` — operator-configured default zone (e.g. `"example.com"`)
586///
587/// Returns `None` if neither is available. When `None`, Scout logs a warning and skips the Ingress.
588pub 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
595/// Returns the DNS zone specified by the `bindy.firestoned.io/zone` annotation.
596///
597/// Returns `None` if the annotation is absent or has an empty value.
598pub 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
605/// Derives the DNS record name from a hostname and zone.
606///
607/// - `host.zone` → `host` (e.g. `"app.example.com"` + `"example.com"` → `"app"`)
608/// - `zone` (apex) → `"@"`
609/// - `deep.sub.zone` → `"deep.sub"`
610///
611/// Trailing dots on `host` are stripped before comparison.
612///
613/// # Errors
614///
615/// Returns an error if `host` does not end with the zone suffix.
616pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
617    // Strip trailing dot if present (some Ingress controllers append it)
618    let host = host.trim_end_matches('.');
619
620    // Apex record
621    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
636/// Maximum length of a single DNS label (RFC 1035 §2.3.4).
637const MAX_DNS_LABEL_LEN: usize = 63;
638
639/// Maximum length of a full DNS name in presentation form (RFC 1035 §2.3.4).
640const MAX_DNS_NAME_LEN: usize = 253;
641
642/// Validate a `bindy.firestoned.io/record-name` override as a legal relative DNS name.
643///
644/// The override deliberately bypasses host->zone matching — the annotator has
645/// explicitly chosen the record name (see [`resolve_record_name`]). But the value
646/// is set by whoever can edit the source Ingress / Service / Route, and it is
647/// written into a zone file, so it must still be syntactically legal (audit
648/// finding P2-7). Without this, a newline or space in the annotation could close
649/// the current record and append another.
650///
651/// Accepts `@` (zone apex), and otherwise a dot-separated sequence of labels:
652/// each 1-[`MAX_DNS_LABEL_LEN`] characters of ASCII alphanumerics, `-` or `_`,
653/// starting and ending with an alphanumeric or `_`; a whole label of `*` is
654/// allowed so wildcard records (`*`, `*.api`) keep working. The full name is
655/// capped at [`MAX_DNS_NAME_LEN`].
656///
657/// # Errors
658/// Returns an error describing the first violation found.
659fn validate_record_name_override(name: &str) -> Result<()> {
660    // Guard clause: the apex is a legal name but not a legal label.
661    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        // Unwraps are safe: the label is non-empty per the guard above.
697        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
711/// Returns the explicit DNS record name override from `bindy.firestoned.io/record-name`.
712///
713/// The annotation value is trimmed of surrounding whitespace. Returns `None` if the
714/// annotation is absent, empty, or whitespace-only.
715pub 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
722/// Resolves the DNS record name for an ARecord, in priority order:
723///
724/// 1. `bindy.firestoned.io/record-name` annotation — explicit override (e.g. `"myapp"`, `"@"`)
725/// 2. Derived from `host` by stripping the zone suffix (see [`derive_record_name`])
726///
727/// When the override annotation is present, the host is **not** validated against the zone:
728/// the operator has explicitly chosen the record name and is responsible for its correctness.
729/// The override's *syntax* is still checked by [`validate_record_name_override`], because the
730/// value reaches a zone file and the annotation is tenant-writable (audit finding P2-7).
731///
732/// # Errors
733///
734/// Returns an error when the override is present but is not a legal DNS name, or — when no
735/// override is set — the error from [`derive_record_name`] if the host does not belong to
736/// the zone.
737pub 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        // P2-7: the override skips zone matching by design, but it still has to be
744        // a legal DNS name — it ends up in a zone file.
745        validate_record_name_override(&override_name)?;
746        return Ok(override_name);
747    }
748    derive_record_name(host, zone)
749}
750
751/// Returns the explicit IP overrides from the `bindy.firestoned.io/ip` annotation.
752///
753/// The value may be a single IP (`"10.0.0.1"`) or a comma-separated list
754/// (`"10.0.0.1,10.0.0.2,10.0.0.3"`). Whitespace around each entry is trimmed
755/// and empty entries are skipped, preserving order and duplicates.
756///
757/// Every entry is validated as an **IPv4 dotted-quad** before it is accepted
758/// (audit finding P2-6). This annotation is set by whoever can edit the source
759/// Ingress / Service / Route — i.e. a namespace tenant — and whatever survives
760/// here is written verbatim into `ARecordSpec.ipv4Addresses` and rendered into a
761/// zone file. IPv6 literals are rejected too: `ARecord` is IPv4-only, so an IPv6
762/// address here would produce an invalid A record.
763///
764/// Invalid entries are dropped with a warning rather than failing the whole
765/// record, so one fat-fingered entry in a list does not take down valid DNS.
766///
767/// Returns `None` if the annotation is absent, empty, contains only
768/// separators/whitespace, or if **no** entry is a valid IPv4 address. `None`
769/// (rather than an empty `Vec`) matters: it lets [`resolve_ips`] fall through to
770/// `default_ips` and then the load-balancer status, whereas `Some(vec![])` would
771/// create a record with no addresses.
772pub 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/// Whether a `DNSZone` authorizes DNS records sourced from `source_namespace`.
799///
800/// A DNSZone in the *same* namespace as the source object (Ingress / Service /
801/// Route) is always authorized. A DNSZone in a different namespace must opt in
802/// via the [`ANNOTATION_ALLOW_ZONE_NAMESPACES`] annotation — a comma-separated
803/// namespace list, or the [`ALLOW_ZONE_NAMESPACES_WILDCARD`] `*`.
804///
805/// This mirrors the cross-namespace gate the DNSZone reconciler already
806/// enforces for instance targeting, and closes audit finding H1: without it,
807/// any tenant's opted-in Ingress could publish records into *any* zone Scout
808/// served (a confused-deputy cross-tenant DNS hijack), because Scout writes
809/// with a cluster-privileged remote client.
810#[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    // P3-3: `*` re-opens the cross-tenant path that H1 closed. It stays supported —
815    // some platform teams genuinely want a shared zone — but an armed wildcard should
816    // never be silent, because it looks identical to an explicit grant from outside.
817    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/// Why a [`DNSZone`] did (or did not) authorize a source namespace.
833///
834/// Kept separate from the boolean so a wildcard grant can be distinguished from an
835/// explicit one — they authorize identically but have very different blast radius.
836#[derive(Debug, Clone, Copy, PartialEq, Eq)]
837pub enum NamespaceGrant {
838    /// The zone lives in the source's own namespace — no cross-namespace access.
839    SameNamespace,
840    /// The source namespace is named explicitly in the allow-list.
841    ExplicitlyListed,
842    /// The allow-list contains `*`; every namespace in the cluster is permitted.
843    Wildcard,
844    /// No rule authorizes this source namespace.
845    Denied,
846}
847
848impl NamespaceGrant {
849    /// Whether this grant permits the operation.
850    #[must_use]
851    pub fn is_authorized(self) -> bool {
852        !matches!(self, Self::Denied)
853    }
854}
855
856/// Classify how `zone` authorizes `source_namespace`, without logging.
857///
858/// An explicit listing is reported in preference to a wildcard when both are
859/// present: the grant would have been made regardless of the `*`, so it is not the
860/// wildcard's doing and should not raise a cross-namespace warning.
861#[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/// Outcome of resolving a zone name against the DNSZone store for a given
891/// source namespace.
892#[derive(Debug, PartialEq, Eq)]
893pub(crate) enum ZoneAuthz {
894    /// A matching DNSZone exists and authorizes the source namespace.
895    Authorized,
896    /// A matching DNSZone exists but does not authorize the source namespace.
897    Forbidden,
898    /// No DNSZone with the requested name is present in the store yet.
899    NotFound,
900}
901
902/// Resolve `zone_name` against the DNSZone `zones` for `source_namespace`.
903///
904/// Returns [`ZoneAuthz::Authorized`] if any matching DNSZone authorizes the
905/// namespace (see [`zone_allows_source_namespace`]), [`ZoneAuthz::Forbidden`]
906/// if the zone exists but no matching DNSZone authorizes it, or
907/// [`ZoneAuthz::NotFound`] if no DNSZone with that name is in the store.
908pub(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/// The DNSZone that authorizes `source_namespace` for `zone_name`, if any.
931///
932/// Same matching rules as [`check_zone_authorization`], but returns the granting
933/// object so the caller can re-verify it against the API server.
934#[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
948/// Authorize `zone_name` for `source_namespace`, re-checking the grant live.
949///
950/// [`check_zone_authorization`] reads a reflector cache, which lags the API server by
951/// the watch latency. Between that read and the server-side-apply that writes the
952/// ARecord there is a window in which a DNSZone's
953/// [`ANNOTATION_ALLOW_ZONE_NAMESPACES`] may have been tightened — the record would
954/// then be published under a grant that no longer exists (audit finding P3-4).
955///
956/// This re-reads the *specific* zone that granted access, immediately before the
957/// caller writes, shrinking the window from "watch latency" to "one API round trip".
958/// It is a narrowing, not an elimination: a true elimination needs the write itself to
959/// be conditional on the zone's `resourceVersion`, which server-side apply on a
960/// *different* object cannot express.
961///
962/// A failed live read is treated as **still authorized**: the cached grant was
963/// affirmative, and failing closed on a transient API error would drop legitimate DNS
964/// records during an API server blip. The error is logged.
965pub(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
1011/// Resolves the IP address(es) to use for an ARecord, in priority order:
1012///
1013/// 1. `bindy.firestoned.io/ip` annotation — explicit override (single IP or comma-separated list)
1014/// 2. `default_ips` — operator-configured default IPs (e.g. shared Traefik ingress VIP)
1015/// 3. Ingress LoadBalancer status — first non-empty IP
1016///
1017/// Returns `None` if no IP can be determined from any source.
1018pub 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
1032/// Resolves the IP to use for an ARecord from the Ingress load-balancer status.
1033///
1034/// Returns the first non-empty `ip` field found in `status.loadBalancer.ingress`.
1035/// Hostname-only entries (no IP) are ignored; a warning is logged for each.
1036pub 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
1061/// Builds a sanitized Kubernetes resource name for an ARecord CR.
1062///
1063/// Format: `scout-{cluster}-{namespace}-{ingress}-{index}`
1064///
1065/// All characters are lowercased. Underscores and any non-alphanumeric characters
1066/// (other than hyphens) are replaced with hyphens. The result is truncated to
1067/// 253 characters to stay within the Kubernetes name limit.
1068pub 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
1079/// Sanitizes a string for use as a Kubernetes resource name.
1080///
1081/// - Lowercases all characters
1082/// - Replaces any character that is not `[a-z0-9-]` with `-`
1083/// - Collapses consecutive hyphens into one
1084/// - Strips leading and trailing hyphens
1085fn 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            // Replace any non-alphanumeric character with a hyphen (collapsing runs)
1096            if !last_was_hyphen {
1097                result.push('-');
1098                last_was_hyphen = true;
1099            }
1100        }
1101    }
1102
1103    // Strip trailing hyphens
1104    let trimmed = result.trim_end_matches('-');
1105    // Strip leading hyphens
1106    trimmed.trim_start_matches('-').to_string()
1107}
1108
1109/// Returns `true` if the Scout finalizer is present on the Ingress.
1110pub 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
1119/// Returns `true` if the Ingress has been marked for deletion.
1120pub fn is_being_deleted(ingress: &Ingress) -> bool {
1121    ingress.metadata.deletion_timestamp.is_some()
1122}
1123
1124/// Builds a Kubernetes label selector string matching all ARecords created
1125/// by Scout for a specific Ingress.
1126///
1127/// Selects on `managed-by=scout`, `source-cluster`, `source-namespace`, and
1128/// `source-name` to precisely target only the records owned by this Ingress.
1129pub 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
1140/// Builds a label selector string matching ARecords for the given Ingress that
1141/// belong to **any cluster other than `current_cluster`**.
1142///
1143/// Used to detect and clean up stale ARecords left behind when the scout is
1144/// restarted with a different `--cluster-name`.  The `!=` operator is supported
1145/// by the Kubernetes label selector language for equality-based requirements.
1146pub 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
1161// ============================================================================
1162// ARecord builder
1163// ============================================================================
1164
1165/// Parameters for building an ARecord CR.
1166pub struct ARecordParams<'a> {
1167    /// Kubernetes resource name for the ARecord CR
1168    pub name: &'a str,
1169    /// Namespace where the ARecord CR will be created
1170    pub target_namespace: &'a str,
1171    /// DNS record name within the zone (e.g. `"app"` or `"@"`)
1172    pub record_name: &'a str,
1173    /// IPv4 addresses to use for the record (one or more)
1174    pub ips: &'a [String],
1175    /// Optional TTL override in seconds
1176    pub ttl: Option<i32>,
1177    /// Logical name of the source cluster (for labels)
1178    pub cluster_name: &'a str,
1179    /// Source Ingress namespace (for labels)
1180    pub ingress_namespace: &'a str,
1181    /// Source Ingress name (for labels)
1182    pub ingress_name: &'a str,
1183    /// DNS zone name (for labels)
1184    pub zone: &'a str,
1185}
1186
1187/// Builds the ARecord CR that Scout will create on the target cluster.
1188pub 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
1226// ============================================================================
1227// Service helpers
1228// ============================================================================
1229
1230/// Returns `true` if the Service is of type `LoadBalancer`.
1231///
1232/// `ClusterIP` and `NodePort` services have no routable external IP, so
1233/// Scout silently skips them without warning.
1234pub 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
1242/// Extracts the first non-empty IP from the Service's LoadBalancer status.
1243///
1244/// Returns `None` if the status has no entries, or the first entry has no IP
1245/// (hostname-only entries are ignored). Scout re-queues and waits for the
1246/// cloud provider to assign an external IP.
1247pub 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/// Parses a `namespace/name` string into a [`NamespacedName`].
1259///
1260/// Whitespace around each segment is trimmed. Returns `None` unless the input
1261/// has exactly two non-empty segments separated by a single `/`.
1262#[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/// Parses a single `gatewayClass` target: either `namespace/name` (explicit
1277/// Service) or `namespace/<label-selector>` (any entry whose Service part
1278/// contains `=`, since Service names never do).
1279///
1280/// The namespace is the segment before the first `/`; namespaces cannot contain
1281/// `/`, so a label selector's own `/` (e.g. `app.kubernetes.io/name=x`) is
1282/// preserved. Returns `None` for empty namespace or empty target.
1283#[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    // No `=` → an explicit Service name. Reuse the strict `ns/name` parser,
1298    // which rejects extra slashes.
1299    service_ref_from_str(s).map(GatewayServiceTarget::Name)
1300}
1301
1302/// Parses a single `class=<target>` mapping entry.
1303///
1304/// Splits on the first `=` (so a label selector's own `=` stays in the target).
1305/// Returns `None` for an empty class or an unparseable target. Used per-entry so
1306/// the repeatable `--gateway-service` CLI flag can carry multi-label selectors
1307/// (which contain commas) without them being mistaken for entry separators.
1308#[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/// Parses the operator's `gatewayClass → LoadBalancer Service` map from a single
1320/// comma-separated string (the `BINDY_SCOUT_GATEWAY_SERVICES` env form).
1321///
1322/// Each entry is `class=<target>`, where `<target>` is either `namespace/name`
1323/// or `namespace/<label-selector>`, e.g.
1324/// `traefik=traefik/traefik,cilium=kube-system/app.kubernetes.io/name=cilium`.
1325/// Malformed entries are skipped. Because commas separate entries here,
1326/// multi-label selectors (which use commas) must be supplied via the repeatable
1327/// `--gateway-service` CLI flag instead. The map's keys double as the allow-list
1328/// of gateway classes Scout will follow.
1329#[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/// Extracts the IP-typed addresses from a Gateway's `status.addresses`.
1338///
1339/// An entry is treated as an IP when its `type` is `IPAddress`, or when the
1340/// `type` is absent but the `value` parses as an [`IpAddr`](std::net::IpAddr).
1341/// `Hostname`-typed entries are ignored. Returns an empty vec when the Gateway
1342/// has no addresses (the common case that forces the LoadBalancer-Service hop).
1343#[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/// Resolves a route's `parentRefs` to the Gateways they point at.
1361///
1362/// Only references to Gateway API Gateways are returned — an entry is kept when
1363/// its `group` is absent or `gateway.networking.k8s.io` AND its `kind` is absent
1364/// or `Gateway`. Each ref's namespace defaults to `route_namespace` when omitted.
1365#[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
1391/// Resolves the external IP of the LoadBalancer Service a gateway class maps to.
1392///
1393/// For a [`GatewayServiceTarget::Name`] the Service is fetched directly; for a
1394/// [`GatewayServiceTarget::Labeled`] target the namespace is listed by label
1395/// selector and the first LoadBalancer Service with an external IP is used.
1396/// Returns `None` (with a debug log) when the Service is unreachable, absent, or
1397/// has no external IP assigned yet.
1398async 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
1445/// Follows a route's `parentRefs` back to the serving Gateway(s) and resolves
1446/// their external IP(s).
1447///
1448/// For each `parentRef` that resolves to a Gateway, Scout resolves the IP by:
1449///   1. reading the Gateway's `status.addresses` (IP-typed) — no extra
1450///      configuration required; works as long as the gateway controller
1451///      populates its own status.
1452///   2. if `status.addresses` is empty, looking up the gateway class in the
1453///      operator-configured `gateway_services` map and reading the external IP
1454///      of the mapped LoadBalancer Service.
1455///
1456/// Returns the de-duplicated IPs in discovery order, or `None` when nothing
1457/// resolves. Individual lookup failures are logged and skipped so one
1458/// unreachable Gateway does not blank out the others.
1459///
1460/// # Arguments
1461/// * `client` - Local cluster client (Gateways and Services live on the workload cluster)
1462/// * `route_namespace` - Namespace of the route, used as the default parentRef namespace
1463/// * `parent_refs` - The route's `spec.parentRefs`
1464/// * `gateway_services` - Optional fallback map: `gatewayClass → Service` used only when
1465///   `status.addresses` is absent
1466pub 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        // Prefer the Gateway's own advertised addresses when present — no
1489        // gateway-services configuration needed for this path.
1490        let gw_ips = gateway_addresses_as_ips(&gateway);
1491        if !gw_ips.is_empty() {
1492            ips.extend(gw_ips);
1493            continue;
1494        }
1495
1496        // status.addresses is empty — fall back to the mapped LoadBalancer
1497        // Service if the gateway class is in the operator-configured map.
1498        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    // De-duplicate preserving discovery order.
1510    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
1520/// Derives the ARecord CR name for a Service.
1521///
1522/// Format: `scout-{cluster}-{namespace}-{service_name}`
1523///
1524/// No index suffix — unlike Ingress, a Service produces exactly one ARecord.
1525/// Applies the same sanitisation and 253-char truncation as Ingress CR names.
1526pub 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
1532/// Builds a Kubernetes label selector matching all ARecords created by Scout
1533/// for a specific Service.
1534pub 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
1549/// Parameters for building a Service-sourced ARecord CR.
1550pub struct ServiceARecordParams<'a> {
1551    /// Kubernetes resource name for the ARecord CR
1552    pub name: &'a str,
1553    /// Namespace where the ARecord CR will be created
1554    pub target_namespace: &'a str,
1555    /// DNS record name within the zone (e.g. `"my-svc"`)
1556    pub record_name: &'a str,
1557    /// IPv4 addresses for the record
1558    pub ips: &'a [String],
1559    /// Optional TTL override in seconds
1560    pub ttl: Option<i32>,
1561    /// Logical name of the source cluster (for labels)
1562    pub cluster_name: &'a str,
1563    /// Source Service namespace (for labels)
1564    pub service_namespace: &'a str,
1565    /// Source Service name (for labels)
1566    pub service_name: &'a str,
1567    /// DNS zone name (for labels)
1568    pub zone: &'a str,
1569}
1570
1571/// Builds the ARecord CR that Scout will create for a `LoadBalancer` Service.
1572pub 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
1610// ============================================================================
1611// Gateway API (HTTPRoute / TLSRoute) helpers
1612// ============================================================================
1613
1614/// Derives the ARecord CR name for an HTTPRoute.
1615///
1616/// Format: `scout-{cluster}-{namespace}-{route_name}-{hostname_index}`
1617///
1618/// One ARecord per hostname in `spec.hostnames[]`. Index tracks which hostname
1619/// this ARecord is for. Applies the same sanitisation and 253-char truncation
1620/// as Ingress CR names.
1621pub 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
1632/// Derives the ARecord CR name for a TLSRoute.
1633///
1634/// Format: `scout-{cluster}-{namespace}-{route_name}-{hostname_index}`
1635///
1636/// One ARecord per hostname in `spec.hostnames[]`. Index tracks which hostname
1637/// this ARecord is for. Applies the same sanitisation and 253-char truncation.
1638pub 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
1649/// Builds a Kubernetes label selector matching all ARecords created by Scout
1650/// for a specific HTTPRoute.
1651pub 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
1666/// Builds a Kubernetes label selector matching all ARecords created by Scout
1667/// for a specific TLSRoute.
1668pub 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
1679/// Derives the ARecord CR name for a TCPRoute.
1680pub 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
1691/// Builds a Kubernetes label selector matching all ARecords created by Scout
1692/// for a specific TCPRoute.
1693pub 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
1704/// Builds a label selector string matching ARecords for the given HTTPRoute that
1705/// belong to **any cluster other than `current_cluster`**.
1706///
1707/// Used to detect and clean up stale ARecords left behind when scout is
1708/// restarted with a different `--cluster-name`.
1709pub 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
1724/// Builds a label selector string matching ARecords for the given TLSRoute that
1725/// belong to **any cluster other than `current_cluster`**.
1726pub 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
1741/// Builds a label selector string matching ARecords for the given TCPRoute that
1742/// belong to **any cluster other than `current_cluster`**.
1743pub 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
1758/// Parameters for building an ARecord CR from an HTTPRoute.
1759pub struct HTTPRouteARecordParams<'a> {
1760    /// Kubernetes resource name for the ARecord CR
1761    pub name: &'a str,
1762    /// Namespace where the ARecord CR will be created
1763    pub target_namespace: &'a str,
1764    /// DNS record name within the zone (e.g. `"api"`)
1765    pub record_name: &'a str,
1766    /// IPv4 addresses for the record
1767    pub ips: &'a [String],
1768    /// Optional TTL override in seconds
1769    pub ttl: Option<i32>,
1770    /// Logical name of the source cluster (for labels)
1771    pub cluster_name: &'a str,
1772    /// Source HTTPRoute namespace (for labels)
1773    pub route_namespace: &'a str,
1774    /// Source HTTPRoute name (for labels)
1775    pub route_name: &'a str,
1776    /// DNS zone name (for labels)
1777    pub zone: &'a str,
1778}
1779
1780/// Builds the ARecord CR that Scout will create for an HTTPRoute.
1781pub 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
1816/// Parameters for building an ARecord CR from a TLSRoute.
1817pub struct TLSRouteARecordParams<'a> {
1818    /// Kubernetes resource name for the ARecord CR
1819    pub name: &'a str,
1820    /// Namespace where the ARecord CR will be created
1821    pub target_namespace: &'a str,
1822    /// DNS record name within the zone (e.g. `"secure"`)
1823    pub record_name: &'a str,
1824    /// IPv4 addresses for the record
1825    pub ips: &'a [String],
1826    /// Optional TTL override in seconds
1827    pub ttl: Option<i32>,
1828    /// Logical name of the source cluster (for labels)
1829    pub cluster_name: &'a str,
1830    /// Source TLSRoute namespace (for labels)
1831    pub route_namespace: &'a str,
1832    /// Source TLSRoute name (for labels)
1833    pub route_name: &'a str,
1834    /// DNS zone name (for labels)
1835    pub zone: &'a str,
1836}
1837
1838/// Builds the ARecord CR that Scout will create for a TLSRoute.
1839pub 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
1874/// Parameters for building an ARecord CR from a TCPRoute.
1875pub struct TCPRouteARecordParams<'a> {
1876    /// Kubernetes resource name for the ARecord CR
1877    pub name: &'a str,
1878    /// Namespace where the ARecord CR will be created
1879    pub target_namespace: &'a str,
1880    /// DNS record name within the zone (e.g. `"db"`)
1881    pub record_name: &'a str,
1882    /// IPv4 addresses for the record
1883    pub ips: &'a [String],
1884    /// Optional TTL override in seconds
1885    pub ttl: Option<i32>,
1886    /// Logical name of the source cluster (for labels)
1887    pub cluster_name: &'a str,
1888    /// Source TCPRoute namespace (for labels)
1889    pub route_namespace: &'a str,
1890    /// Source TCPRoute name (for labels)
1891    pub route_name: &'a str,
1892    /// DNS zone name (for labels)
1893    pub zone: &'a str,
1894}
1895
1896/// Builds the ARecord CR that Scout will create for a TCPRoute.
1897pub 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
1932// ============================================================================
1933// Finalizer helpers (async — require Kubernetes API access)
1934// ============================================================================
1935
1936/// Adds the Scout finalizer to an Ingress.
1937///
1938/// Merges the finalizer into the existing list so any other finalizers
1939/// already present are preserved.
1940async 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
1956/// Removes the Scout finalizer from an Ingress.
1957///
1958/// Preserves any other finalizers that may be present.
1959async 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
1979/// Adds the Scout finalizer to a Service.
1980async 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
1996/// Removes the Scout finalizer from a Service.
1997async 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
2017/// Adds the Scout finalizer to an HTTPRoute.
2018async 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
2034/// Removes the Scout finalizer from an HTTPRoute.
2035async 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
2055/// Adds the Scout finalizer to a TLSRoute.
2056async 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
2072/// Removes the Scout finalizer from a TLSRoute.
2073async 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
2093/// Adds the Scout finalizer to a TCPRoute.
2094async 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
2110/// Removes the Scout finalizer from a TCPRoute.
2111async 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
2131/// Deletes all ARecords in `target_namespace` that were created by Scout for
2132/// the given Ingress (identified by cluster + namespace + ingress name labels).
2133///
2134/// Must be called with the **remote** client so it targets the cluster where
2135/// ARecords live (which may differ from the local cluster in Phase 2+).
2136async 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
2161/// Deletes all ARecords in `target_namespace` that were created by Scout for
2162/// the given Ingress by a **previous** cluster name — i.e., any ARecord whose
2163/// `source-cluster` label differs from `current_cluster`.
2164///
2165/// This is called after every successful reconcile so that a scout restarted
2166/// with a new `--cluster-name` automatically cleans up the orphaned records
2167/// it left behind under the old name.
2168async 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
2202/// Deletes all ARecords in `target_namespace` that Scout created for the given Service.
2203///
2204/// Called during Service deletion and opt-out annotation removal.
2205async 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
2230/// Deletes all ARecords created by Scout for a specific HTTPRoute.
2231async 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
2256/// Deletes all ARecords created by Scout for a specific TLSRoute.
2257async 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
2282/// Deletes stale ARecords for an HTTPRoute from previous cluster names.
2283async 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
2308/// Deletes stale ARecords for a TLSRoute from previous cluster names.
2309async 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
2334/// Deletes all ARecords created by Scout for a specific TCPRoute.
2335async 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
2360/// Deletes stale ARecords for a TCPRoute from previous cluster names.
2361async 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
2386// ============================================================================
2387// Reconciler
2388// ============================================================================
2389
2390/// Reconciles a single Ingress, creating or updating ARecord CRs as needed.
2391///
2392/// Handles the full lifecycle:
2393/// - Adds a finalizer to opted-in Ingresses so deletion is intercepted.
2394/// - On deletion, removes all ARecords Scout created then releases the finalizer.
2395/// - If the opt-in annotation is removed, cleans up ARecords and the finalizer.
2396///
2397/// # Errors
2398///
2399/// Returns an error that will be retried by the controller runtime.
2400async 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    // Skip excluded namespaces
2405    if ctx.excluded_namespaces.contains(&namespace) {
2406        debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
2407        return Ok(Action::await_change());
2408    }
2409
2410    // Handle Ingress deletion — remove ARecords and release the finalizer
2411    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    // Guard: opt-in annotation required (scout-enabled: "true" or recordKind: "ARecord")
2461    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        // Annotation may have been removed after a finalizer was added — clean up
2468        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    // Ensure our finalizer is present before creating any ARecords.
2497    // Adding the finalizer triggers a re-reconcile; return early to avoid
2498    // doing record creation twice.
2499    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    // Guard: zone required (annotation or operator default)
2508    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    // Guard: a matching DNSZone must exist AND authorize this Ingress's
2519    // namespace (finding H1 — otherwise any tenant's Ingress could publish
2520    // into any zone Scout serves).
2521    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    // Resolve IPs: annotation override → default_ips → LB status
2553    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    // Optional TTL override
2564    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        // Server-side apply
2607        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    // Clean up any ARecords that were created by a previous cluster name for
2625    // this same Ingress — happens when scout is restarted with a new --cluster-name.
2626    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
2639///// Reconciles a single `LoadBalancer` Service, creating or updating an ARecord CR as needed.
2640///
2641/// Mirrors the Ingress reconciler lifecycle:
2642/// - Opts in via `bindy.firestoned.io/scout-enabled: "true"`.
2643/// - Silently skips non-`LoadBalancer` Services (no warning — ClusterIP/NodePort are intra-cluster).
2644/// - Adds a finalizer; on deletion removes the ARecord and releases it.
2645/// - If the opt-in annotation is removed, cleans up the ARecord and finalizer.
2646/// - Re-queues if no external IP is available yet (cloud provider may not have assigned one).
2647///
2648/// # Errors
2649///
2650/// Returns an error that will be retried by the controller runtime.
2651async 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    // Handle Service deletion — remove ARecord and release the finalizer
2664    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    // Guard: opt-in annotation required
2709    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    // Guard: only LoadBalancer services have routable external IPs
2741    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    // Ensure finalizer before creating any ARecord
2747    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    // Guard: zone required
2762    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    // Guard: a matching DNSZone must exist AND authorize this Service's
2773    // namespace (finding H1).
2774    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    // Resolve IPs: annotation (single or comma-separated) → default_ips → LB status
2798    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    // Derive the DNS record name: annotation override → "{service_name}.{zone}" stripped of zone
2821    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
2866/// Error policy for the Service controller: requeue with a fixed backoff.
2867fn 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
2872/// Error policy: requeue with a fixed backoff on any reconcile error.
2873fn 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
2878// ============================================================================
2879// Gateway API (HTTPRoute / TLSRoute) Reconciliation
2880//
2881// Note: HTTPRoute and TLSRoute reconciliation follows the same pattern as
2882// Ingress reconciliation, with these differences:
2883//
2884// 1. HTTPRoute sources: `spec.hostnames[]` (array) instead of `spec.rules[].host`
2885// 2. TLSRoute sources: `spec.hostnames[]` (array) instead of routes with hosts
2886// 3. One ARecord per hostname with index suffix (like Ingress has one per rule)
2887// 4. Zone and IP resolution use the same annotation scheme as Ingress/Service
2888// ============================================================================
2889
2890/// Reconciles a single `HTTPRoute` resource, creating or updating ARecord CRs as needed.
2891///
2892/// Mirrors the Ingress reconciler lifecycle:
2893/// - Opts in via `bindy.firestoned.io/scout-enabled: "true"`.
2894/// - Adds a finalizer; on deletion removes ARecords and releases it.
2895/// - If the opt-in annotation is removed, cleans up ARecords and finalizer.
2896/// - One ARecord created per hostname in `spec.hostnames[]` with an index suffix.
2897/// - Re-queues if zone is not found or no IP is available yet.
2898///
2899/// # Errors
2900///
2901/// Returns `ScoutError` if API calls fail (apply, delete, patch).
2902async 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    // Guard: Skip excluded namespaces
2910    if ctx.excluded_namespaces.contains(&namespace) {
2911        debug!(httproute = %name, ns = %namespace, "Skipping excluded namespace");
2912        return Ok(Action::await_change());
2913    }
2914
2915    // Handle HTTPRoute deletion — remove ARecords and release the finalizer
2916    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    // Guard: opt-in annotation required
2972    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    // Ensure finalizer before creating any ARecord
3013    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    // Guard: zone required
3028    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    // Guard: a matching DNSZone must exist AND authorize this HTTPRoute's
3039    // namespace (finding H1).
3040    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    // Resolve IPs: annotation → gateway chain (parentRefs → Gateway → LB Service)
3064    // → default_ips → no routable IP = requeue
3065    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    // Extract hostnames from spec.hostnames[]
3099    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        // Server-side apply
3137        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    // Clean up stale ARecords from old cluster names
3155    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
3168/// Reconciles a single `TLSRoute` resource, creating or updating ARecord CRs as needed.
3169///
3170/// Identical to HTTPRoute reconciliation: both resources have `spec.hostnames[]`
3171/// and use the same annotation/IP resolution scheme.
3172///
3173/// # Errors
3174///
3175/// Returns `ScoutError` if API calls fail.
3176async 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    // Guard: Skip excluded namespaces
3184    if ctx.excluded_namespaces.contains(&namespace) {
3185        debug!(tlsroute = %name, ns = %namespace, "Skipping excluded namespace");
3186        return Ok(Action::await_change());
3187    }
3188
3189    // Handle TLSRoute deletion — remove ARecords and release the finalizer
3190    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    // Guard: opt-in annotation required
3246    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    // Ensure finalizer before creating any ARecord
3287    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    // Guard: zone required
3302    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    // Guard: a matching DNSZone must exist AND authorize this TLSRoute's
3313    // namespace (finding H1).
3314    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    // Resolve IPs: annotation → gateway chain (parentRefs → Gateway → LB Service)
3338    // → default_ips → no routable IP = requeue
3339    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    // Extract hostnames from spec.hostnames[]
3373    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        // Server-side apply
3411        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    // Clean up stale ARecords from old cluster names
3429    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
3442/// Reconciles a single `TCPRoute` resource, creating or updating ARecord CRs as needed.
3443async 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
3682/// Error policy for Gateway API routes: requeue with a fixed backoff.
3683fn 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
3692/// Error policy for TLSRoute: requeue with a fixed backoff.
3693fn 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
3702/// Error policy for TCPRoute: requeue with a fixed backoff.
3703fn 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
3712// ============================================================================
3713// Remote client builder (Phase 2)
3714// ============================================================================
3715
3716/// Builds a Kubernetes client from a kubeconfig stored in a Kubernetes Secret.
3717///
3718/// The Secret must contain a `kubeconfig` key in `.data` with a valid kubeconfig
3719/// YAML document. Used in Phase 2 to connect Scout (running in the workload cluster)
3720/// to the remote Bindy cluster where ARecords and DNSZones live.
3721///
3722/// # Errors
3723///
3724/// Returns an error if the Secret cannot be read, the `kubeconfig` key is absent,
3725/// the YAML is malformed, or the resulting client configuration is invalid.
3726async 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
3757// ============================================================================
3758// Entry point
3759// ============================================================================
3760
3761/// Reads scout configuration from environment variables.
3762struct ScoutConfig {
3763    target_namespace: String,
3764    cluster_name: String,
3765    excluded_namespaces: Vec<String>,
3766    /// Default IPs used when no per-Ingress annotation override or LB status IP is available.
3767    /// Set via `BINDY_SCOUT_DEFAULT_IPS` (comma-separated) or `--default-ips` CLI flag.
3768    default_ips: Vec<String>,
3769    /// `gatewayClass → LoadBalancer Service` map for gateway-chain IP resolution.
3770    /// Set via `BINDY_SCOUT_GATEWAY_SERVICES` (`class=ns/name,...`) or `--gateway-service`.
3771    gateway_services: BTreeMap<String, GatewayServiceTarget>,
3772    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
3773    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone` CLI flag.
3774    default_zone: Option<String>,
3775    /// Kubernetes label selector restricting which namespaces Scout will act in. `None` means
3776    /// every namespace is eligible (backward-compatible default). Set via
3777    /// `BINDY_SCOUT_NAMESPACE_SELECTOR` or `--namespace-selector` CLI flag.
3778    namespace_selector: Option<String>,
3779    /// Name of the Secret containing the remote cluster kubeconfig (Phase 2).
3780    /// When `None`, Scout operates in same-cluster mode.
3781    remote_secret_name: Option<String>,
3782    /// Namespace of the remote kubeconfig Secret. Defaults to Scout's own namespace.
3783    remote_secret_namespace: String,
3784}
3785
3786impl ScoutConfig {
3787    /// Build configuration from environment variables, with optional CLI overrides.
3788    ///
3789    /// CLI arguments take precedence over environment variables when provided.
3790    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        // Always exclude Scout's own namespace
3822        if !excluded_namespaces.contains(&own_namespace) {
3823            excluded_namespaces.push(own_namespace.clone());
3824        }
3825
3826        // CLI --default-ips takes precedence over BINDY_SCOUT_DEFAULT_IPS env var
3827        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        // CLI --gateway-service takes precedence over BINDY_SCOUT_GATEWAY_SERVICES env var.
3840        // CLI entries are parsed one-by-one (each flag is a single `class=target`) so a
3841        // multi-label selector's commas are not mistaken for entry separators; the env
3842        // form is a single comma-separated string.
3843        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        // CLI --default-zone takes precedence over BINDY_SCOUT_DEFAULT_ZONE env var
3855        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        // CLI --namespace-selector takes precedence over BINDY_SCOUT_NAMESPACE_SELECTOR env var.
3862        // Unset (None) means every namespace is eligible — the backward-compatible default.
3863        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
3892// ============================================================================
3893// Internal helpers
3894// ============================================================================
3895
3896/// Converts a [`watcher::Error`] into a short, human-readable diagnosis string.
3897///
3898/// The kube-runtime watcher wraps all errors in a thin enum. This function
3899/// peels back the layers to surface the actionable cause: connection refused,
3900/// unauthorized, RBAC-forbidden, or a generic API / transport error.
3901fn diagnose_reflector_error(e: &watcher::Error) -> String {
3902    // Extract the phase label and the inner kube client error, handling the
3903    // two variants that don't carry a kube::Error directly.
3904    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
3937/// Entry point for the `bindy scout` subcommand.
3938///
3939/// Initialises the Kubernetes client, builds reflector stores for `DNSZone`
3940/// resources (for zone validation), then runs the Ingress controller loop.
3941///
3942/// # Errors
3943///
3944/// Returns an error if the Kubernetes client cannot be initialised or if the
3945/// cluster name is not provided via CLI or the `BINDY_SCOUT_CLUSTER_NAME` env var.
3946pub 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    // Build a reflector store for DNSZone resources using the REMOTE client.
4002    // In same-cluster mode this is the local cluster; in Phase 2 this is the bindy cluster.
4003    // Scoped to the target namespace: DNSZones and ARecords always live in the same namespace
4004    // on the bindy cluster, so a namespaced watch is sufficient and avoids the need for a
4005    // cluster-scoped ClusterRole.
4006    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    // Start the DNSZone reflector in the background.
4015    // The kube-runtime watcher relies on the consumer to apply backoff: "You can apply your own
4016    // backoff by not polling the stream for a duration after errors." We sleep on each error so
4017    // that a repeated Connect failure doesn't spin in a tight logging loop.
4018    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    // Watch Ingresses across all namespaces using the LOCAL client
4049    let ingress_api: Api<Ingress> = Api::all(local_client.clone());
4050    // Watch Services across all namespaces using the LOCAL client
4051    let svc_api: Api<Service> = Api::all(local_client.clone());
4052    // Watch HTTPRoutes across all namespaces using the LOCAL client
4053    let httproute_api: Api<HTTPRoute> = Api::all(local_client.clone());
4054    // Watch TLSRoutes across all namespaces using the LOCAL client
4055    let tlsroute_api: Api<TLSRoute> = Api::all(local_client.clone());
4056    // Watch TCPRoutes across all namespaces using the LOCAL client
4057    let tcproute_api: Api<TCPRoute> = Api::all(local_client.clone());
4058
4059    // Probe EACH route kind, not one as a proxy for the others. Gateway API
4060    // ships in two channels and the kinds graduated at different times —
4061    // HTTPRoute Standard since v1.0, TLSRoute since v1.5, TCPRoute since v1.6 —
4062    // so a standard-channel install older than those serves HTTPRoute and not
4063    // the rest. Inferring from one kind would leave the other two controllers
4064    // retrying a 404 forever, which is exactly what this check prevents.
4065    //
4066    // Ingress and Service are core API and always served, so they are not
4067    // probed: a check that can never fail is noise.
4068    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    // Boxed so the Gateway API controllers can be left out entirely. The
4150    // futures above are lazy — constructing one starts nothing — so the three
4151    // route controllers are simply never polled when their CRDs are absent.
4152    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
4169/// Returns whether the cluster serves a given resource kind.
4170///
4171/// Gateway API is not installed by default in Kubernetes, and — critically —
4172/// its route kinds do NOT arrive together. Gateway API ships in two channels:
4173/// `HTTPRoute` has been Standard since v1.0, `TLSRoute` only became Standard in
4174/// v1.5 and `TCPRoute` in v1.6. A standard-channel install older than those has
4175/// `HTTPRoute` and neither of the others, so probing one kind and inferring the
4176/// rest re-creates the very error loop this check exists to prevent.
4177///
4178/// A [`Controller`] started against a kind whose CRD is absent does not fail
4179/// loudly: it retries forever, logging an error per attempt for an API that
4180/// will never appear. Probing once at startup turns that into a single
4181/// informational line.
4182///
4183/// # Arguments
4184/// * `api` - Cluster-wide handle for the kind to probe.
4185///
4186/// # Returns
4187/// `true` when the kind is served, `false` only when the API server answers
4188/// `404`. Any other error is reported as `true`, so a transient problem during
4189/// startup cannot silently disable watching for the lifetime of the process —
4190/// loud-but-working is the safer failure here.
4191///
4192/// The probe runs ONCE, at startup. Installing the CRDs later requires a Scout
4193/// restart before the corresponding controller begins watching.
4194pub 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}