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 `docs/roadmaps/bindy-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::{ALLOW_ZONE_NAMESPACES_WILDCARD, ANNOTATION_ALLOW_ZONE_NAMESPACES};
25use crate::crd::{ARecord, ARecordSpec, DNSZone};
26use anyhow::{anyhow, Context, Result};
27use k8s_openapi::api::core::v1::{Namespace, Secret, Service};
28use k8s_openapi::apimachinery::pkg::apis::meta::v1::Time;
29use k8s_openapi::jiff::Timestamp;
30use kube::api::{DeleteParams, ListParams, Patch, PatchParams};
31use kube::config::{KubeConfigOptions, Kubeconfig};
32
33/// Reconcile error type — wraps `anyhow::Error` so that it satisfies the
34/// `std::error::Error` bound required by `kube::runtime::Controller::run`.
35#[derive(Debug, thiserror::Error)]
36#[error(transparent)]
37pub struct ScoutError(#[from] anyhow::Error);
38use futures::StreamExt;
39use k8s_openapi::api::networking::v1::Ingress;
40use kube::{
41    runtime::{
42        controller::Action, reflector, watcher, watcher::Config as WatcherConfig, Controller,
43    },
44    Api, Client, Error as KubeError, ResourceExt,
45};
46use std::{collections::BTreeMap, sync::Arc, time::Duration};
47use tracing::{debug, error, info, warn};
48
49// ============================================================================
50// Gateway API Type Definitions
51//
52// HTTPRoute and TLSRoute are not in k8s_openapi yet, so we define minimal structs.
53// We only care about metadata and spec.hostnames[] for Scout's reconciliation.
54// ============================================================================
55
56/// A minimal Gateway API `parentRef` — the reference from a route back to the
57/// Gateway (or other parent) that serves it.
58///
59/// Only the fields Scout needs to walk route → Gateway are modelled. Per the
60/// Gateway API defaults, an omitted `group` means `gateway.networking.k8s.io`
61/// and an omitted `kind` means `Gateway`.
62#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
63#[serde(rename_all = "camelCase")]
64pub struct ParentReference {
65    /// API group of the parent. Defaults to `gateway.networking.k8s.io` when absent.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub group: Option<String>,
68    /// Kind of the parent. Defaults to `Gateway` when absent.
69    #[serde(default, skip_serializing_if = "Option::is_none")]
70    pub kind: Option<String>,
71    /// Namespace of the parent. Defaults to the route's own namespace when absent.
72    #[serde(default, skip_serializing_if = "Option::is_none")]
73    pub namespace: Option<String>,
74    /// Name of the parent Gateway.
75    pub name: String,
76}
77
78/// Minimal HTTPRoute spec for Scout's use case.
79#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
80#[serde(rename_all = "camelCase")]
81pub struct HTTPRouteSpec {
82    /// Hostnames matching this HTTPRoute
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub hostnames: Option<Vec<String>>,
85    /// Gateways this route attaches to. Scout follows these to discover the
86    /// serving Gateway's external IP when no explicit IP annotation is set.
87    #[serde(default, skip_serializing_if = "Option::is_none")]
88    pub parent_refs: Option<Vec<ParentReference>>,
89}
90
91/// Minimal HTTPRoute definition for Scout's use case.
92#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
93pub struct HTTPRoute {
94    #[serde(rename = "apiVersion")]
95    pub api_version: String,
96    pub kind: String,
97    pub metadata: kube::api::ObjectMeta,
98    #[serde(default)]
99    pub spec: Option<HTTPRouteSpec>,
100}
101
102/// Minimal TLSRoute spec for Scout's use case.
103#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
104#[serde(rename_all = "camelCase")]
105pub struct TLSRouteSpec {
106    /// Hostnames matching this TLSRoute
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub hostnames: Option<Vec<String>>,
109    /// Rules for this TLSRoute (required by API, but Scout only uses hostnames)
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub rules: Option<Vec<serde_json::Value>>,
112    /// Gateways this route attaches to. Scout follows these to discover the
113    /// serving Gateway's external IP when no explicit IP annotation is set.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub parent_refs: Option<Vec<ParentReference>>,
116}
117
118/// Minimal TLSRoute definition for Scout's use case.
119#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
120pub struct TLSRoute {
121    #[serde(rename = "apiVersion")]
122    pub api_version: String,
123    pub kind: String,
124    pub metadata: kube::api::ObjectMeta,
125    #[serde(default)]
126    pub spec: Option<TLSRouteSpec>,
127}
128
129/// Minimal TCPRoute spec for Scout's use case.
130#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
131#[serde(rename_all = "camelCase")]
132pub struct TCPRouteSpec {
133    /// Rules for this TCPRoute (kept for schema compatibility with the
134    /// Gateway API). Scout does not currently parse individual rule contents
135    /// for TCPRoute (L4), but keeping the field avoids schema drift when the
136    /// CR is round-tripped by the controller.
137    #[serde(default, skip_serializing_if = "Option::is_none")]
138    pub rules: Option<Vec<serde_json::Value>>,
139    /// Gateways this route attaches to. Scout follows these to discover the
140    /// serving Gateway's external IP when no explicit IP annotation is set.
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub parent_refs: Option<Vec<ParentReference>>,
143}
144
145/// Minimal TCPRoute definition for Scout's use case.
146#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
147pub struct TCPRoute {
148    #[serde(rename = "apiVersion")]
149    pub api_version: String,
150    pub kind: String,
151    pub metadata: kube::api::ObjectMeta,
152    #[serde(default)]
153    pub spec: Option<TCPRouteSpec>,
154}
155
156// Implement k8s_openapi::Metadata for HTTPRoute and TLSRoute
157impl k8s_openapi::Metadata for HTTPRoute {
158    type Ty = kube::api::ObjectMeta;
159    fn metadata(&self) -> &kube::api::ObjectMeta {
160        &self.metadata
161    }
162    fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
163        &mut self.metadata
164    }
165}
166
167impl k8s_openapi::Metadata for TLSRoute {
168    type Ty = kube::api::ObjectMeta;
169    fn metadata(&self) -> &kube::api::ObjectMeta {
170        &self.metadata
171    }
172    fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
173        &mut self.metadata
174    }
175}
176
177// Implement k8s_openapi::Resource for HTTPRoute and TLSRoute
178impl k8s_openapi::Resource for HTTPRoute {
179    const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
180    const GROUP: &'static str = "gateway.networking.k8s.io";
181    const KIND: &'static str = "HTTPRoute";
182    const VERSION: &'static str = "v1";
183    const URL_PATH_SEGMENT: &'static str = "httproutes";
184    type Scope = k8s_openapi::NamespaceResourceScope;
185}
186
187impl k8s_openapi::Resource for TLSRoute {
188    const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
189    const GROUP: &'static str = "gateway.networking.k8s.io";
190    const KIND: &'static str = "TLSRoute";
191    const VERSION: &'static str = "v1alpha2";
192    const URL_PATH_SEGMENT: &'static str = "tlsroutes";
193    type Scope = k8s_openapi::NamespaceResourceScope;
194}
195
196impl k8s_openapi::Metadata for TCPRoute {
197    type Ty = kube::api::ObjectMeta;
198    fn metadata(&self) -> &kube::api::ObjectMeta {
199        &self.metadata
200    }
201    fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
202        &mut self.metadata
203    }
204}
205
206impl k8s_openapi::Resource for TCPRoute {
207    const API_VERSION: &'static str = "gateway.networking.k8s.io/v1alpha2";
208    const GROUP: &'static str = "gateway.networking.k8s.io";
209    const KIND: &'static str = "TCPRoute";
210    const VERSION: &'static str = "v1alpha2";
211    const URL_PATH_SEGMENT: &'static str = "tcproutes";
212    type Scope = k8s_openapi::NamespaceResourceScope;
213}
214
215/// Gateway API group used for `parentRefs` and Gateway lookups.
216pub const GATEWAY_API_GROUP: &str = "gateway.networking.k8s.io";
217
218/// Gateway API `kind` for a Gateway parent reference.
219pub const GATEWAY_KIND: &str = "Gateway";
220
221/// A namespaced object reference (`namespace` + `name`).
222///
223/// Used both for the operator-configured `gatewayClass → LoadBalancer Service`
224/// map and for the Gateways a route's `parentRefs` point at.
225#[derive(Debug, Clone, PartialEq, Eq)]
226pub struct NamespacedName {
227    /// Object namespace.
228    pub namespace: String,
229    /// Object name.
230    pub name: String,
231}
232
233/// How Scout locates the LoadBalancer Service backing a gateway class.
234///
235/// Configured per `gatewayClass` so operators can pin the exact Service — either
236/// by an explicit `namespace/name`, or by a label selector scoped to a namespace
237/// (useful when the Service name is generated but carries stable labels).
238#[derive(Debug, Clone, PartialEq, Eq)]
239pub enum GatewayServiceTarget {
240    /// A specific LoadBalancer Service, addressed by namespace and name.
241    Name(NamespacedName),
242    /// A LoadBalancer Service found by label selector within a namespace.
243    /// `selector` is a standard Kubernetes label-selector string, e.g.
244    /// `app.kubernetes.io/name=traefik`.
245    Labeled {
246        /// Namespace to search for the Service.
247        namespace: String,
248        /// Kubernetes label selector identifying the Service.
249        selector: String,
250    },
251}
252
253impl GatewayServiceTarget {
254    /// Namespace the target Service lives in.
255    #[must_use]
256    pub fn namespace(&self) -> &str {
257        match self {
258            Self::Name(nn) => &nn.namespace,
259            Self::Labeled { namespace, .. } => namespace,
260        }
261    }
262}
263
264/// Minimal Gateway spec — only `gatewayClassName`, used to match the running class.
265#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
266#[serde(rename_all = "camelCase")]
267pub struct GatewaySpec {
268    /// Name of the GatewayClass implementing this Gateway.
269    pub gateway_class_name: String,
270}
271
272/// A single entry in `Gateway.status.addresses`.
273#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
274#[serde(rename_all = "camelCase")]
275pub struct GatewayStatusAddress {
276    /// Address type, e.g. `IPAddress` or `Hostname`. Absent is treated as unknown.
277    #[serde(default, skip_serializing_if = "Option::is_none")]
278    pub r#type: Option<String>,
279    /// The address value (an IP for `IPAddress`, a DNS name for `Hostname`).
280    pub value: String,
281}
282
283/// Minimal Gateway status — only the assigned addresses.
284#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
285#[serde(rename_all = "camelCase")]
286pub struct GatewayStatus {
287    /// Addresses the controller has assigned to this Gateway (often empty when
288    /// the controller publishes the external IP only on its LoadBalancer Service).
289    #[serde(default, skip_serializing_if = "Option::is_none")]
290    pub addresses: Option<Vec<GatewayStatusAddress>>,
291}
292
293/// Minimal Gateway definition for Scout's chain-following.
294#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
295pub struct Gateway {
296    #[serde(rename = "apiVersion")]
297    pub api_version: String,
298    pub kind: String,
299    pub metadata: kube::api::ObjectMeta,
300    pub spec: GatewaySpec,
301    #[serde(default, skip_serializing_if = "Option::is_none")]
302    pub status: Option<GatewayStatus>,
303}
304
305impl k8s_openapi::Metadata for Gateway {
306    type Ty = kube::api::ObjectMeta;
307    fn metadata(&self) -> &kube::api::ObjectMeta {
308        &self.metadata
309    }
310    fn metadata_mut(&mut self) -> &mut kube::api::ObjectMeta {
311        &mut self.metadata
312    }
313}
314
315impl k8s_openapi::Resource for Gateway {
316    const API_VERSION: &'static str = "gateway.networking.k8s.io/v1";
317    const GROUP: &'static str = "gateway.networking.k8s.io";
318    const KIND: &'static str = "Gateway";
319    const VERSION: &'static str = "v1";
320    const URL_PATH_SEGMENT: &'static str = "gateways";
321    type Scope = k8s_openapi::NamespaceResourceScope;
322}
323
324// ============================================================================
325// Constants
326// ============================================================================
327
328/// Annotation specifying the DNS record kind Scout should create for this Ingress.
329/// Set to `"ARecord"` to create an A record. Any other value (or absent) is ignored.
330pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
331
332/// Expected value of [`ANNOTATION_RECORD_KIND`] for A record creation.
333pub const RECORD_KIND_ARECORD: &str = "ARecord";
334
335/// Annotation specifying which DNS zone owns this Ingress host
336pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
337
338/// Simplified opt-in annotation — set to `"true"` to enable Scout for this Ingress.
339/// Takes precedence over (and is preferred to) [`ANNOTATION_RECORD_KIND`] for new users.
340/// Both annotations are accepted for backward compatibility.
341pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
342
343/// Annotation for explicitly overriding the IP(s) used in the ARecord.
344///
345/// Accepts a single IP (`"10.0.0.1"`) or a comma-separated list of IPs
346/// (`"10.0.0.1,10.0.0.2"`) — every entry becomes an address on the resulting
347/// `ARecord`, in the order given. Whitespace around each entry is trimmed and
348/// empty entries are skipped. When set, takes precedence over `--default-ips`
349/// and any LoadBalancer status IP.
350pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
351
352/// Annotation for overriding the TTL (in seconds) on the created ARecord.
353/// When absent, the ARecord inherits the TTL from the DNSZone spec.
354pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
355
356/// Annotation for overriding the DNS record name (`spec.name`) on the created ARecord.
357///
358/// When set, the value replaces the name normally derived from the source resource's
359/// host/hostname. Use `"@"` to target the zone apex. When absent or empty, Scout falls
360/// back to deriving the name from the host stripped of the zone suffix.
361///
362/// On multi-host resources (Ingress / HTTPRoute / TLSRoute) the override is applied to
363/// every ARecord produced from that resource, so it is intended for single-host use cases.
364pub const ANNOTATION_RECORD_NAME: &str = "bindy.firestoned.io/record-name";
365
366/// Finalizer added to Ingresses managed by Scout to ensure cleanup on deletion
367pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
368
369/// Label placed on created ARecords identifying Scout as the manager
370pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
371
372/// Label value for ARecords created by Scout
373pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
374
375/// Label identifying the source cluster on created ARecords
376pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
377
378/// Label identifying the source namespace on created ARecords
379pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
380
381/// Label identifying the source resource name on created ARecords.
382/// Used for all resource kinds (Ingress, Service, HTTPRoute, TLSRoute).
383pub const LABEL_SOURCE_NAME: &str = "bindy.firestoned.io/source-name";
384
385/// Label carrying the DNS zone name on created ARecords (for DNSZone selector matching)
386pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
387
388/// Default namespace where ARecords are created when `BINDY_SCOUT_NAMESPACE` is not set
389pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
390
391/// Maximum Kubernetes resource name length in characters
392const MAX_K8S_NAME_LEN: usize = 253;
393
394/// Prefix applied to all ARecord CR names created by Scout
395const ARECORD_NAME_PREFIX: &str = "scout";
396
397/// Requeue delay for non-fatal errors (seconds)
398const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
399
400/// Grace period, in seconds, that Scout keeps retrying remote ARecord cleanup
401/// for an object that is being deleted before it releases its finalizer anyway.
402///
403/// The finalizer exists so Scout gets a chance to delete the ARecords it created
404/// on the (possibly remote, Phase 2) Bindy cluster before the source object
405/// disappears. But if that remote cluster is unreachable — a broken/expired
406/// kubeconfig, a network partition — blocking finalizer removal on the remote
407/// call would strand the tenant's Ingress/Service/route in `Terminating`
408/// indefinitely, and because Scout adds this finalizer cluster-wide a single
409/// broken remote connection could block deletions across every namespace.
410///
411/// Within the grace period Scout requeues and retries (so transient failures
412/// still get cleaned up); past it, Scout releases the finalizer and logs that
413/// the remote ARecords may be orphaned and must be reconciled separately.
414pub(crate) const REMOTE_CLEANUP_GRACE_SECS: i64 = 300;
415
416/// Backoff delay before re-polling the DNSZone reflector after a connection error (seconds).
417/// The kube-runtime watcher has no built-in backoff — consumers must apply their own by
418/// delaying the next poll. Without this, a failed LIST/WATCH causes a tight retry loop.
419const REFLECTOR_ERROR_BACKOFF_SECS: u64 = 5;
420
421// ============================================================================
422// Context
423// ============================================================================
424
425/// Shared context passed to every reconciler invocation.
426pub struct ScoutContext {
427    /// Local Kubernetes client — Ingress watching and finalizer management.
428    /// Always the in-cluster client regardless of mode.
429    pub client: Client,
430    /// Remote Kubernetes client — ARecord creation/deletion and DNSZone validation.
431    /// In same-cluster mode (Phase 1) this is identical to `client`.
432    /// In remote mode (Phase 2+) this targets the dedicated Bindy cluster.
433    pub remote_client: Client,
434    /// Namespace where ARecords are created (on the remote/target cluster)
435    pub target_namespace: String,
436    /// Logical cluster name stamped on created ARecord labels
437    pub cluster_name: String,
438    /// Namespaces excluded from Ingress watching (always includes Scout's own namespace)
439    pub excluded_namespaces: Vec<String>,
440    /// Default IPs used when no annotation override and no LB status IP is available.
441    /// Intended for shared-ingress topologies (e.g. Traefik) where all Ingresses resolve
442    /// to the same IP(s). Set via `BINDY_SCOUT_DEFAULT_IPS` or `--default-ips`.
443    pub default_ips: Vec<String>,
444    /// Operator-configured `gatewayClass → LoadBalancer Service` map. When an HTTPRoute
445    /// or TLSRoute has no explicit IP annotation, Scout follows its `parentRefs` to a
446    /// Gateway of one of these classes and reads the mapped Service's external IP.
447    /// Set via `BINDY_SCOUT_GATEWAY_SERVICES` or `--gateway-service`.
448    pub gateway_services: BTreeMap<String, GatewayServiceTarget>,
449    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
450    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone`.
451    pub default_zone: Option<String>,
452    /// Kubernetes label selector restricting which namespaces Scout will act in (e.g.
453    /// `"bindy.firestoned.io/scout-enabled=true"`). A source object's own per-resource opt-in
454    /// annotation is still required in addition — the namespace must match this selector AND
455    /// the object must carry the annotation. `None` means every namespace is eligible, matching
456    /// pre-selector behavior; this is the default for backward compatibility, but production
457    /// deployments are strongly encouraged to set it rather than run with cluster-wide scope
458    /// (see `namespace_matches_selector` and the Scout guide). Set via
459    /// `BINDY_SCOUT_NAMESPACE_SELECTOR` or `--namespace-selector`.
460    pub namespace_selector: Option<String>,
461    /// Read-only store of DNSZone resources for zone validation.
462    /// Populated from the remote client so zones are validated against the bindy cluster.
463    pub zone_store: reflector::Store<DNSZone>,
464}
465
466// ============================================================================
467// Namespace-selector helpers (async — require Kubernetes API access)
468// ============================================================================
469
470/// Returns `true` if `namespace` currently carries labels matching `selector`.
471///
472/// `selector` is a standard Kubernetes label selector expression (e.g.
473/// `"bindy.firestoned.io/scout-enabled=true"`, or any expression accepted by
474/// `kubectl -l`). Rather than re-implement label-selector parsing/matching
475/// client-side, this delegates to the API server: it issues a `List` on
476/// `Namespace` scoped to the single namespace by name (`fieldSelector:
477/// metadata.name=<namespace>`) combined with the caller's label selector, and
478/// checks whether the (0-or-1-item) result is non-empty. This guarantees
479/// exactly the same selector semantics as `kubectl get ns -l <selector>`.
480///
481/// # Errors
482///
483/// Returns an error if the `List` call fails (e.g. RBAC-forbidden, transport
484/// error, or a malformed selector rejected by the API server) — callers should
485/// propagate this as a reconcile error (triggering a requeue with backoff)
486/// rather than treat a failed check as "not matched", to avoid spuriously
487/// cleaning up ARecords/finalizers on a transient API hiccup.
488async fn namespace_matches_selector(
489    client: &Client,
490    namespace: &str,
491    selector: &str,
492) -> Result<bool> {
493    let ns_api: Api<Namespace> = Api::all(client.clone());
494    let lp = ListParams::default()
495        .labels(selector)
496        .fields(&format!("metadata.name={namespace}"));
497    let list = ns_api.list(&lp).await.with_context(|| {
498        format!("failed to check namespace '{namespace}' against selector '{selector}'")
499    })?;
500    Ok(!list.items.is_empty())
501}
502
503/// Returns whether `namespace` is eligible for Scout to act in.
504///
505/// When `selector` is `None` (the default), every namespace is eligible —
506/// preserving pre-selector behavior for backward compatibility. When `selector`
507/// is `Some`, this delegates to [`namespace_matches_selector`], so only
508/// namespaces whose labels match the configured selector are eligible.
509///
510/// This is combined (AND) with the existing per-resource opt-in annotation
511/// check (`is_scout_opted_in`) in each reconciler: both the namespace and the
512/// individual Ingress/Service/route object must opt in.
513///
514/// # Errors
515///
516/// Propagates any error from [`namespace_matches_selector`].
517async fn source_namespace_eligible(
518    client: &Client,
519    namespace: &str,
520    selector: Option<&str>,
521) -> Result<bool> {
522    match selector {
523        None => Ok(true),
524        Some(sel) => namespace_matches_selector(client, namespace, sel).await,
525    }
526}
527
528// ============================================================================
529// Pure helper functions (tested in scout_tests.rs)
530// ============================================================================
531
532/// Returns `true` when an object that entered `Terminating` at
533/// `deletion_timestamp` has been terminating for at least
534/// [`REMOTE_CLEANUP_GRACE_SECS`] as of `now`.
535///
536/// Used during finalizer handling: when remote ARecord cleanup keeps failing,
537/// this decides whether Scout should give up and release its finalizer (grace
538/// expired) rather than strand the source object in `Terminating` forever.
539///
540/// Returns `false` when `deletion_timestamp` is `None` (the object is not being
541/// deleted, so there is nothing to time out), when the grace period has not yet
542/// elapsed (keep retrying), or when the timestamp is in the future (clock skew).
543pub(crate) fn cleanup_grace_expired(deletion_timestamp: Option<&Time>, now: Timestamp) -> bool {
544    match deletion_timestamp {
545        Some(Time(started)) => now.duration_since(*started).as_secs() >= REMOTE_CLEANUP_GRACE_SECS,
546        None => false,
547    }
548}
549
550/// Returns `true` if the Ingress is annotated for ARecord creation.
551///
552/// The annotation `bindy.firestoned.io/recordKind` must have the value `"ARecord"` (case-sensitive).
553/// Any other value (or absence of the annotation) returns `false`.
554pub fn is_arecord_enabled(annotations: &BTreeMap<String, String>) -> bool {
555    annotations
556        .get(ANNOTATION_RECORD_KIND)
557        .map(|v| v == RECORD_KIND_ARECORD)
558        .unwrap_or(false)
559}
560
561/// Returns `true` if Scout should manage this Ingress.
562///
563/// Accepts either the simplified opt-in annotation:
564/// - `bindy.firestoned.io/scout-enabled: "true"` (preferred for new deployments)
565///
566/// Or the legacy annotation for backward compatibility:
567/// - `bindy.firestoned.io/recordKind: "ARecord"`
568///
569/// The record kind always defaults to `ARecord` — no further annotation is needed.
570pub fn is_scout_opted_in(annotations: &BTreeMap<String, String>) -> bool {
571    annotations
572        .get(ANNOTATION_SCOUT_ENABLED)
573        .map(|v| v == "true")
574        .unwrap_or(false)
575        || is_arecord_enabled(annotations)
576}
577
578/// Resolves the DNS zone for an Ingress, in priority order:
579///
580/// 1. `bindy.firestoned.io/zone` annotation — per-Ingress explicit override
581/// 2. `default_zone` — operator-configured default zone (e.g. `"example.com"`)
582///
583/// Returns `None` if neither is available. When `None`, Scout logs a warning and skips the Ingress.
584pub fn resolve_zone(
585    annotations: &BTreeMap<String, String>,
586    default_zone: Option<&str>,
587) -> Option<String> {
588    get_zone_annotation(annotations).or_else(|| default_zone.map(ToString::to_string))
589}
590
591/// Returns the DNS zone specified by the `bindy.firestoned.io/zone` annotation.
592///
593/// Returns `None` if the annotation is absent or has an empty value.
594pub fn get_zone_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
595    annotations
596        .get(ANNOTATION_ZONE)
597        .filter(|v| !v.is_empty())
598        .cloned()
599}
600
601/// Derives the DNS record name from a hostname and zone.
602///
603/// - `host.zone` → `host` (e.g. `"app.example.com"` + `"example.com"` → `"app"`)
604/// - `zone` (apex) → `"@"`
605/// - `deep.sub.zone` → `"deep.sub"`
606///
607/// Trailing dots on `host` are stripped before comparison.
608///
609/// # Errors
610///
611/// Returns an error if `host` does not end with the zone suffix.
612pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
613    // Strip trailing dot if present (some Ingress controllers append it)
614    let host = host.trim_end_matches('.');
615
616    // Apex record
617    if host == zone {
618        return Ok("@".to_string());
619    }
620
621    let zone_suffix = format!(".{zone}");
622    if !host.ends_with(&zone_suffix) {
623        return Err(anyhow!(
624            "host \"{host}\" does not belong to zone \"{zone}\""
625        ));
626    }
627
628    let record_name = &host[..host.len() - zone_suffix.len()];
629    Ok(record_name.to_string())
630}
631
632/// Returns the explicit DNS record name override from `bindy.firestoned.io/record-name`.
633///
634/// The annotation value is trimmed of surrounding whitespace. Returns `None` if the
635/// annotation is absent, empty, or whitespace-only.
636pub fn get_record_name_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
637    annotations
638        .get(ANNOTATION_RECORD_NAME)
639        .map(|v| v.trim().to_string())
640        .filter(|v| !v.is_empty())
641}
642
643/// Resolves the DNS record name for an ARecord, in priority order:
644///
645/// 1. `bindy.firestoned.io/record-name` annotation — explicit override (e.g. `"myapp"`, `"@"`)
646/// 2. Derived from `host` by stripping the zone suffix (see [`derive_record_name`])
647///
648/// When the override annotation is present, the host is **not** validated against the zone:
649/// the operator has explicitly chosen the record name and is responsible for its correctness.
650///
651/// # Errors
652///
653/// Returns the error from [`derive_record_name`] only when no override is set and the host
654/// does not belong to the zone.
655pub fn resolve_record_name(
656    annotations: &BTreeMap<String, String>,
657    host: &str,
658    zone: &str,
659) -> Result<String> {
660    if let Some(override_name) = get_record_name_annotation(annotations) {
661        return Ok(override_name);
662    }
663    derive_record_name(host, zone)
664}
665
666/// Returns the explicit IP overrides from the `bindy.firestoned.io/ip` annotation.
667///
668/// The value may be a single IP (`"10.0.0.1"`) or a comma-separated list
669/// (`"10.0.0.1,10.0.0.2,10.0.0.3"`). Whitespace around each entry is trimmed
670/// and empty entries are skipped, preserving order and duplicates.
671///
672/// Returns `None` if the annotation is absent, empty, or contains only
673/// separators/whitespace.
674pub fn resolve_ips_from_annotation(annotations: &BTreeMap<String, String>) -> Option<Vec<String>> {
675    let raw = annotations.get(ANNOTATION_IP)?;
676    let ips: Vec<String> = raw
677        .split(',')
678        .map(str::trim)
679        .filter(|s| !s.is_empty())
680        .map(ToString::to_string)
681        .collect();
682    if ips.is_empty() {
683        None
684    } else {
685        Some(ips)
686    }
687}
688
689/// Whether a `DNSZone` authorizes DNS records sourced from `source_namespace`.
690///
691/// A DNSZone in the *same* namespace as the source object (Ingress / Service /
692/// Route) is always authorized. A DNSZone in a different namespace must opt in
693/// via the [`ANNOTATION_ALLOW_ZONE_NAMESPACES`] annotation — a comma-separated
694/// namespace list, or the [`ALLOW_ZONE_NAMESPACES_WILDCARD`] `*`.
695///
696/// This mirrors the cross-namespace gate the DNSZone reconciler already
697/// enforces for instance targeting, and closes audit finding H1: without it,
698/// any tenant's opted-in Ingress could publish records into *any* zone Scout
699/// served (a confused-deputy cross-tenant DNS hijack), because Scout writes
700/// with a cluster-privileged remote client.
701#[must_use]
702pub fn zone_allows_source_namespace(zone: &DNSZone, source_namespace: &str) -> bool {
703    if zone.namespace().as_deref() == Some(source_namespace) {
704        return true;
705    }
706    let Some(annotations) = zone.metadata.annotations.as_ref() else {
707        return false;
708    };
709    let Some(value) = annotations.get(ANNOTATION_ALLOW_ZONE_NAMESPACES) else {
710        return false;
711    };
712    value
713        .split(',')
714        .map(str::trim)
715        .any(|entry| entry == ALLOW_ZONE_NAMESPACES_WILDCARD || entry == source_namespace)
716}
717
718/// Outcome of resolving a zone name against the DNSZone store for a given
719/// source namespace.
720#[derive(Debug, PartialEq, Eq)]
721pub(crate) enum ZoneAuthz {
722    /// A matching DNSZone exists and authorizes the source namespace.
723    Authorized,
724    /// A matching DNSZone exists but does not authorize the source namespace.
725    Forbidden,
726    /// No DNSZone with the requested name is present in the store yet.
727    NotFound,
728}
729
730/// Resolve `zone_name` against the DNSZone `zones` for `source_namespace`.
731///
732/// Returns [`ZoneAuthz::Authorized`] if any matching DNSZone authorizes the
733/// namespace (see [`zone_allows_source_namespace`]), [`ZoneAuthz::Forbidden`]
734/// if the zone exists but no matching DNSZone authorizes it, or
735/// [`ZoneAuthz::NotFound`] if no DNSZone with that name is in the store.
736pub(crate) fn check_zone_authorization(
737    zones: &[Arc<DNSZone>],
738    zone_name: &str,
739    source_namespace: &str,
740) -> ZoneAuthz {
741    let mut found = false;
742    for zone in zones {
743        if zone.spec.zone_name != zone_name {
744            continue;
745        }
746        found = true;
747        if zone_allows_source_namespace(zone, source_namespace) {
748            return ZoneAuthz::Authorized;
749        }
750    }
751    if found {
752        ZoneAuthz::Forbidden
753    } else {
754        ZoneAuthz::NotFound
755    }
756}
757
758/// Resolves the IP address(es) to use for an ARecord, in priority order:
759///
760/// 1. `bindy.firestoned.io/ip` annotation — explicit override (single IP or comma-separated list)
761/// 2. `default_ips` — operator-configured default IPs (e.g. shared Traefik ingress VIP)
762/// 3. Ingress LoadBalancer status — first non-empty IP
763///
764/// Returns `None` if no IP can be determined from any source.
765pub fn resolve_ips(
766    annotations: &BTreeMap<String, String>,
767    default_ips: &[String],
768    ingress: &Ingress,
769) -> Option<Vec<String>> {
770    if let Some(ips) = resolve_ips_from_annotation(annotations) {
771        return Some(ips);
772    }
773    if !default_ips.is_empty() {
774        return Some(default_ips.to_vec());
775    }
776    resolve_ip_from_lb_status(ingress).map(|ip| vec![ip])
777}
778
779/// Resolves the IP to use for an ARecord from the Ingress load-balancer status.
780///
781/// Returns the first non-empty `ip` field found in `status.loadBalancer.ingress`.
782/// Hostname-only entries (no IP) are ignored; a warning is logged for each.
783pub fn resolve_ip_from_lb_status(ingress: &Ingress) -> Option<String> {
784    let lb_ingresses = ingress
785        .status
786        .as_ref()?
787        .load_balancer
788        .as_ref()?
789        .ingress
790        .as_ref()?;
791
792    for lb in lb_ingresses {
793        if let Some(ip) = &lb.ip {
794            if !ip.is_empty() {
795                return Some(ip.clone());
796            }
797        }
798        if lb.hostname.is_some() {
799            warn!(
800                ingress = %ingress.name_any(),
801                "Ingress LB status has hostname but no IP — A record requires an IP address; skipping"
802            );
803        }
804    }
805    None
806}
807
808/// Builds a sanitized Kubernetes resource name for an ARecord CR.
809///
810/// Format: `scout-{cluster}-{namespace}-{ingress}-{index}`
811///
812/// All characters are lowercased. Underscores and any non-alphanumeric characters
813/// (other than hyphens) are replaced with hyphens. The result is truncated to
814/// 253 characters to stay within the Kubernetes name limit.
815pub fn arecord_cr_name(
816    cluster: &str,
817    namespace: &str,
818    ingress_name: &str,
819    host_index: usize,
820) -> String {
821    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{ingress_name}-{host_index}");
822    let sanitized = sanitize_k8s_name(&raw);
823    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
824}
825
826/// Sanitizes a string for use as a Kubernetes resource name.
827///
828/// - Lowercases all characters
829/// - Replaces any character that is not `[a-z0-9-]` with `-`
830/// - Collapses consecutive hyphens into one
831/// - Strips leading and trailing hyphens
832fn sanitize_k8s_name(s: &str) -> String {
833    let lower = s.to_lowercase();
834    let mut result = String::with_capacity(lower.len());
835    let mut last_was_hyphen = false;
836
837    for ch in lower.chars() {
838        if ch.is_ascii_alphanumeric() {
839            result.push(ch);
840            last_was_hyphen = false;
841        } else {
842            // Replace any non-alphanumeric character with a hyphen (collapsing runs)
843            if !last_was_hyphen {
844                result.push('-');
845                last_was_hyphen = true;
846            }
847        }
848    }
849
850    // Strip trailing hyphens
851    let trimmed = result.trim_end_matches('-');
852    // Strip leading hyphens
853    trimmed.trim_start_matches('-').to_string()
854}
855
856/// Returns `true` if the Scout finalizer is present on the Ingress.
857pub fn has_finalizer(ingress: &Ingress) -> bool {
858    ingress
859        .metadata
860        .finalizers
861        .as_ref()
862        .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
863        .unwrap_or(false)
864}
865
866/// Returns `true` if the Ingress has been marked for deletion.
867pub fn is_being_deleted(ingress: &Ingress) -> bool {
868    ingress.metadata.deletion_timestamp.is_some()
869}
870
871/// Builds a Kubernetes label selector string matching all ARecords created
872/// by Scout for a specific Ingress.
873///
874/// Selects on `managed-by=scout`, `source-cluster`, `source-namespace`, and
875/// `source-name` to precisely target only the records owned by this Ingress.
876pub fn arecord_label_selector(cluster: &str, namespace: &str, ingress_name: &str) -> String {
877    format!(
878        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={ingress_name}",
879        LABEL_MANAGED_BY,
880        LABEL_MANAGED_BY_SCOUT,
881        cluster_key = LABEL_SOURCE_CLUSTER,
882        ns_key = LABEL_SOURCE_NAMESPACE,
883        name_key = LABEL_SOURCE_NAME,
884    )
885}
886
887/// Builds a label selector string matching ARecords for the given Ingress that
888/// belong to **any cluster other than `current_cluster`**.
889///
890/// Used to detect and clean up stale ARecords left behind when the scout is
891/// restarted with a different `--cluster-name`.  The `!=` operator is supported
892/// by the Kubernetes label selector language for equality-based requirements.
893pub fn stale_arecord_label_selector(
894    current_cluster: &str,
895    namespace: &str,
896    ingress_name: &str,
897) -> String {
898    format!(
899        "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={ingress_name}",
900        LABEL_MANAGED_BY,
901        LABEL_MANAGED_BY_SCOUT,
902        cluster_key = LABEL_SOURCE_CLUSTER,
903        ns_key = LABEL_SOURCE_NAMESPACE,
904        name_key = LABEL_SOURCE_NAME,
905    )
906}
907
908// ============================================================================
909// ARecord builder
910// ============================================================================
911
912/// Parameters for building an ARecord CR.
913pub struct ARecordParams<'a> {
914    /// Kubernetes resource name for the ARecord CR
915    pub name: &'a str,
916    /// Namespace where the ARecord CR will be created
917    pub target_namespace: &'a str,
918    /// DNS record name within the zone (e.g. `"app"` or `"@"`)
919    pub record_name: &'a str,
920    /// IPv4 addresses to use for the record (one or more)
921    pub ips: &'a [String],
922    /// Optional TTL override in seconds
923    pub ttl: Option<i32>,
924    /// Logical name of the source cluster (for labels)
925    pub cluster_name: &'a str,
926    /// Source Ingress namespace (for labels)
927    pub ingress_namespace: &'a str,
928    /// Source Ingress name (for labels)
929    pub ingress_name: &'a str,
930    /// DNS zone name (for labels)
931    pub zone: &'a str,
932}
933
934/// Builds the ARecord CR that Scout will create on the target cluster.
935pub fn build_arecord(params: ARecordParams<'_>) -> ARecord {
936    let mut labels = BTreeMap::new();
937    labels.insert(
938        LABEL_MANAGED_BY.to_string(),
939        LABEL_MANAGED_BY_SCOUT.to_string(),
940    );
941    labels.insert(
942        LABEL_SOURCE_CLUSTER.to_string(),
943        params.cluster_name.to_string(),
944    );
945    labels.insert(
946        LABEL_SOURCE_NAMESPACE.to_string(),
947        params.ingress_namespace.to_string(),
948    );
949    labels.insert(
950        LABEL_SOURCE_NAME.to_string(),
951        params.ingress_name.to_string(),
952    );
953    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
954
955    let meta = kube::api::ObjectMeta {
956        name: Some(params.name.to_string()),
957        namespace: Some(params.target_namespace.to_string()),
958        labels: Some(labels),
959        ..Default::default()
960    };
961
962    ARecord {
963        metadata: meta,
964        spec: ARecordSpec {
965            name: params.record_name.to_string(),
966            ipv4_addresses: params.ips.to_vec(),
967            ttl: params.ttl,
968        },
969        status: None,
970    }
971}
972
973// ============================================================================
974// Service helpers
975// ============================================================================
976
977/// Returns `true` if the Service is of type `LoadBalancer`.
978///
979/// `ClusterIP` and `NodePort` services have no routable external IP, so
980/// Scout silently skips them without warning.
981pub fn is_loadbalancer_service(svc: &Service) -> bool {
982    svc.spec
983        .as_ref()
984        .and_then(|s| s.type_.as_deref())
985        .map(|t| t == "LoadBalancer")
986        .unwrap_or(false)
987}
988
989/// Extracts the first non-empty IP from the Service's LoadBalancer status.
990///
991/// Returns `None` if the status has no entries, or the first entry has no IP
992/// (hostname-only entries are ignored). Scout re-queues and waits for the
993/// cloud provider to assign an external IP.
994pub fn resolve_ip_from_service_lb_status(svc: &Service) -> Option<String> {
995    svc.status
996        .as_ref()?
997        .load_balancer
998        .as_ref()?
999        .ingress
1000        .as_ref()?
1001        .iter()
1002        .find_map(|entry| entry.ip.clone().filter(|ip| !ip.is_empty()))
1003}
1004
1005/// Parses a `namespace/name` string into a [`NamespacedName`].
1006///
1007/// Whitespace around each segment is trimmed. Returns `None` unless the input
1008/// has exactly two non-empty segments separated by a single `/`.
1009#[must_use]
1010pub fn service_ref_from_str(s: &str) -> Option<NamespacedName> {
1011    let mut parts = s.split('/');
1012    let namespace = parts.next()?.trim();
1013    let name = parts.next()?.trim();
1014    if namespace.is_empty() || name.is_empty() || parts.next().is_some() {
1015        return None;
1016    }
1017    Some(NamespacedName {
1018        namespace: namespace.to_string(),
1019        name: name.to_string(),
1020    })
1021}
1022
1023/// Parses a single `gatewayClass` target: either `namespace/name` (explicit
1024/// Service) or `namespace/<label-selector>` (any entry whose Service part
1025/// contains `=`, since Service names never do).
1026///
1027/// The namespace is the segment before the first `/`; namespaces cannot contain
1028/// `/`, so a label selector's own `/` (e.g. `app.kubernetes.io/name=x`) is
1029/// preserved. Returns `None` for empty namespace or empty target.
1030#[must_use]
1031pub fn gateway_service_target_from_str(s: &str) -> Option<GatewayServiceTarget> {
1032    let (namespace, rest) = s.split_once('/')?;
1033    let namespace = namespace.trim();
1034    let rest = rest.trim();
1035    if namespace.is_empty() || rest.is_empty() {
1036        return None;
1037    }
1038    if rest.contains('=') {
1039        return Some(GatewayServiceTarget::Labeled {
1040            namespace: namespace.to_string(),
1041            selector: rest.to_string(),
1042        });
1043    }
1044    // No `=` → an explicit Service name. Reuse the strict `ns/name` parser,
1045    // which rejects extra slashes.
1046    service_ref_from_str(s).map(GatewayServiceTarget::Name)
1047}
1048
1049/// Parses a single `class=<target>` mapping entry.
1050///
1051/// Splits on the first `=` (so a label selector's own `=` stays in the target).
1052/// Returns `None` for an empty class or an unparseable target. Used per-entry so
1053/// the repeatable `--gateway-service` CLI flag can carry multi-label selectors
1054/// (which contain commas) without them being mistaken for entry separators.
1055#[must_use]
1056pub fn parse_gateway_service_entry(entry: &str) -> Option<(String, GatewayServiceTarget)> {
1057    let (class, target) = entry.trim().split_once('=')?;
1058    let class = class.trim();
1059    if class.is_empty() {
1060        return None;
1061    }
1062    let target = gateway_service_target_from_str(target)?;
1063    Some((class.to_string(), target))
1064}
1065
1066/// Parses the operator's `gatewayClass → LoadBalancer Service` map from a single
1067/// comma-separated string (the `BINDY_SCOUT_GATEWAY_SERVICES` env form).
1068///
1069/// Each entry is `class=<target>`, where `<target>` is either `namespace/name`
1070/// or `namespace/<label-selector>`, e.g.
1071/// `traefik=traefik/traefik,cilium=kube-system/app.kubernetes.io/name=cilium`.
1072/// Malformed entries are skipped. Because commas separate entries here,
1073/// multi-label selectors (which use commas) must be supplied via the repeatable
1074/// `--gateway-service` CLI flag instead. The map's keys double as the allow-list
1075/// of gateway classes Scout will follow.
1076#[must_use]
1077pub fn parse_gateway_services(raw: &str) -> BTreeMap<String, GatewayServiceTarget> {
1078    raw.split(',')
1079        .filter(|e| !e.trim().is_empty())
1080        .filter_map(parse_gateway_service_entry)
1081        .collect()
1082}
1083
1084/// Extracts the IP-typed addresses from a Gateway's `status.addresses`.
1085///
1086/// An entry is treated as an IP when its `type` is `IPAddress`, or when the
1087/// `type` is absent but the `value` parses as an [`IpAddr`](std::net::IpAddr).
1088/// `Hostname`-typed entries are ignored. Returns an empty vec when the Gateway
1089/// has no addresses (the common case that forces the LoadBalancer-Service hop).
1090#[must_use]
1091pub fn gateway_addresses_as_ips(gw: &Gateway) -> Vec<String> {
1092    let Some(addresses) = gw.status.as_ref().and_then(|s| s.addresses.as_ref()) else {
1093        return Vec::new();
1094    };
1095    addresses
1096        .iter()
1097        .filter(|addr| match addr.r#type.as_deref() {
1098            Some("IPAddress") => true,
1099            Some("Hostname") => false,
1100            _ => addr.value.parse::<std::net::IpAddr>().is_ok(),
1101        })
1102        .map(|addr| addr.value.clone())
1103        .filter(|v| !v.is_empty())
1104        .collect()
1105}
1106
1107/// Resolves a route's `parentRefs` to the Gateways they point at.
1108///
1109/// Only references to Gateway API Gateways are returned — an entry is kept when
1110/// its `group` is absent or `gateway.networking.k8s.io` AND its `kind` is absent
1111/// or `Gateway`. Each ref's namespace defaults to `route_namespace` when omitted.
1112#[must_use]
1113pub fn gateway_parent_refs(
1114    parent_refs: &[ParentReference],
1115    route_namespace: &str,
1116) -> Vec<NamespacedName> {
1117    parent_refs
1118        .iter()
1119        .filter(|r| {
1120            let group_ok = r
1121                .group
1122                .as_deref()
1123                .is_none_or(|g| g.is_empty() || g == GATEWAY_API_GROUP);
1124            let kind_ok = r.kind.as_deref().is_none_or(|k| k == GATEWAY_KIND);
1125            group_ok && kind_ok
1126        })
1127        .map(|r| NamespacedName {
1128            namespace: r
1129                .namespace
1130                .clone()
1131                .filter(|ns| !ns.is_empty())
1132                .unwrap_or_else(|| route_namespace.to_string()),
1133            name: r.name.clone(),
1134        })
1135        .collect()
1136}
1137
1138/// Resolves the external IP of the LoadBalancer Service a gateway class maps to.
1139///
1140/// For a [`GatewayServiceTarget::Name`] the Service is fetched directly; for a
1141/// [`GatewayServiceTarget::Labeled`] target the namespace is listed by label
1142/// selector and the first LoadBalancer Service with an external IP is used.
1143/// Returns `None` (with a debug log) when the Service is unreachable, absent, or
1144/// has no external IP assigned yet.
1145async fn resolve_ip_from_gateway_service(
1146    client: &Client,
1147    target: &GatewayServiceTarget,
1148) -> Option<String> {
1149    match target {
1150        GatewayServiceTarget::Name(svc_ref) => {
1151            let svc_api: Api<Service> = Api::namespaced(client.clone(), &svc_ref.namespace);
1152            match svc_api.get(&svc_ref.name).await {
1153                Ok(svc) => resolve_ip_from_service_lb_status(&svc).or_else(|| {
1154                    debug!(service = %svc_ref.name, ns = %svc_ref.namespace,
1155                        "Gateway LoadBalancer Service has no external IP yet");
1156                    None
1157                }),
1158                Err(e) => {
1159                    debug!(service = %svc_ref.name, ns = %svc_ref.namespace, error = %e,
1160                        "Could not fetch Gateway's LoadBalancer Service");
1161                    None
1162                }
1163            }
1164        }
1165        GatewayServiceTarget::Labeled {
1166            namespace,
1167            selector,
1168        } => {
1169            let svc_api: Api<Service> = Api::namespaced(client.clone(), namespace);
1170            let lp = kube::api::ListParams::default().labels(selector);
1171            match svc_api.list(&lp).await {
1172                Ok(list) => list
1173                    .items
1174                    .iter()
1175                    .filter(|svc| is_loadbalancer_service(svc))
1176                    .find_map(resolve_ip_from_service_lb_status)
1177                    .or_else(|| {
1178                        debug!(ns = %namespace, selector = %selector,
1179                            "No LoadBalancer Service with an external IP matched the selector");
1180                        None
1181                    }),
1182                Err(e) => {
1183                    debug!(ns = %namespace, selector = %selector, error = %e,
1184                        "Could not list Gateway LoadBalancer Services by selector");
1185                    None
1186                }
1187            }
1188        }
1189    }
1190}
1191
1192/// Follows a route's `parentRefs` back to the serving Gateway(s) and resolves
1193/// their external IP(s).
1194///
1195/// For each `parentRef` that resolves to a Gateway, Scout resolves the IP by:
1196///   1. reading the Gateway's `status.addresses` (IP-typed) — no extra
1197///      configuration required; works as long as the gateway controller
1198///      populates its own status.
1199///   2. if `status.addresses` is empty, looking up the gateway class in the
1200///      operator-configured `gateway_services` map and reading the external IP
1201///      of the mapped LoadBalancer Service.
1202///
1203/// Returns the de-duplicated IPs in discovery order, or `None` when nothing
1204/// resolves. Individual lookup failures are logged and skipped so one
1205/// unreachable Gateway does not blank out the others.
1206///
1207/// # Arguments
1208/// * `client` - Local cluster client (Gateways and Services live on the workload cluster)
1209/// * `route_namespace` - Namespace of the route, used as the default parentRef namespace
1210/// * `parent_refs` - The route's `spec.parentRefs`
1211/// * `gateway_services` - Optional fallback map: `gatewayClass → Service` used only when
1212///   `status.addresses` is absent
1213pub async fn resolve_ips_from_gateways(
1214    client: &Client,
1215    route_namespace: &str,
1216    parent_refs: &[ParentReference],
1217    gateway_services: &BTreeMap<String, GatewayServiceTarget>,
1218) -> Option<Vec<String>> {
1219    if parent_refs.is_empty() {
1220        return None;
1221    }
1222
1223    let mut ips: Vec<String> = Vec::new();
1224    for gw_ref in gateway_parent_refs(parent_refs, route_namespace) {
1225        let gw_api: Api<Gateway> = Api::namespaced(client.clone(), &gw_ref.namespace);
1226        let gateway = match gw_api.get(&gw_ref.name).await {
1227            Ok(gw) => gw,
1228            Err(e) => {
1229                debug!(gateway = %gw_ref.name, ns = %gw_ref.namespace, error = %e,
1230                    "Skipping parentRef Gateway that could not be fetched");
1231                continue;
1232            }
1233        };
1234
1235        // Prefer the Gateway's own advertised addresses when present — no
1236        // gateway-services configuration needed for this path.
1237        let gw_ips = gateway_addresses_as_ips(&gateway);
1238        if !gw_ips.is_empty() {
1239            ips.extend(gw_ips);
1240            continue;
1241        }
1242
1243        // status.addresses is empty — fall back to the mapped LoadBalancer
1244        // Service if the gateway class is in the operator-configured map.
1245        let class = &gateway.spec.gateway_class_name;
1246        let Some(target) = gateway_services.get(class) else {
1247            debug!(gateway = %gw_ref.name, class = %class,
1248                "Gateway has no status.addresses and class not in configured gateway-services — skipping");
1249            continue;
1250        };
1251        if let Some(ip) = resolve_ip_from_gateway_service(client, target).await {
1252            ips.push(ip);
1253        }
1254    }
1255
1256    // De-duplicate preserving discovery order.
1257    let mut seen = std::collections::HashSet::new();
1258    ips.retain(|ip| seen.insert(ip.clone()));
1259
1260    if ips.is_empty() {
1261        None
1262    } else {
1263        Some(ips)
1264    }
1265}
1266
1267/// Derives the ARecord CR name for a Service.
1268///
1269/// Format: `scout-{cluster}-{namespace}-{service_name}`
1270///
1271/// No index suffix — unlike Ingress, a Service produces exactly one ARecord.
1272/// Applies the same sanitisation and 253-char truncation as Ingress CR names.
1273pub fn service_arecord_cr_name(cluster: &str, namespace: &str, service_name: &str) -> String {
1274    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{service_name}");
1275    let sanitized = sanitize_k8s_name(&raw);
1276    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1277}
1278
1279/// Builds a Kubernetes label selector matching all ARecords created by Scout
1280/// for a specific Service.
1281pub fn service_arecord_label_selector(
1282    cluster: &str,
1283    namespace: &str,
1284    service_name: &str,
1285) -> String {
1286    format!(
1287        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={service_name}",
1288        LABEL_MANAGED_BY,
1289        LABEL_MANAGED_BY_SCOUT,
1290        cluster_key = LABEL_SOURCE_CLUSTER,
1291        ns_key = LABEL_SOURCE_NAMESPACE,
1292        name_key = LABEL_SOURCE_NAME,
1293    )
1294}
1295
1296/// Parameters for building a Service-sourced ARecord CR.
1297pub struct ServiceARecordParams<'a> {
1298    /// Kubernetes resource name for the ARecord CR
1299    pub name: &'a str,
1300    /// Namespace where the ARecord CR will be created
1301    pub target_namespace: &'a str,
1302    /// DNS record name within the zone (e.g. `"my-svc"`)
1303    pub record_name: &'a str,
1304    /// IPv4 addresses for the record
1305    pub ips: &'a [String],
1306    /// Optional TTL override in seconds
1307    pub ttl: Option<i32>,
1308    /// Logical name of the source cluster (for labels)
1309    pub cluster_name: &'a str,
1310    /// Source Service namespace (for labels)
1311    pub service_namespace: &'a str,
1312    /// Source Service name (for labels)
1313    pub service_name: &'a str,
1314    /// DNS zone name (for labels)
1315    pub zone: &'a str,
1316}
1317
1318/// Builds the ARecord CR that Scout will create for a `LoadBalancer` Service.
1319pub fn build_service_arecord(params: ServiceARecordParams<'_>) -> ARecord {
1320    let mut labels = BTreeMap::new();
1321    labels.insert(
1322        LABEL_MANAGED_BY.to_string(),
1323        LABEL_MANAGED_BY_SCOUT.to_string(),
1324    );
1325    labels.insert(
1326        LABEL_SOURCE_CLUSTER.to_string(),
1327        params.cluster_name.to_string(),
1328    );
1329    labels.insert(
1330        LABEL_SOURCE_NAMESPACE.to_string(),
1331        params.service_namespace.to_string(),
1332    );
1333    labels.insert(
1334        LABEL_SOURCE_NAME.to_string(),
1335        params.service_name.to_string(),
1336    );
1337    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1338
1339    let meta = kube::api::ObjectMeta {
1340        name: Some(params.name.to_string()),
1341        namespace: Some(params.target_namespace.to_string()),
1342        labels: Some(labels),
1343        ..Default::default()
1344    };
1345
1346    ARecord {
1347        metadata: meta,
1348        spec: ARecordSpec {
1349            name: params.record_name.to_string(),
1350            ipv4_addresses: params.ips.to_vec(),
1351            ttl: params.ttl,
1352        },
1353        status: None,
1354    }
1355}
1356
1357// ============================================================================
1358// Gateway API (HTTPRoute / TLSRoute) helpers
1359// ============================================================================
1360
1361/// Derives the ARecord CR name for an HTTPRoute.
1362///
1363/// Format: `scout-{cluster}-{namespace}-{route_name}-{hostname_index}`
1364///
1365/// One ARecord per hostname in `spec.hostnames[]`. Index tracks which hostname
1366/// this ARecord is for. Applies the same sanitisation and 253-char truncation
1367/// as Ingress CR names.
1368pub fn httproute_arecord_cr_name(
1369    cluster: &str,
1370    namespace: &str,
1371    route_name: &str,
1372    hostname_index: usize,
1373) -> String {
1374    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1375    let sanitized = sanitize_k8s_name(&raw);
1376    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1377}
1378
1379/// Derives the ARecord CR name for a TLSRoute.
1380///
1381/// Format: `scout-{cluster}-{namespace}-{route_name}-{hostname_index}`
1382///
1383/// One ARecord per hostname in `spec.hostnames[]`. Index tracks which hostname
1384/// this ARecord is for. Applies the same sanitisation and 253-char truncation.
1385pub fn tlsroute_arecord_cr_name(
1386    cluster: &str,
1387    namespace: &str,
1388    route_name: &str,
1389    hostname_index: usize,
1390) -> String {
1391    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1392    let sanitized = sanitize_k8s_name(&raw);
1393    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1394}
1395
1396/// Builds a Kubernetes label selector matching all ARecords created by Scout
1397/// for a specific HTTPRoute.
1398pub fn httproute_arecord_label_selector(
1399    cluster: &str,
1400    namespace: &str,
1401    route_name: &str,
1402) -> String {
1403    format!(
1404        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1405        LABEL_MANAGED_BY,
1406        LABEL_MANAGED_BY_SCOUT,
1407        cluster_key = LABEL_SOURCE_CLUSTER,
1408        ns_key = LABEL_SOURCE_NAMESPACE,
1409        name_key = LABEL_SOURCE_NAME,
1410    )
1411}
1412
1413/// Builds a Kubernetes label selector matching all ARecords created by Scout
1414/// for a specific TLSRoute.
1415pub fn tlsroute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1416    format!(
1417        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1418        LABEL_MANAGED_BY,
1419        LABEL_MANAGED_BY_SCOUT,
1420        cluster_key = LABEL_SOURCE_CLUSTER,
1421        ns_key = LABEL_SOURCE_NAMESPACE,
1422        name_key = LABEL_SOURCE_NAME,
1423    )
1424}
1425
1426/// Derives the ARecord CR name for a TCPRoute.
1427pub fn tcproute_arecord_cr_name(
1428    cluster: &str,
1429    namespace: &str,
1430    route_name: &str,
1431    hostname_index: usize,
1432) -> String {
1433    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{route_name}-{hostname_index}");
1434    let sanitized = sanitize_k8s_name(&raw);
1435    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
1436}
1437
1438/// Builds a Kubernetes label selector matching all ARecords created by Scout
1439/// for a specific TCPRoute.
1440pub fn tcproute_arecord_label_selector(cluster: &str, namespace: &str, route_name: &str) -> String {
1441    format!(
1442        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{name_key}={route_name}",
1443        LABEL_MANAGED_BY,
1444        LABEL_MANAGED_BY_SCOUT,
1445        cluster_key = LABEL_SOURCE_CLUSTER,
1446        ns_key = LABEL_SOURCE_NAMESPACE,
1447        name_key = LABEL_SOURCE_NAME,
1448    )
1449}
1450
1451/// Builds a label selector string matching ARecords for the given HTTPRoute that
1452/// belong to **any cluster other than `current_cluster`**.
1453///
1454/// Used to detect and clean up stale ARecords left behind when scout is
1455/// restarted with a different `--cluster-name`.
1456pub fn stale_httproute_arecord_label_selector(
1457    current_cluster: &str,
1458    namespace: &str,
1459    route_name: &str,
1460) -> String {
1461    format!(
1462        "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1463        LABEL_MANAGED_BY,
1464        LABEL_MANAGED_BY_SCOUT,
1465        cluster_key = LABEL_SOURCE_CLUSTER,
1466        ns_key = LABEL_SOURCE_NAMESPACE,
1467        name_key = LABEL_SOURCE_NAME,
1468    )
1469}
1470
1471/// Builds a label selector string matching ARecords for the given TLSRoute that
1472/// belong to **any cluster other than `current_cluster`**.
1473pub fn stale_tlsroute_arecord_label_selector(
1474    current_cluster: &str,
1475    namespace: &str,
1476    route_name: &str,
1477) -> String {
1478    format!(
1479        "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1480        LABEL_MANAGED_BY,
1481        LABEL_MANAGED_BY_SCOUT,
1482        cluster_key = LABEL_SOURCE_CLUSTER,
1483        ns_key = LABEL_SOURCE_NAMESPACE,
1484        name_key = LABEL_SOURCE_NAME,
1485    )
1486}
1487
1488/// Builds a label selector string matching ARecords for the given TCPRoute that
1489/// belong to **any cluster other than `current_cluster`**.
1490pub fn stale_tcproute_arecord_label_selector(
1491    current_cluster: &str,
1492    namespace: &str,
1493    route_name: &str,
1494) -> String {
1495    format!(
1496        "{}={},{cluster_key}!={current_cluster},{ns_key}={namespace},{name_key}={route_name}",
1497        LABEL_MANAGED_BY,
1498        LABEL_MANAGED_BY_SCOUT,
1499        cluster_key = LABEL_SOURCE_CLUSTER,
1500        ns_key = LABEL_SOURCE_NAMESPACE,
1501        name_key = LABEL_SOURCE_NAME,
1502    )
1503}
1504
1505/// Parameters for building an ARecord CR from an HTTPRoute.
1506pub struct HTTPRouteARecordParams<'a> {
1507    /// Kubernetes resource name for the ARecord CR
1508    pub name: &'a str,
1509    /// Namespace where the ARecord CR will be created
1510    pub target_namespace: &'a str,
1511    /// DNS record name within the zone (e.g. `"api"`)
1512    pub record_name: &'a str,
1513    /// IPv4 addresses for the record
1514    pub ips: &'a [String],
1515    /// Optional TTL override in seconds
1516    pub ttl: Option<i32>,
1517    /// Logical name of the source cluster (for labels)
1518    pub cluster_name: &'a str,
1519    /// Source HTTPRoute namespace (for labels)
1520    pub route_namespace: &'a str,
1521    /// Source HTTPRoute name (for labels)
1522    pub route_name: &'a str,
1523    /// DNS zone name (for labels)
1524    pub zone: &'a str,
1525}
1526
1527/// Builds the ARecord CR that Scout will create for an HTTPRoute.
1528pub fn build_httproute_arecord(params: HTTPRouteARecordParams<'_>) -> ARecord {
1529    let mut labels = BTreeMap::new();
1530    labels.insert(
1531        LABEL_MANAGED_BY.to_string(),
1532        LABEL_MANAGED_BY_SCOUT.to_string(),
1533    );
1534    labels.insert(
1535        LABEL_SOURCE_CLUSTER.to_string(),
1536        params.cluster_name.to_string(),
1537    );
1538    labels.insert(
1539        LABEL_SOURCE_NAMESPACE.to_string(),
1540        params.route_namespace.to_string(),
1541    );
1542    labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1543    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1544
1545    let meta = kube::api::ObjectMeta {
1546        name: Some(params.name.to_string()),
1547        namespace: Some(params.target_namespace.to_string()),
1548        labels: Some(labels),
1549        ..Default::default()
1550    };
1551
1552    ARecord {
1553        metadata: meta,
1554        spec: ARecordSpec {
1555            name: params.record_name.to_string(),
1556            ipv4_addresses: params.ips.to_vec(),
1557            ttl: params.ttl,
1558        },
1559        status: None,
1560    }
1561}
1562
1563/// Parameters for building an ARecord CR from a TLSRoute.
1564pub struct TLSRouteARecordParams<'a> {
1565    /// Kubernetes resource name for the ARecord CR
1566    pub name: &'a str,
1567    /// Namespace where the ARecord CR will be created
1568    pub target_namespace: &'a str,
1569    /// DNS record name within the zone (e.g. `"secure"`)
1570    pub record_name: &'a str,
1571    /// IPv4 addresses for the record
1572    pub ips: &'a [String],
1573    /// Optional TTL override in seconds
1574    pub ttl: Option<i32>,
1575    /// Logical name of the source cluster (for labels)
1576    pub cluster_name: &'a str,
1577    /// Source TLSRoute namespace (for labels)
1578    pub route_namespace: &'a str,
1579    /// Source TLSRoute name (for labels)
1580    pub route_name: &'a str,
1581    /// DNS zone name (for labels)
1582    pub zone: &'a str,
1583}
1584
1585/// Builds the ARecord CR that Scout will create for a TLSRoute.
1586pub fn build_tlsroute_arecord(params: TLSRouteARecordParams<'_>) -> ARecord {
1587    let mut labels = BTreeMap::new();
1588    labels.insert(
1589        LABEL_MANAGED_BY.to_string(),
1590        LABEL_MANAGED_BY_SCOUT.to_string(),
1591    );
1592    labels.insert(
1593        LABEL_SOURCE_CLUSTER.to_string(),
1594        params.cluster_name.to_string(),
1595    );
1596    labels.insert(
1597        LABEL_SOURCE_NAMESPACE.to_string(),
1598        params.route_namespace.to_string(),
1599    );
1600    labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1601    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1602
1603    let meta = kube::api::ObjectMeta {
1604        name: Some(params.name.to_string()),
1605        namespace: Some(params.target_namespace.to_string()),
1606        labels: Some(labels),
1607        ..Default::default()
1608    };
1609
1610    ARecord {
1611        metadata: meta,
1612        spec: ARecordSpec {
1613            name: params.record_name.to_string(),
1614            ipv4_addresses: params.ips.to_vec(),
1615            ttl: params.ttl,
1616        },
1617        status: None,
1618    }
1619}
1620
1621/// Parameters for building an ARecord CR from a TCPRoute.
1622pub struct TCPRouteARecordParams<'a> {
1623    /// Kubernetes resource name for the ARecord CR
1624    pub name: &'a str,
1625    /// Namespace where the ARecord CR will be created
1626    pub target_namespace: &'a str,
1627    /// DNS record name within the zone (e.g. `"db"`)
1628    pub record_name: &'a str,
1629    /// IPv4 addresses for the record
1630    pub ips: &'a [String],
1631    /// Optional TTL override in seconds
1632    pub ttl: Option<i32>,
1633    /// Logical name of the source cluster (for labels)
1634    pub cluster_name: &'a str,
1635    /// Source TCPRoute namespace (for labels)
1636    pub route_namespace: &'a str,
1637    /// Source TCPRoute name (for labels)
1638    pub route_name: &'a str,
1639    /// DNS zone name (for labels)
1640    pub zone: &'a str,
1641}
1642
1643/// Builds the ARecord CR that Scout will create for a TCPRoute.
1644pub fn build_tcproute_arecord(params: TCPRouteARecordParams<'_>) -> ARecord {
1645    let mut labels = BTreeMap::new();
1646    labels.insert(
1647        LABEL_MANAGED_BY.to_string(),
1648        LABEL_MANAGED_BY_SCOUT.to_string(),
1649    );
1650    labels.insert(
1651        LABEL_SOURCE_CLUSTER.to_string(),
1652        params.cluster_name.to_string(),
1653    );
1654    labels.insert(
1655        LABEL_SOURCE_NAMESPACE.to_string(),
1656        params.route_namespace.to_string(),
1657    );
1658    labels.insert(LABEL_SOURCE_NAME.to_string(), params.route_name.to_string());
1659    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
1660
1661    let meta = kube::api::ObjectMeta {
1662        name: Some(params.name.to_string()),
1663        namespace: Some(params.target_namespace.to_string()),
1664        labels: Some(labels),
1665        ..Default::default()
1666    };
1667
1668    ARecord {
1669        metadata: meta,
1670        spec: ARecordSpec {
1671            name: params.record_name.to_string(),
1672            ipv4_addresses: params.ips.to_vec(),
1673            ttl: params.ttl,
1674        },
1675        status: None,
1676    }
1677}
1678
1679// ============================================================================
1680// Finalizer helpers (async — require Kubernetes API access)
1681// ============================================================================
1682
1683/// Adds the Scout finalizer to an Ingress.
1684///
1685/// Merges the finalizer into the existing list so any other finalizers
1686/// already present are preserved.
1687async fn add_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1688    let namespace = ingress.namespace().unwrap_or_default();
1689    let name = ingress.name_any();
1690    let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1691
1692    let mut finalizers = ingress.metadata.finalizers.clone().unwrap_or_default();
1693    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1694        finalizers.push(FINALIZER_SCOUT.to_string());
1695    }
1696
1697    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1698    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1699        .await?;
1700    Ok(())
1701}
1702
1703/// Removes the Scout finalizer from an Ingress.
1704///
1705/// Preserves any other finalizers that may be present.
1706async fn remove_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
1707    let namespace = ingress.namespace().unwrap_or_default();
1708    let name = ingress.name_any();
1709    let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
1710
1711    let finalizers: Vec<String> = ingress
1712        .metadata
1713        .finalizers
1714        .clone()
1715        .unwrap_or_default()
1716        .into_iter()
1717        .filter(|f| f != FINALIZER_SCOUT)
1718        .collect();
1719
1720    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1721    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1722        .await?;
1723    Ok(())
1724}
1725
1726/// Adds the Scout finalizer to a Service.
1727async fn add_finalizer_to_service(client: &Client, svc: &Service) -> Result<()> {
1728    let namespace = svc.namespace().unwrap_or_default();
1729    let name = svc.name_any();
1730    let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
1731
1732    let mut finalizers = svc.metadata.finalizers.clone().unwrap_or_default();
1733    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1734        finalizers.push(FINALIZER_SCOUT.to_string());
1735    }
1736
1737    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1738    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1739        .await?;
1740    Ok(())
1741}
1742
1743/// Removes the Scout finalizer from a Service.
1744async fn remove_finalizer_from_service(client: &Client, svc: &Service) -> Result<()> {
1745    let namespace = svc.namespace().unwrap_or_default();
1746    let name = svc.name_any();
1747    let api: Api<Service> = Api::namespaced(client.clone(), &namespace);
1748
1749    let finalizers: Vec<String> = svc
1750        .metadata
1751        .finalizers
1752        .clone()
1753        .unwrap_or_default()
1754        .into_iter()
1755        .filter(|f| f != FINALIZER_SCOUT)
1756        .collect();
1757
1758    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1759    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1760        .await?;
1761    Ok(())
1762}
1763
1764/// Adds the Scout finalizer to an HTTPRoute.
1765async fn add_finalizer_to_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
1766    let namespace = route.namespace().unwrap_or_default();
1767    let name = route.name_any();
1768    let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
1769
1770    let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1771    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1772        finalizers.push(FINALIZER_SCOUT.to_string());
1773    }
1774
1775    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1776    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1777        .await?;
1778    Ok(())
1779}
1780
1781/// Removes the Scout finalizer from an HTTPRoute.
1782async fn remove_finalizer_from_httproute(client: &Client, route: &HTTPRoute) -> Result<()> {
1783    let namespace = route.namespace().unwrap_or_default();
1784    let name = route.name_any();
1785    let api: Api<HTTPRoute> = Api::namespaced(client.clone(), &namespace);
1786
1787    let finalizers: Vec<String> = route
1788        .metadata
1789        .finalizers
1790        .clone()
1791        .unwrap_or_default()
1792        .into_iter()
1793        .filter(|f| f != FINALIZER_SCOUT)
1794        .collect();
1795
1796    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1797    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1798        .await?;
1799    Ok(())
1800}
1801
1802/// Adds the Scout finalizer to a TLSRoute.
1803async fn add_finalizer_to_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
1804    let namespace = route.namespace().unwrap_or_default();
1805    let name = route.name_any();
1806    let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
1807
1808    let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1809    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1810        finalizers.push(FINALIZER_SCOUT.to_string());
1811    }
1812
1813    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1814    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1815        .await?;
1816    Ok(())
1817}
1818
1819/// Removes the Scout finalizer from a TLSRoute.
1820async fn remove_finalizer_from_tlsroute(client: &Client, route: &TLSRoute) -> Result<()> {
1821    let namespace = route.namespace().unwrap_or_default();
1822    let name = route.name_any();
1823    let api: Api<TLSRoute> = Api::namespaced(client.clone(), &namespace);
1824
1825    let finalizers: Vec<String> = route
1826        .metadata
1827        .finalizers
1828        .clone()
1829        .unwrap_or_default()
1830        .into_iter()
1831        .filter(|f| f != FINALIZER_SCOUT)
1832        .collect();
1833
1834    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1835    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1836        .await?;
1837    Ok(())
1838}
1839
1840/// Adds the Scout finalizer to a TCPRoute.
1841async fn add_finalizer_to_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
1842    let namespace = route.namespace().unwrap_or_default();
1843    let name = route.name_any();
1844    let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
1845
1846    let mut finalizers = route.metadata.finalizers.clone().unwrap_or_default();
1847    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
1848        finalizers.push(FINALIZER_SCOUT.to_string());
1849    }
1850
1851    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1852    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1853        .await?;
1854    Ok(())
1855}
1856
1857/// Removes the Scout finalizer from a TCPRoute.
1858async fn remove_finalizer_from_tcproute(client: &Client, route: &TCPRoute) -> Result<()> {
1859    let namespace = route.namespace().unwrap_or_default();
1860    let name = route.name_any();
1861    let api: Api<TCPRoute> = Api::namespaced(client.clone(), &namespace);
1862
1863    let finalizers: Vec<String> = route
1864        .metadata
1865        .finalizers
1866        .clone()
1867        .unwrap_or_default()
1868        .into_iter()
1869        .filter(|f| f != FINALIZER_SCOUT)
1870        .collect();
1871
1872    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
1873    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
1874        .await?;
1875    Ok(())
1876}
1877
1878/// Deletes all ARecords in `target_namespace` that were created by Scout for
1879/// the given Ingress (identified by cluster + namespace + ingress name labels).
1880///
1881/// Must be called with the **remote** client so it targets the cluster where
1882/// ARecords live (which may differ from the local cluster in Phase 2+).
1883async fn delete_arecords_for_ingress(
1884    remote_client: &Client,
1885    target_namespace: &str,
1886    cluster: &str,
1887    ingress_namespace: &str,
1888    ingress_name: &str,
1889) -> Result<()> {
1890    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1891    let selector = arecord_label_selector(cluster, ingress_namespace, ingress_name);
1892    let lp = ListParams::default().labels(&selector);
1893
1894    let arecords = api.list(&lp).await?;
1895    for ar in arecords.items {
1896        let ar_name = ar.name_any();
1897        api.delete(&ar_name, &DeleteParams::default()).await?;
1898        info!(
1899            arecord = %ar_name,
1900            ingress = %ingress_name,
1901            ns = %ingress_namespace,
1902            "Deleted ARecord during Ingress cleanup"
1903        );
1904    }
1905    Ok(())
1906}
1907
1908/// Deletes all ARecords in `target_namespace` that were created by Scout for
1909/// the given Ingress by a **previous** cluster name — i.e., any ARecord whose
1910/// `source-cluster` label differs from `current_cluster`.
1911///
1912/// This is called after every successful reconcile so that a scout restarted
1913/// with a new `--cluster-name` automatically cleans up the orphaned records
1914/// it left behind under the old name.
1915async fn delete_stale_cluster_arecords(
1916    remote_client: &Client,
1917    target_namespace: &str,
1918    current_cluster: &str,
1919    ingress_namespace: &str,
1920    ingress_name: &str,
1921) -> Result<()> {
1922    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1923    let selector = stale_arecord_label_selector(current_cluster, ingress_namespace, ingress_name);
1924    let lp = ListParams::default().labels(&selector);
1925
1926    let arecords = api.list(&lp).await?;
1927    for ar in arecords.items {
1928        let ar_name = ar.name_any();
1929        let old_cluster = ar
1930            .metadata
1931            .labels
1932            .as_ref()
1933            .and_then(|l| l.get(LABEL_SOURCE_CLUSTER))
1934            .map(String::as_str)
1935            .unwrap_or("unknown");
1936        api.delete(&ar_name, &DeleteParams::default()).await?;
1937        info!(
1938            arecord = %ar_name,
1939            old_cluster = %old_cluster,
1940            new_cluster = %current_cluster,
1941            ingress = %ingress_name,
1942            ns = %ingress_namespace,
1943            "Deleted stale ARecord after cluster-name change"
1944        );
1945    }
1946    Ok(())
1947}
1948
1949/// Deletes all ARecords in `target_namespace` that Scout created for the given Service.
1950///
1951/// Called during Service deletion and opt-out annotation removal.
1952async fn delete_arecords_for_service(
1953    remote_client: &Client,
1954    target_namespace: &str,
1955    cluster: &str,
1956    svc_namespace: &str,
1957    svc_name: &str,
1958) -> Result<()> {
1959    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1960    let selector = service_arecord_label_selector(cluster, svc_namespace, svc_name);
1961    let lp = ListParams::default().labels(&selector);
1962
1963    let arecords = api.list(&lp).await?;
1964    for ar in arecords.items {
1965        let ar_name = ar.name_any();
1966        api.delete(&ar_name, &DeleteParams::default()).await?;
1967        info!(
1968            arecord = %ar_name,
1969            service = %svc_name,
1970            ns = %svc_namespace,
1971            "Deleted ARecord during Service cleanup"
1972        );
1973    }
1974    Ok(())
1975}
1976
1977/// Deletes all ARecords created by Scout for a specific HTTPRoute.
1978async fn delete_arecords_for_httproute(
1979    remote_client: &Client,
1980    target_namespace: &str,
1981    cluster: &str,
1982    route_namespace: &str,
1983    route_name: &str,
1984) -> Result<()> {
1985    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
1986    let selector = httproute_arecord_label_selector(cluster, route_namespace, route_name);
1987    let lp = ListParams::default().labels(&selector);
1988
1989    let arecords = api.list(&lp).await?;
1990    for ar in arecords.items {
1991        let ar_name = ar.name_any();
1992        api.delete(&ar_name, &DeleteParams::default()).await?;
1993        info!(
1994            arecord = %ar_name,
1995            httproute = %route_name,
1996            ns = %route_namespace,
1997            "Deleted ARecord during HTTPRoute cleanup"
1998        );
1999    }
2000    Ok(())
2001}
2002
2003/// Deletes all ARecords created by Scout for a specific TLSRoute.
2004async fn delete_arecords_for_tlsroute(
2005    remote_client: &Client,
2006    target_namespace: &str,
2007    cluster: &str,
2008    route_namespace: &str,
2009    route_name: &str,
2010) -> Result<()> {
2011    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2012    let selector = tlsroute_arecord_label_selector(cluster, route_namespace, route_name);
2013    let lp = ListParams::default().labels(&selector);
2014
2015    let arecords = api.list(&lp).await?;
2016    for ar in arecords.items {
2017        let ar_name = ar.name_any();
2018        api.delete(&ar_name, &DeleteParams::default()).await?;
2019        info!(
2020            arecord = %ar_name,
2021            tlsroute = %route_name,
2022            ns = %route_namespace,
2023            "Deleted ARecord during TLSRoute cleanup"
2024        );
2025    }
2026    Ok(())
2027}
2028
2029/// Deletes stale ARecords for an HTTPRoute from previous cluster names.
2030async fn delete_stale_cluster_httproute_arecords(
2031    remote_client: &Client,
2032    target_namespace: &str,
2033    current_cluster: &str,
2034    route_namespace: &str,
2035    route_name: &str,
2036) -> Result<()> {
2037    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2038    let selector =
2039        stale_httproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2040    let lp = ListParams::default().labels(&selector);
2041
2042    let arecords = api.list(&lp).await?;
2043    for ar in arecords.items {
2044        let ar_name = ar.name_any();
2045        api.delete(&ar_name, &DeleteParams::default()).await?;
2046        info!(
2047            arecord = %ar_name,
2048            httproute = %route_name,
2049            "Deleted stale HTTPRoute ARecord from previous cluster name"
2050        );
2051    }
2052    Ok(())
2053}
2054
2055/// Deletes stale ARecords for a TLSRoute from previous cluster names.
2056async fn delete_stale_cluster_tlsroute_arecords(
2057    remote_client: &Client,
2058    target_namespace: &str,
2059    current_cluster: &str,
2060    route_namespace: &str,
2061    route_name: &str,
2062) -> Result<()> {
2063    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2064    let selector =
2065        stale_tlsroute_arecord_label_selector(current_cluster, route_namespace, route_name);
2066    let lp = ListParams::default().labels(&selector);
2067
2068    let arecords = api.list(&lp).await?;
2069    for ar in arecords.items {
2070        let ar_name = ar.name_any();
2071        api.delete(&ar_name, &DeleteParams::default()).await?;
2072        info!(
2073            arecord = %ar_name,
2074            tlsroute = %route_name,
2075            "Deleted stale TLSRoute ARecord from previous cluster name"
2076        );
2077    }
2078    Ok(())
2079}
2080
2081/// Deletes all ARecords created by Scout for a specific TCPRoute.
2082async fn delete_arecords_for_tcproute(
2083    remote_client: &Client,
2084    target_namespace: &str,
2085    cluster: &str,
2086    route_namespace: &str,
2087    route_name: &str,
2088) -> Result<()> {
2089    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2090    let selector = tcproute_arecord_label_selector(cluster, route_namespace, route_name);
2091    let lp = ListParams::default().labels(&selector);
2092
2093    let arecords = api.list(&lp).await?;
2094    for ar in arecords.items {
2095        let ar_name = ar.name_any();
2096        api.delete(&ar_name, &DeleteParams::default()).await?;
2097        info!(
2098            arecord = %ar_name,
2099            tcproute = %route_name,
2100            ns = %route_namespace,
2101            "Deleted ARecord during TCPRoute cleanup"
2102        );
2103    }
2104    Ok(())
2105}
2106
2107/// Deletes stale ARecords for a TCPRoute from previous cluster names.
2108async fn delete_stale_cluster_tcproute_arecords(
2109    remote_client: &Client,
2110    target_namespace: &str,
2111    current_cluster: &str,
2112    route_namespace: &str,
2113    route_name: &str,
2114) -> Result<()> {
2115    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
2116    let selector =
2117        stale_tcproute_arecord_label_selector(current_cluster, route_namespace, route_name);
2118    let lp = ListParams::default().labels(&selector);
2119
2120    let arecords = api.list(&lp).await?;
2121    for ar in arecords.items {
2122        let ar_name = ar.name_any();
2123        api.delete(&ar_name, &DeleteParams::default()).await?;
2124        info!(
2125            arecord = %ar_name,
2126            tcproute = %route_name,
2127            "Deleted stale TCPRoute ARecord from previous cluster name"
2128        );
2129    }
2130    Ok(())
2131}
2132
2133// ============================================================================
2134// Reconciler
2135// ============================================================================
2136
2137/// Reconciles a single Ingress, creating or updating ARecord CRs as needed.
2138///
2139/// Handles the full lifecycle:
2140/// - Adds a finalizer to opted-in Ingresses so deletion is intercepted.
2141/// - On deletion, removes all ARecords Scout created then releases the finalizer.
2142/// - If the opt-in annotation is removed, cleans up ARecords and the finalizer.
2143///
2144/// # Errors
2145///
2146/// Returns an error that will be retried by the controller runtime.
2147async fn reconcile(ingress: Arc<Ingress>, ctx: Arc<ScoutContext>) -> Result<Action, ScoutError> {
2148    let name = ingress.name_any();
2149    let namespace = ingress.namespace().unwrap_or_default();
2150
2151    // Skip excluded namespaces
2152    if ctx.excluded_namespaces.contains(&namespace) {
2153        debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
2154        return Ok(Action::await_change());
2155    }
2156
2157    // Handle Ingress deletion — remove ARecords and release the finalizer
2158    if is_being_deleted(&ingress) {
2159        if has_finalizer(&ingress) {
2160            info!(ingress = %name, ns = %namespace, "Ingress deleting — cleaning up ARecords");
2161            let cleanup: Result<()> = async {
2162                delete_arecords_for_ingress(
2163                    &ctx.remote_client,
2164                    &ctx.target_namespace,
2165                    &ctx.cluster_name,
2166                    &namespace,
2167                    &name,
2168                )
2169                .await?;
2170                delete_stale_cluster_arecords(
2171                    &ctx.remote_client,
2172                    &ctx.target_namespace,
2173                    &ctx.cluster_name,
2174                    &namespace,
2175                    &name,
2176                )
2177                .await
2178            }
2179            .await;
2180            if let Err(e) = cleanup {
2181                if !cleanup_grace_expired(
2182                    ingress.metadata.deletion_timestamp.as_ref(),
2183                    Timestamp::now(),
2184                ) {
2185                    warn!(ingress = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Ingress deletion — retrying within grace period");
2186                    return Ok(Action::requeue(Duration::from_secs(
2187                        SCOUT_ERROR_REQUEUE_SECS,
2188                    )));
2189                }
2190                error!(ingress = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock Ingress deletion; remote ARecords may be orphaned and must be reconciled separately");
2191            }
2192            remove_finalizer(&ctx.client, &ingress)
2193                .await
2194                .map_err(ScoutError::from)?;
2195            info!(ingress = %name, ns = %namespace, "Finalizer removed — Ingress deletion unblocked");
2196        }
2197        return Ok(Action::await_change());
2198    }
2199
2200    let annotations = ingress
2201        .metadata
2202        .annotations
2203        .as_ref()
2204        .cloned()
2205        .unwrap_or_default();
2206
2207    // Guard: opt-in annotation required (scout-enabled: "true" or recordKind: "ARecord")
2208    let namespace_eligible =
2209        source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2210            .await
2211            .map_err(ScoutError::from)?;
2212
2213    if !is_scout_opted_in(&annotations) || !namespace_eligible {
2214        // Annotation may have been removed after a finalizer was added — clean up
2215        if has_finalizer(&ingress) {
2216            info!(ingress = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2217            delete_arecords_for_ingress(
2218                &ctx.remote_client,
2219                &ctx.target_namespace,
2220                &ctx.cluster_name,
2221                &namespace,
2222                &name,
2223            )
2224            .await
2225            .map_err(ScoutError::from)?;
2226            delete_stale_cluster_arecords(
2227                &ctx.remote_client,
2228                &ctx.target_namespace,
2229                &ctx.cluster_name,
2230                &namespace,
2231                &name,
2232            )
2233            .await
2234            .map_err(ScoutError::from)?;
2235            remove_finalizer(&ctx.client, &ingress)
2236                .await
2237                .map_err(ScoutError::from)?;
2238        }
2239        debug!(ingress = %name, ns = %namespace, "No arecord annotation — skipping");
2240        return Ok(Action::await_change());
2241    }
2242
2243    // Ensure our finalizer is present before creating any ARecords.
2244    // Adding the finalizer triggers a re-reconcile; return early to avoid
2245    // doing record creation twice.
2246    if !has_finalizer(&ingress) {
2247        add_finalizer(&ctx.client, &ingress)
2248            .await
2249            .map_err(ScoutError::from)?;
2250        debug!(ingress = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2251        return Ok(Action::await_change());
2252    }
2253
2254    // Guard: zone required (annotation or operator default)
2255    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2256        Some(z) => z,
2257        None => {
2258            warn!(ingress = %name, ns = %namespace, "No DNS zone available (set bindy.firestoned.io/zone annotation or BINDY_SCOUT_DEFAULT_ZONE) — skipping");
2259            return Ok(Action::requeue(Duration::from_secs(
2260                SCOUT_ERROR_REQUEUE_SECS,
2261            )));
2262        }
2263    };
2264
2265    // Guard: a matching DNSZone must exist AND authorize this Ingress's
2266    // namespace (finding H1 — otherwise any tenant's Ingress could publish
2267    // into any zone Scout serves).
2268    match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2269        ZoneAuthz::Authorized => {}
2270        ZoneAuthz::Forbidden => {
2271            warn!(
2272                ingress = %name, ns = %namespace, zone = %zone,
2273                "Ingress namespace not authorized for zone — the DNSZone must live in this \
2274                 namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it \
2275                 (or '*') — skipping"
2276            );
2277            return Ok(Action::requeue(Duration::from_secs(
2278                SCOUT_ERROR_REQUEUE_SECS,
2279            )));
2280        }
2281        ZoneAuthz::NotFound => {
2282            warn!(
2283                ingress = %name, ns = %namespace, zone = %zone,
2284                "Zone not found in DNSZone store — skipping until zone appears"
2285            );
2286            return Ok(Action::requeue(Duration::from_secs(
2287                SCOUT_ERROR_REQUEUE_SECS,
2288            )));
2289        }
2290    }
2291
2292    // Resolve IPs: annotation override → default_ips → LB status
2293    let ips = match resolve_ips(&annotations, &ctx.default_ips, &ingress) {
2294        Some(ips) => ips,
2295        None => {
2296            warn!(ingress = %name, ns = %namespace, "No IP available (no annotation override, no default IPs, no LB status IP) — requeuing");
2297            return Ok(Action::requeue(Duration::from_secs(
2298                SCOUT_ERROR_REQUEUE_SECS,
2299            )));
2300        }
2301    };
2302
2303    // Optional TTL override
2304    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2305
2306    let spec_rules = ingress
2307        .spec
2308        .as_ref()
2309        .and_then(|s| s.rules.as_ref())
2310        .cloned()
2311        .unwrap_or_default();
2312
2313    let arecord_api: Api<ARecord> =
2314        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2315
2316    for (idx, rule) in spec_rules.iter().enumerate() {
2317        let host = match rule.host.as_deref() {
2318            Some(h) if !h.is_empty() => h,
2319            _ => {
2320                debug!(ingress = %name, rule_index = idx, "Ingress rule has no host — skipping");
2321                continue;
2322            }
2323        };
2324
2325        let record_name = match resolve_record_name(&annotations, host, &zone) {
2326            Ok(n) => n,
2327            Err(e) => {
2328                warn!(ingress = %name, host = %host, zone = %zone, error = %e, "Host does not belong to zone — skipping rule");
2329                continue;
2330            }
2331        };
2332
2333        let cr_name = arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
2334        let arecord = build_arecord(ARecordParams {
2335            name: &cr_name,
2336            target_namespace: &ctx.target_namespace,
2337            record_name: &record_name,
2338            ips: &ips,
2339            ttl,
2340            cluster_name: &ctx.cluster_name,
2341            ingress_namespace: &namespace,
2342            ingress_name: &name,
2343            zone: &zone,
2344        });
2345
2346        // Server-side apply
2347        let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2348        match arecord_api
2349            .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2350            .await
2351        {
2352            Ok(_) => {
2353                info!(arecord = %cr_name, ingress = %name, host = %host, ips = ?ips, "ARecord created/updated");
2354            }
2355            Err(e) => {
2356                error!(arecord = %cr_name, ingress = %name, error = %e, "Failed to apply ARecord");
2357                return Err(ScoutError::from(anyhow!(
2358                    "Failed to apply ARecord {cr_name}: {e}"
2359                )));
2360            }
2361        }
2362    }
2363
2364    // Clean up any ARecords that were created by a previous cluster name for
2365    // this same Ingress — happens when scout is restarted with a new --cluster-name.
2366    delete_stale_cluster_arecords(
2367        &ctx.remote_client,
2368        &ctx.target_namespace,
2369        &ctx.cluster_name,
2370        &namespace,
2371        &name,
2372    )
2373    .await
2374    .map_err(ScoutError::from)?;
2375
2376    Ok(Action::await_change())
2377}
2378
2379///// Reconciles a single `LoadBalancer` Service, creating or updating an ARecord CR as needed.
2380///
2381/// Mirrors the Ingress reconciler lifecycle:
2382/// - Opts in via `bindy.firestoned.io/scout-enabled: "true"`.
2383/// - Silently skips non-`LoadBalancer` Services (no warning — ClusterIP/NodePort are intra-cluster).
2384/// - Adds a finalizer; on deletion removes the ARecord and releases it.
2385/// - If the opt-in annotation is removed, cleans up the ARecord and finalizer.
2386/// - Re-queues if no external IP is available yet (cloud provider may not have assigned one).
2387///
2388/// # Errors
2389///
2390/// Returns an error that will be retried by the controller runtime.
2391async fn reconcile_service(
2392    svc: Arc<Service>,
2393    ctx: Arc<ScoutContext>,
2394) -> Result<Action, ScoutError> {
2395    let name = svc.name_any();
2396    let namespace = svc.namespace().unwrap_or_default();
2397
2398    if ctx.excluded_namespaces.contains(&namespace) {
2399        debug!(service = %name, ns = %namespace, "Skipping excluded namespace");
2400        return Ok(Action::await_change());
2401    }
2402
2403    // Handle Service deletion — remove ARecord and release the finalizer
2404    if svc.metadata.deletion_timestamp.is_some() {
2405        if svc
2406            .metadata
2407            .finalizers
2408            .as_ref()
2409            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2410            .unwrap_or(false)
2411        {
2412            info!(service = %name, ns = %namespace, "Service deleting — cleaning up ARecord");
2413            if let Err(e) = delete_arecords_for_service(
2414                &ctx.remote_client,
2415                &ctx.target_namespace,
2416                &ctx.cluster_name,
2417                &namespace,
2418                &name,
2419            )
2420            .await
2421            {
2422                if !cleanup_grace_expired(
2423                    svc.metadata.deletion_timestamp.as_ref(),
2424                    Timestamp::now(),
2425                ) {
2426                    warn!(service = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during Service deletion — retrying within grace period");
2427                    return Ok(Action::requeue(Duration::from_secs(
2428                        SCOUT_ERROR_REQUEUE_SECS,
2429                    )));
2430                }
2431                error!(service = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock Service deletion; remote ARecords may be orphaned and must be reconciled separately");
2432            }
2433            remove_finalizer_from_service(&ctx.client, &svc)
2434                .await
2435                .map_err(ScoutError::from)?;
2436            info!(service = %name, ns = %namespace, "Finalizer removed — Service deletion unblocked");
2437        }
2438        return Ok(Action::await_change());
2439    }
2440
2441    let annotations = svc
2442        .metadata
2443        .annotations
2444        .as_ref()
2445        .cloned()
2446        .unwrap_or_default();
2447
2448    // Guard: opt-in annotation required
2449    let namespace_eligible =
2450        source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2451            .await
2452            .map_err(ScoutError::from)?;
2453
2454    if !is_scout_opted_in(&annotations) || !namespace_eligible {
2455        let has_fin = svc
2456            .metadata
2457            .finalizers
2458            .as_ref()
2459            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2460            .unwrap_or(false);
2461        if has_fin {
2462            info!(service = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecord and finalizer");
2463            delete_arecords_for_service(
2464                &ctx.remote_client,
2465                &ctx.target_namespace,
2466                &ctx.cluster_name,
2467                &namespace,
2468                &name,
2469            )
2470            .await
2471            .map_err(ScoutError::from)?;
2472            remove_finalizer_from_service(&ctx.client, &svc)
2473                .await
2474                .map_err(ScoutError::from)?;
2475        }
2476        debug!(service = %name, ns = %namespace, "No scout-enabled annotation — skipping");
2477        return Ok(Action::await_change());
2478    }
2479
2480    // Guard: only LoadBalancer services have routable external IPs
2481    if !is_loadbalancer_service(&svc) {
2482        debug!(service = %name, ns = %namespace, "Service is not LoadBalancer type — skipping");
2483        return Ok(Action::await_change());
2484    }
2485
2486    // Ensure finalizer before creating any ARecord
2487    let has_fin = svc
2488        .metadata
2489        .finalizers
2490        .as_ref()
2491        .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2492        .unwrap_or(false);
2493    if !has_fin {
2494        add_finalizer_to_service(&ctx.client, &svc)
2495            .await
2496            .map_err(ScoutError::from)?;
2497        debug!(service = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2498        return Ok(Action::await_change());
2499    }
2500
2501    // Guard: zone required
2502    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2503        Some(z) => z,
2504        None => {
2505            warn!(service = %name, ns = %namespace, "No DNS zone available — skipping");
2506            return Ok(Action::requeue(Duration::from_secs(
2507                SCOUT_ERROR_REQUEUE_SECS,
2508            )));
2509        }
2510    };
2511
2512    // Guard: a matching DNSZone must exist AND authorize this Service's
2513    // namespace (finding H1).
2514    match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2515        ZoneAuthz::Authorized => {}
2516        ZoneAuthz::Forbidden => {
2517            warn!(service = %name, ns = %namespace, zone = %zone, "Service namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
2518            return Ok(Action::requeue(Duration::from_secs(
2519                SCOUT_ERROR_REQUEUE_SECS,
2520            )));
2521        }
2522        ZoneAuthz::NotFound => {
2523            warn!(service = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
2524            return Ok(Action::requeue(Duration::from_secs(
2525                SCOUT_ERROR_REQUEUE_SECS,
2526            )));
2527        }
2528    }
2529
2530    // Resolve IPs: annotation (single or comma-separated) → default_ips → LB status
2531    let ips = {
2532        let from_annotation = resolve_ips_from_annotation(&annotations);
2533        let from_defaults = if ctx.default_ips.is_empty() {
2534            None
2535        } else {
2536            Some(ctx.default_ips.clone())
2537        };
2538        let from_lb = resolve_ip_from_service_lb_status(&svc).map(|ip| vec![ip]);
2539
2540        match from_annotation.or(from_defaults).or(from_lb) {
2541            Some(ips) => ips,
2542            None => {
2543                warn!(service = %name, ns = %namespace, "No external IP yet — requeuing in {}s", SCOUT_ERROR_REQUEUE_SECS);
2544                return Ok(Action::requeue(Duration::from_secs(
2545                    SCOUT_ERROR_REQUEUE_SECS,
2546                )));
2547            }
2548        }
2549    };
2550
2551    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2552
2553    // Derive the DNS record name: annotation override → "{service_name}.{zone}" stripped of zone
2554    let fqdn = format!("{name}.{zone}");
2555    let record_name = match resolve_record_name(&annotations, &fqdn, &zone) {
2556        Ok(n) => n,
2557        Err(e) => {
2558            warn!(service = %name, zone = %zone, error = %e, "Cannot derive record name — skipping");
2559            return Ok(Action::requeue(Duration::from_secs(
2560                SCOUT_ERROR_REQUEUE_SECS,
2561            )));
2562        }
2563    };
2564
2565    let cr_name = service_arecord_cr_name(&ctx.cluster_name, &namespace, &name);
2566    let arecord = build_service_arecord(ServiceARecordParams {
2567        name: &cr_name,
2568        target_namespace: &ctx.target_namespace,
2569        record_name: &record_name,
2570        ips: &ips,
2571        ttl,
2572        cluster_name: &ctx.cluster_name,
2573        service_namespace: &namespace,
2574        service_name: &name,
2575        zone: &zone,
2576    });
2577
2578    let arecord_api: Api<ARecord> =
2579        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2580    let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2581    match arecord_api
2582        .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2583        .await
2584    {
2585        Ok(_) => {
2586            info!(arecord = %cr_name, service = %name, ips = ?ips, "ARecord created/updated for Service");
2587        }
2588        Err(e) => {
2589            error!(arecord = %cr_name, service = %name, error = %e, "Failed to apply ARecord for Service");
2590            return Err(ScoutError::from(anyhow!(
2591                "Failed to apply ARecord {cr_name}: {e}"
2592            )));
2593        }
2594    }
2595
2596    Ok(Action::await_change())
2597}
2598
2599/// Error policy for the Service controller: requeue with a fixed backoff.
2600fn service_error_policy(_obj: Arc<Service>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2601    error!(error = %error, "Scout service reconcile error — requeuing");
2602    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2603}
2604
2605/// Error policy: requeue with a fixed backoff on any reconcile error.
2606fn error_policy(_obj: Arc<Ingress>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
2607    error!(error = %error, "Scout reconcile error — requeuing");
2608    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
2609}
2610
2611// ============================================================================
2612// Gateway API (HTTPRoute / TLSRoute) Reconciliation
2613//
2614// Note: HTTPRoute and TLSRoute reconciliation follows the same pattern as
2615// Ingress reconciliation, with these differences:
2616//
2617// 1. HTTPRoute sources: `spec.hostnames[]` (array) instead of `spec.rules[].host`
2618// 2. TLSRoute sources: `spec.hostnames[]` (array) instead of routes with hosts
2619// 3. One ARecord per hostname with index suffix (like Ingress has one per rule)
2620// 4. Zone and IP resolution use the same annotation scheme as Ingress/Service
2621// ============================================================================
2622
2623/// Reconciles a single `HTTPRoute` resource, creating or updating ARecord CRs as needed.
2624///
2625/// Mirrors the Ingress reconciler lifecycle:
2626/// - Opts in via `bindy.firestoned.io/scout-enabled: "true"`.
2627/// - Adds a finalizer; on deletion removes ARecords and releases it.
2628/// - If the opt-in annotation is removed, cleans up ARecords and finalizer.
2629/// - One ARecord created per hostname in `spec.hostnames[]` with an index suffix.
2630/// - Re-queues if zone is not found or no IP is available yet.
2631///
2632/// # Errors
2633///
2634/// Returns `ScoutError` if API calls fail (apply, delete, patch).
2635async fn reconcile_httproute(
2636    route: Arc<HTTPRoute>,
2637    ctx: Arc<ScoutContext>,
2638) -> Result<Action, ScoutError> {
2639    let name = route.name_any();
2640    let namespace = route.namespace().unwrap_or_default();
2641
2642    // Guard: Skip excluded namespaces
2643    if ctx.excluded_namespaces.contains(&namespace) {
2644        debug!(httproute = %name, ns = %namespace, "Skipping excluded namespace");
2645        return Ok(Action::await_change());
2646    }
2647
2648    // Handle HTTPRoute deletion — remove ARecords and release the finalizer
2649    if route.metadata.deletion_timestamp.is_some() {
2650        if route
2651            .metadata
2652            .finalizers
2653            .as_ref()
2654            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2655            .unwrap_or(false)
2656        {
2657            info!(httproute = %name, ns = %namespace, "HTTPRoute deleting — cleaning up ARecords");
2658            let cleanup: Result<()> = async {
2659                delete_arecords_for_httproute(
2660                    &ctx.remote_client,
2661                    &ctx.target_namespace,
2662                    &ctx.cluster_name,
2663                    &namespace,
2664                    &name,
2665                )
2666                .await?;
2667                delete_stale_cluster_httproute_arecords(
2668                    &ctx.remote_client,
2669                    &ctx.target_namespace,
2670                    &ctx.cluster_name,
2671                    &namespace,
2672                    &name,
2673                )
2674                .await
2675            }
2676            .await;
2677            if let Err(e) = cleanup {
2678                if !cleanup_grace_expired(
2679                    route.metadata.deletion_timestamp.as_ref(),
2680                    Timestamp::now(),
2681                ) {
2682                    warn!(httproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during HTTPRoute deletion — retrying within grace period");
2683                    return Ok(Action::requeue(Duration::from_secs(
2684                        SCOUT_ERROR_REQUEUE_SECS,
2685                    )));
2686                }
2687                error!(httproute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock HTTPRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
2688            }
2689            remove_finalizer_from_httproute(&ctx.client, &route)
2690                .await
2691                .map_err(ScoutError::from)?;
2692            info!(httproute = %name, ns = %namespace, "Finalizer removed — HTTPRoute deletion unblocked");
2693        }
2694        return Ok(Action::await_change());
2695    }
2696
2697    let annotations = route
2698        .metadata
2699        .annotations
2700        .as_ref()
2701        .cloned()
2702        .unwrap_or_default();
2703
2704    // Guard: opt-in annotation required
2705    let namespace_eligible =
2706        source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
2707            .await
2708            .map_err(ScoutError::from)?;
2709
2710    if !is_scout_opted_in(&annotations) || !namespace_eligible {
2711        let has_fin = route
2712            .metadata
2713            .finalizers
2714            .as_ref()
2715            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2716            .unwrap_or(false);
2717        if has_fin {
2718            info!(httproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2719            delete_arecords_for_httproute(
2720                &ctx.remote_client,
2721                &ctx.target_namespace,
2722                &ctx.cluster_name,
2723                &namespace,
2724                &name,
2725            )
2726            .await
2727            .map_err(ScoutError::from)?;
2728            delete_stale_cluster_httproute_arecords(
2729                &ctx.remote_client,
2730                &ctx.target_namespace,
2731                &ctx.cluster_name,
2732                &namespace,
2733                &name,
2734            )
2735            .await
2736            .map_err(ScoutError::from)?;
2737            remove_finalizer_from_httproute(&ctx.client, &route)
2738                .await
2739                .map_err(ScoutError::from)?;
2740        }
2741        debug!(httproute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
2742        return Ok(Action::await_change());
2743    }
2744
2745    // Ensure finalizer before creating any ARecord
2746    let has_fin = route
2747        .metadata
2748        .finalizers
2749        .as_ref()
2750        .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
2751        .unwrap_or(false);
2752    if !has_fin {
2753        add_finalizer_to_httproute(&ctx.client, &route)
2754            .await
2755            .map_err(ScoutError::from)?;
2756        debug!(httproute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
2757        return Ok(Action::await_change());
2758    }
2759
2760    // Guard: zone required
2761    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
2762        Some(z) => z,
2763        None => {
2764            warn!(httproute = %name, ns = %namespace, "No DNS zone available — skipping");
2765            return Ok(Action::requeue(Duration::from_secs(
2766                SCOUT_ERROR_REQUEUE_SECS,
2767            )));
2768        }
2769    };
2770
2771    // Guard: a matching DNSZone must exist AND authorize this HTTPRoute's
2772    // namespace (finding H1).
2773    match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
2774        ZoneAuthz::Authorized => {}
2775        ZoneAuthz::Forbidden => {
2776            warn!(httproute = %name, ns = %namespace, zone = %zone, "HTTPRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
2777            return Ok(Action::requeue(Duration::from_secs(
2778                SCOUT_ERROR_REQUEUE_SECS,
2779            )));
2780        }
2781        ZoneAuthz::NotFound => {
2782            warn!(httproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
2783            return Ok(Action::requeue(Duration::from_secs(
2784                SCOUT_ERROR_REQUEUE_SECS,
2785            )));
2786        }
2787    }
2788
2789    // Resolve IPs: annotation → gateway chain (parentRefs → Gateway → LB Service)
2790    // → default_ips → no routable IP = requeue
2791    let ips = {
2792        let from_annotation = resolve_ips_from_annotation(&annotations);
2793        let from_gateway = if from_annotation.is_some() {
2794            None
2795        } else {
2796            let parent_refs = route
2797                .spec
2798                .as_ref()
2799                .and_then(|s| s.parent_refs.as_ref())
2800                .cloned()
2801                .unwrap_or_default();
2802            resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
2803                .await
2804        };
2805        let from_defaults = if ctx.default_ips.is_empty() {
2806            None
2807        } else {
2808            Some(ctx.default_ips.clone())
2809        };
2810
2811        match from_annotation.or(from_gateway).or(from_defaults) {
2812            Some(ips) => ips,
2813            None => {
2814                warn!(httproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
2815                return Ok(Action::requeue(Duration::from_secs(
2816                    SCOUT_ERROR_REQUEUE_SECS,
2817                )));
2818            }
2819        }
2820    };
2821
2822    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
2823
2824    // Extract hostnames from spec.hostnames[]
2825    let hostnames = route
2826        .spec
2827        .as_ref()
2828        .and_then(|s| s.hostnames.as_ref())
2829        .cloned()
2830        .unwrap_or_default();
2831
2832    let arecord_api: Api<ARecord> =
2833        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
2834
2835    for (idx, hostname) in hostnames.iter().enumerate() {
2836        if hostname.is_empty() {
2837            debug!(httproute = %name, hostname_index = idx, "HTTPRoute hostname is empty — skipping");
2838            continue;
2839        }
2840
2841        let record_name = match resolve_record_name(&annotations, hostname, &zone) {
2842            Ok(n) => n,
2843            Err(e) => {
2844                warn!(httproute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
2845                continue;
2846            }
2847        };
2848
2849        let cr_name = httproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
2850        let arecord = build_httproute_arecord(HTTPRouteARecordParams {
2851            name: &cr_name,
2852            target_namespace: &ctx.target_namespace,
2853            record_name: &record_name,
2854            ips: &ips,
2855            ttl,
2856            cluster_name: &ctx.cluster_name,
2857            route_namespace: &namespace,
2858            route_name: &name,
2859            zone: &zone,
2860        });
2861
2862        // Server-side apply
2863        let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
2864        match arecord_api
2865            .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
2866            .await
2867        {
2868            Ok(_) => {
2869                info!(arecord = %cr_name, httproute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for HTTPRoute");
2870            }
2871            Err(e) => {
2872                error!(arecord = %cr_name, httproute = %name, error = %e, "Failed to apply ARecord for HTTPRoute");
2873                return Err(ScoutError::from(anyhow!(
2874                    "Failed to apply ARecord {cr_name}: {e}"
2875                )));
2876            }
2877        }
2878    }
2879
2880    // Clean up stale ARecords from old cluster names
2881    delete_stale_cluster_httproute_arecords(
2882        &ctx.remote_client,
2883        &ctx.target_namespace,
2884        &ctx.cluster_name,
2885        &namespace,
2886        &name,
2887    )
2888    .await
2889    .map_err(ScoutError::from)?;
2890
2891    Ok(Action::await_change())
2892}
2893
2894/// Reconciles a single `TLSRoute` resource, creating or updating ARecord CRs as needed.
2895///
2896/// Identical to HTTPRoute reconciliation: both resources have `spec.hostnames[]`
2897/// and use the same annotation/IP resolution scheme.
2898///
2899/// # Errors
2900///
2901/// Returns `ScoutError` if API calls fail.
2902async fn reconcile_tlsroute(
2903    route: Arc<TLSRoute>,
2904    ctx: Arc<ScoutContext>,
2905) -> Result<Action, ScoutError> {
2906    let name = route.name_any();
2907    let namespace = route.namespace().unwrap_or_default();
2908
2909    // Guard: Skip excluded namespaces
2910    if ctx.excluded_namespaces.contains(&namespace) {
2911        debug!(tlsroute = %name, ns = %namespace, "Skipping excluded namespace");
2912        return Ok(Action::await_change());
2913    }
2914
2915    // Handle TLSRoute 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!(tlsroute = %name, ns = %namespace, "TLSRoute deleting — cleaning up ARecords");
2925            let cleanup: Result<()> = async {
2926                delete_arecords_for_tlsroute(
2927                    &ctx.remote_client,
2928                    &ctx.target_namespace,
2929                    &ctx.cluster_name,
2930                    &namespace,
2931                    &name,
2932                )
2933                .await?;
2934                delete_stale_cluster_tlsroute_arecords(
2935                    &ctx.remote_client,
2936                    &ctx.target_namespace,
2937                    &ctx.cluster_name,
2938                    &namespace,
2939                    &name,
2940                )
2941                .await
2942            }
2943            .await;
2944            if let Err(e) = cleanup {
2945                if !cleanup_grace_expired(
2946                    route.metadata.deletion_timestamp.as_ref(),
2947                    Timestamp::now(),
2948                ) {
2949                    warn!(tlsroute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TLSRoute deletion — retrying within grace period");
2950                    return Ok(Action::requeue(Duration::from_secs(
2951                        SCOUT_ERROR_REQUEUE_SECS,
2952                    )));
2953                }
2954                error!(tlsroute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock TLSRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
2955            }
2956            remove_finalizer_from_tlsroute(&ctx.client, &route)
2957                .await
2958                .map_err(ScoutError::from)?;
2959            info!(tlsroute = %name, ns = %namespace, "Finalizer removed — TLSRoute deletion unblocked");
2960        }
2961        return Ok(Action::await_change());
2962    }
2963
2964    let annotations = route
2965        .metadata
2966        .annotations
2967        .as_ref()
2968        .cloned()
2969        .unwrap_or_default();
2970
2971    // 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!(tlsroute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
2986            delete_arecords_for_tlsroute(
2987                &ctx.remote_client,
2988                &ctx.target_namespace,
2989                &ctx.cluster_name,
2990                &namespace,
2991                &name,
2992            )
2993            .await
2994            .map_err(ScoutError::from)?;
2995            delete_stale_cluster_tlsroute_arecords(
2996                &ctx.remote_client,
2997                &ctx.target_namespace,
2998                &ctx.cluster_name,
2999                &namespace,
3000                &name,
3001            )
3002            .await
3003            .map_err(ScoutError::from)?;
3004            remove_finalizer_from_tlsroute(&ctx.client, &route)
3005                .await
3006                .map_err(ScoutError::from)?;
3007        }
3008        debug!(tlsroute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3009        return Ok(Action::await_change());
3010    }
3011
3012    // 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_tlsroute(&ctx.client, &route)
3021            .await
3022            .map_err(ScoutError::from)?;
3023        debug!(tlsroute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3024        return Ok(Action::await_change());
3025    }
3026
3027    // Guard: zone required
3028    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3029        Some(z) => z,
3030        None => {
3031            warn!(tlsroute = %name, ns = %namespace, "No DNS zone available — skipping");
3032            return Ok(Action::requeue(Duration::from_secs(
3033                SCOUT_ERROR_REQUEUE_SECS,
3034            )));
3035        }
3036    };
3037
3038    // Guard: a matching DNSZone must exist AND authorize this TLSRoute's
3039    // namespace (finding H1).
3040    match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
3041        ZoneAuthz::Authorized => {}
3042        ZoneAuthz::Forbidden => {
3043            warn!(tlsroute = %name, ns = %namespace, zone = %zone, "TLSRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
3044            return Ok(Action::requeue(Duration::from_secs(
3045                SCOUT_ERROR_REQUEUE_SECS,
3046            )));
3047        }
3048        ZoneAuthz::NotFound => {
3049            warn!(tlsroute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3050            return Ok(Action::requeue(Duration::from_secs(
3051                SCOUT_ERROR_REQUEUE_SECS,
3052            )));
3053        }
3054    }
3055
3056    // Resolve IPs: annotation → gateway chain (parentRefs → Gateway → LB Service)
3057    // → default_ips → no routable IP = requeue
3058    let ips = {
3059        let from_annotation = resolve_ips_from_annotation(&annotations);
3060        let from_gateway = if from_annotation.is_some() {
3061            None
3062        } else {
3063            let parent_refs = route
3064                .spec
3065                .as_ref()
3066                .and_then(|s| s.parent_refs.as_ref())
3067                .cloned()
3068                .unwrap_or_default();
3069            resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3070                .await
3071        };
3072        let from_defaults = if ctx.default_ips.is_empty() {
3073            None
3074        } else {
3075            Some(ctx.default_ips.clone())
3076        };
3077
3078        match from_annotation.or(from_gateway).or(from_defaults) {
3079            Some(ips) => ips,
3080            None => {
3081                warn!(tlsroute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3082                return Ok(Action::requeue(Duration::from_secs(
3083                    SCOUT_ERROR_REQUEUE_SECS,
3084                )));
3085            }
3086        }
3087    };
3088
3089    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3090
3091    // Extract hostnames from spec.hostnames[]
3092    let hostnames = route
3093        .spec
3094        .as_ref()
3095        .and_then(|s| s.hostnames.as_ref())
3096        .cloned()
3097        .unwrap_or_default();
3098
3099    let arecord_api: Api<ARecord> =
3100        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3101
3102    for (idx, hostname) in hostnames.iter().enumerate() {
3103        if hostname.is_empty() {
3104            debug!(tlsroute = %name, hostname_index = idx, "TLSRoute hostname is empty — skipping");
3105            continue;
3106        }
3107
3108        let record_name = match resolve_record_name(&annotations, hostname, &zone) {
3109            Ok(n) => n,
3110            Err(e) => {
3111                warn!(tlsroute = %name, hostname = %hostname, zone = %zone, error = %e, "Hostname does not belong to zone — skipping");
3112                continue;
3113            }
3114        };
3115
3116        let cr_name = tlsroute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
3117        let arecord = build_tlsroute_arecord(TLSRouteARecordParams {
3118            name: &cr_name,
3119            target_namespace: &ctx.target_namespace,
3120            record_name: &record_name,
3121            ips: &ips,
3122            ttl,
3123            cluster_name: &ctx.cluster_name,
3124            route_namespace: &namespace,
3125            route_name: &name,
3126            zone: &zone,
3127        });
3128
3129        // Server-side apply
3130        let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3131        match arecord_api
3132            .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3133            .await
3134        {
3135            Ok(_) => {
3136                info!(arecord = %cr_name, tlsroute = %name, hostname = %hostname, ips = ?ips, "ARecord created/updated for TLSRoute");
3137            }
3138            Err(e) => {
3139                error!(arecord = %cr_name, tlsroute = %name, error = %e, "Failed to apply ARecord for TLSRoute");
3140                return Err(ScoutError::from(anyhow!(
3141                    "Failed to apply ARecord {cr_name}: {e}"
3142                )));
3143            }
3144        }
3145    }
3146
3147    // Clean up stale ARecords from old cluster names
3148    delete_stale_cluster_tlsroute_arecords(
3149        &ctx.remote_client,
3150        &ctx.target_namespace,
3151        &ctx.cluster_name,
3152        &namespace,
3153        &name,
3154    )
3155    .await
3156    .map_err(ScoutError::from)?;
3157
3158    Ok(Action::await_change())
3159}
3160
3161/// Reconciles a single `TCPRoute` resource, creating or updating ARecord CRs as needed.
3162async fn reconcile_tcproute(
3163    route: Arc<TCPRoute>,
3164    ctx: Arc<ScoutContext>,
3165) -> Result<Action, ScoutError> {
3166    let name = route.name_any();
3167    let namespace = route.namespace().unwrap_or_default();
3168
3169    if ctx.excluded_namespaces.contains(&namespace) {
3170        debug!(tcproute = %name, ns = %namespace, "Skipping excluded namespace");
3171        return Ok(Action::await_change());
3172    }
3173
3174    if route.metadata.deletion_timestamp.is_some() {
3175        if route
3176            .metadata
3177            .finalizers
3178            .as_ref()
3179            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3180            .unwrap_or(false)
3181        {
3182            info!(tcproute = %name, ns = %namespace, "TCPRoute deleting — cleaning up ARecords");
3183            let cleanup: Result<()> = async {
3184                delete_arecords_for_tcproute(
3185                    &ctx.remote_client,
3186                    &ctx.target_namespace,
3187                    &ctx.cluster_name,
3188                    &namespace,
3189                    &name,
3190                )
3191                .await?;
3192                delete_stale_cluster_tcproute_arecords(
3193                    &ctx.remote_client,
3194                    &ctx.target_namespace,
3195                    &ctx.cluster_name,
3196                    &namespace,
3197                    &name,
3198                )
3199                .await
3200            }
3201            .await;
3202            if let Err(e) = cleanup {
3203                if !cleanup_grace_expired(
3204                    route.metadata.deletion_timestamp.as_ref(),
3205                    Timestamp::now(),
3206                ) {
3207                    warn!(tcproute = %name, ns = %namespace, error = %e, "Remote ARecord cleanup failed during TCPRoute deletion — retrying within grace period");
3208                    return Ok(Action::requeue(Duration::from_secs(
3209                        SCOUT_ERROR_REQUEUE_SECS,
3210                    )));
3211                }
3212                error!(tcproute = %name, ns = %namespace, error = %e, grace_secs = REMOTE_CLEANUP_GRACE_SECS, "Remote ARecord cleanup still failing after grace period — releasing finalizer to unblock TCPRoute deletion; remote ARecords may be orphaned and must be reconciled separately");
3213            }
3214            remove_finalizer_from_tcproute(&ctx.client, &route)
3215                .await
3216                .map_err(ScoutError::from)?;
3217            info!(tcproute = %name, ns = %namespace, "Finalizer removed — TCPRoute deletion unblocked");
3218        }
3219        return Ok(Action::await_change());
3220    }
3221
3222    let annotations = route
3223        .metadata
3224        .annotations
3225        .as_ref()
3226        .cloned()
3227        .unwrap_or_default();
3228
3229    let namespace_eligible =
3230        source_namespace_eligible(&ctx.client, &namespace, ctx.namespace_selector.as_deref())
3231            .await
3232            .map_err(ScoutError::from)?;
3233
3234    if !is_scout_opted_in(&annotations) || !namespace_eligible {
3235        let has_fin = route
3236            .metadata
3237            .finalizers
3238            .as_ref()
3239            .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3240            .unwrap_or(false);
3241        if has_fin {
3242            info!(tcproute = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
3243            delete_arecords_for_tcproute(
3244                &ctx.remote_client,
3245                &ctx.target_namespace,
3246                &ctx.cluster_name,
3247                &namespace,
3248                &name,
3249            )
3250            .await
3251            .map_err(ScoutError::from)?;
3252            delete_stale_cluster_tcproute_arecords(
3253                &ctx.remote_client,
3254                &ctx.target_namespace,
3255                &ctx.cluster_name,
3256                &namespace,
3257                &name,
3258            )
3259            .await
3260            .map_err(ScoutError::from)?;
3261            remove_finalizer_from_tcproute(&ctx.client, &route)
3262                .await
3263                .map_err(ScoutError::from)?;
3264        }
3265        debug!(tcproute = %name, ns = %namespace, "No scout-enabled annotation — skipping");
3266        return Ok(Action::await_change());
3267    }
3268
3269    let has_fin = route
3270        .metadata
3271        .finalizers
3272        .as_ref()
3273        .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
3274        .unwrap_or(false);
3275    if !has_fin {
3276        add_finalizer_to_tcproute(&ctx.client, &route)
3277            .await
3278            .map_err(ScoutError::from)?;
3279        debug!(tcproute = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
3280        return Ok(Action::await_change());
3281    }
3282
3283    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
3284        Some(z) => z,
3285        None => {
3286            warn!(tcproute = %name, ns = %namespace, "No DNS zone available — skipping");
3287            return Ok(Action::requeue(Duration::from_secs(
3288                SCOUT_ERROR_REQUEUE_SECS,
3289            )));
3290        }
3291    };
3292
3293    match check_zone_authorization(&ctx.zone_store.state(), &zone, &namespace) {
3294        ZoneAuthz::Authorized => {}
3295        ZoneAuthz::Forbidden => {
3296            warn!(tcproute = %name, ns = %namespace, zone = %zone, "TCPRoute namespace not authorized for zone — the DNSZone must live in this namespace or set annotation {ANNOTATION_ALLOW_ZONE_NAMESPACES} to include it (or '*') — skipping");
3297            return Ok(Action::requeue(Duration::from_secs(
3298                SCOUT_ERROR_REQUEUE_SECS,
3299            )));
3300        }
3301        ZoneAuthz::NotFound => {
3302            warn!(tcproute = %name, ns = %namespace, zone = %zone, "Zone not found in DNSZone store — requeuing");
3303            return Ok(Action::requeue(Duration::from_secs(
3304                SCOUT_ERROR_REQUEUE_SECS,
3305            )));
3306        }
3307    }
3308
3309    let ips = {
3310        let from_annotation = resolve_ips_from_annotation(&annotations);
3311        let from_gateway = if from_annotation.is_some() {
3312            None
3313        } else {
3314            let parent_refs = route
3315                .spec
3316                .as_ref()
3317                .and_then(|s| s.parent_refs.as_ref())
3318                .cloned()
3319                .unwrap_or_default();
3320            resolve_ips_from_gateways(&ctx.client, &namespace, &parent_refs, &ctx.gateway_services)
3321                .await
3322        };
3323        let from_defaults = if ctx.default_ips.is_empty() {
3324            None
3325        } else {
3326            Some(ctx.default_ips.clone())
3327        };
3328
3329        match from_annotation.or(from_gateway).or(from_defaults) {
3330            Some(ips) => ips,
3331            None => {
3332                warn!(tcproute = %name, ns = %namespace, "No IP available (no annotation override, no gateway IP, no default IPs) — requeuing");
3333                return Ok(Action::requeue(Duration::from_secs(
3334                    SCOUT_ERROR_REQUEUE_SECS,
3335                )));
3336            }
3337        }
3338    };
3339
3340    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
3341
3342    let Some(record_name) = get_record_name_annotation(&annotations) else {
3343        warn!(tcproute = %name, ns = %namespace, "TCPRoute has no record-name override — skipping (add bindy.firestoned.io/record-name annotation)");
3344        return Ok(Action::requeue(Duration::from_secs(
3345            SCOUT_ERROR_REQUEUE_SECS,
3346        )));
3347    };
3348
3349    let arecord_api: Api<ARecord> =
3350        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
3351
3352    let cr_name = tcproute_arecord_cr_name(&ctx.cluster_name, &namespace, &name, 0);
3353    let arecord = build_tcproute_arecord(TCPRouteARecordParams {
3354        name: &cr_name,
3355        target_namespace: &ctx.target_namespace,
3356        record_name: &record_name,
3357        ips: &ips,
3358        ttl,
3359        cluster_name: &ctx.cluster_name,
3360        route_namespace: &namespace,
3361        route_name: &name,
3362        zone: &zone,
3363    });
3364
3365    let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
3366    match arecord_api
3367        .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
3368        .await
3369    {
3370        Ok(_) => {
3371            info!(arecord = %cr_name, tcproute = %name, record_name = %record_name, ips = ?ips, "ARecord created/updated for TCPRoute");
3372        }
3373        Err(e) => {
3374            error!(arecord = %cr_name, tcproute = %name, error = %e, "Failed to apply ARecord for TCPRoute");
3375            return Err(ScoutError::from(anyhow!(
3376                "Failed to apply ARecord {cr_name}: {e}"
3377            )));
3378        }
3379    }
3380
3381    delete_stale_cluster_tcproute_arecords(
3382        &ctx.remote_client,
3383        &ctx.target_namespace,
3384        &ctx.cluster_name,
3385        &namespace,
3386        &name,
3387    )
3388    .await
3389    .map_err(ScoutError::from)?;
3390
3391    Ok(Action::await_change())
3392}
3393
3394/// Error policy for Gateway API routes: requeue with a fixed backoff.
3395fn gateway_route_error_policy(
3396    _obj: Arc<HTTPRoute>,
3397    error: &ScoutError,
3398    _ctx: Arc<ScoutContext>,
3399) -> Action {
3400    error!(error = %error, "Scout HTTPRoute reconcile error — requeuing");
3401    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3402}
3403
3404/// Error policy for TLSRoute: requeue with a fixed backoff.
3405fn tlsroute_error_policy(
3406    _obj: Arc<TLSRoute>,
3407    error: &ScoutError,
3408    _ctx: Arc<ScoutContext>,
3409) -> Action {
3410    error!(error = %error, "Scout TLSRoute reconcile error — requeuing");
3411    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3412}
3413
3414/// Error policy for TCPRoute: requeue with a fixed backoff.
3415fn tcproute_error_policy(
3416    _obj: Arc<TCPRoute>,
3417    error: &ScoutError,
3418    _ctx: Arc<ScoutContext>,
3419) -> Action {
3420    error!(error = %error, "Scout TCPRoute reconcile error — requeuing");
3421    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
3422}
3423
3424// ============================================================================
3425// Remote client builder (Phase 2)
3426// ============================================================================
3427
3428/// Builds a Kubernetes client from a kubeconfig stored in a Kubernetes Secret.
3429///
3430/// The Secret must contain a `kubeconfig` key in `.data` with a valid kubeconfig
3431/// YAML document. Used in Phase 2 to connect Scout (running in the workload cluster)
3432/// to the remote Bindy cluster where ARecords and DNSZones live.
3433///
3434/// # Errors
3435///
3436/// Returns an error if the Secret cannot be read, the `kubeconfig` key is absent,
3437/// the YAML is malformed, or the resulting client configuration is invalid.
3438async fn build_remote_client(
3439    local_client: &Client,
3440    secret_name: &str,
3441    secret_namespace: &str,
3442) -> Result<Client> {
3443    let api: Api<Secret> = Api::namespaced(local_client.clone(), secret_namespace);
3444    let secret = api.get(secret_name).await.map_err(|e| {
3445        anyhow!("Failed to read kubeconfig Secret {secret_namespace}/{secret_name}: {e}")
3446    })?;
3447
3448    let kubeconfig_bytes = secret
3449        .data
3450        .as_ref()
3451        .and_then(|d| d.get("kubeconfig"))
3452        .ok_or_else(|| {
3453            anyhow!("Secret {secret_namespace}/{secret_name} has no 'kubeconfig' key in .data")
3454        })?;
3455
3456    let kubeconfig_str = std::str::from_utf8(&kubeconfig_bytes.0)
3457        .map_err(|e| anyhow!("kubeconfig in Secret is not valid UTF-8: {e}"))?;
3458
3459    let kubeconfig = Kubeconfig::from_yaml(kubeconfig_str)
3460        .map_err(|e| anyhow!("Failed to parse kubeconfig from Secret: {e}"))?;
3461
3462    let config = kube::Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
3463        .await
3464        .map_err(|e| anyhow!("Failed to build client config from kubeconfig: {e}"))?;
3465
3466    Client::try_from(config).map_err(|e| anyhow!("Failed to create remote Kubernetes client: {e}"))
3467}
3468
3469// ============================================================================
3470// Entry point
3471// ============================================================================
3472
3473/// Reads scout configuration from environment variables.
3474struct ScoutConfig {
3475    target_namespace: String,
3476    cluster_name: String,
3477    excluded_namespaces: Vec<String>,
3478    /// Default IPs used when no per-Ingress annotation override or LB status IP is available.
3479    /// Set via `BINDY_SCOUT_DEFAULT_IPS` (comma-separated) or `--default-ips` CLI flag.
3480    default_ips: Vec<String>,
3481    /// `gatewayClass → LoadBalancer Service` map for gateway-chain IP resolution.
3482    /// Set via `BINDY_SCOUT_GATEWAY_SERVICES` (`class=ns/name,...`) or `--gateway-service`.
3483    gateway_services: BTreeMap<String, GatewayServiceTarget>,
3484    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
3485    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone` CLI flag.
3486    default_zone: Option<String>,
3487    /// Kubernetes label selector restricting which namespaces Scout will act in. `None` means
3488    /// every namespace is eligible (backward-compatible default). Set via
3489    /// `BINDY_SCOUT_NAMESPACE_SELECTOR` or `--namespace-selector` CLI flag.
3490    namespace_selector: Option<String>,
3491    /// Name of the Secret containing the remote cluster kubeconfig (Phase 2).
3492    /// When `None`, Scout operates in same-cluster mode.
3493    remote_secret_name: Option<String>,
3494    /// Namespace of the remote kubeconfig Secret. Defaults to Scout's own namespace.
3495    remote_secret_namespace: String,
3496}
3497
3498impl ScoutConfig {
3499    /// Build configuration from environment variables, with optional CLI overrides.
3500    ///
3501    /// CLI arguments take precedence over environment variables when provided.
3502    fn from_env(
3503        cli_cluster_name: Option<String>,
3504        cli_namespace: Option<String>,
3505        cli_default_ips: Vec<String>,
3506        cli_gateway_services: Vec<String>,
3507        cli_default_zone: Option<String>,
3508        cli_namespace_selector: Option<String>,
3509    ) -> Result<Self> {
3510        let target_namespace = cli_namespace
3511            .filter(|s| !s.is_empty())
3512            .or_else(|| std::env::var("BINDY_SCOUT_NAMESPACE").ok())
3513            .unwrap_or_else(|| DEFAULT_SCOUT_NAMESPACE.to_string());
3514
3515        let cluster_name = cli_cluster_name
3516            .filter(|s| !s.is_empty())
3517            .or_else(|| std::env::var("BINDY_SCOUT_CLUSTER_NAME").ok())
3518            .ok_or_else(|| {
3519                anyhow!("BINDY_SCOUT_CLUSTER_NAME is required (set via --cluster-name or env var)")
3520            })?;
3521
3522        let own_namespace =
3523            std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string());
3524
3525        let mut excluded_namespaces: Vec<String> = std::env::var("BINDY_SCOUT_EXCLUDE_NAMESPACES")
3526            .unwrap_or_default()
3527            .split(',')
3528            .map(str::trim)
3529            .filter(|s| !s.is_empty())
3530            .map(ToString::to_string)
3531            .collect();
3532
3533        // Always exclude Scout's own namespace
3534        if !excluded_namespaces.contains(&own_namespace) {
3535            excluded_namespaces.push(own_namespace.clone());
3536        }
3537
3538        // CLI --default-ips takes precedence over BINDY_SCOUT_DEFAULT_IPS env var
3539        let default_ips = if !cli_default_ips.is_empty() {
3540            cli_default_ips
3541        } else {
3542            std::env::var("BINDY_SCOUT_DEFAULT_IPS")
3543                .unwrap_or_default()
3544                .split(',')
3545                .map(str::trim)
3546                .filter(|s| !s.is_empty())
3547                .map(ToString::to_string)
3548                .collect()
3549        };
3550
3551        // CLI --gateway-service takes precedence over BINDY_SCOUT_GATEWAY_SERVICES env var.
3552        // CLI entries are parsed one-by-one (each flag is a single `class=target`) so a
3553        // multi-label selector's commas are not mistaken for entry separators; the env
3554        // form is a single comma-separated string.
3555        let gateway_services = if cli_gateway_services.is_empty() {
3556            parse_gateway_services(
3557                &std::env::var("BINDY_SCOUT_GATEWAY_SERVICES").unwrap_or_default(),
3558            )
3559        } else {
3560            cli_gateway_services
3561                .iter()
3562                .filter_map(|e| parse_gateway_service_entry(e))
3563                .collect()
3564        };
3565
3566        // CLI --default-zone takes precedence over BINDY_SCOUT_DEFAULT_ZONE env var
3567        let default_zone = cli_default_zone.filter(|s| !s.is_empty()).or_else(|| {
3568            std::env::var("BINDY_SCOUT_DEFAULT_ZONE")
3569                .ok()
3570                .filter(|s| !s.is_empty())
3571        });
3572
3573        // CLI --namespace-selector takes precedence over BINDY_SCOUT_NAMESPACE_SELECTOR env var.
3574        // Unset (None) means every namespace is eligible — the backward-compatible default.
3575        let namespace_selector = cli_namespace_selector
3576            .filter(|s| !s.is_empty())
3577            .or_else(|| {
3578                std::env::var("BINDY_SCOUT_NAMESPACE_SELECTOR")
3579                    .ok()
3580                    .filter(|s| !s.is_empty())
3581            });
3582
3583        let remote_secret_name = std::env::var("BINDY_SCOUT_REMOTE_SECRET")
3584            .ok()
3585            .filter(|s| !s.is_empty());
3586
3587        let remote_secret_namespace =
3588            std::env::var("BINDY_SCOUT_REMOTE_SECRET_NAMESPACE").unwrap_or(own_namespace);
3589
3590        Ok(Self {
3591            target_namespace,
3592            cluster_name,
3593            excluded_namespaces,
3594            default_ips,
3595            gateway_services,
3596            default_zone,
3597            namespace_selector,
3598            remote_secret_name,
3599            remote_secret_namespace,
3600        })
3601    }
3602}
3603
3604// ============================================================================
3605// Internal helpers
3606// ============================================================================
3607
3608/// Converts a [`watcher::Error`] into a short, human-readable diagnosis string.
3609///
3610/// The kube-runtime watcher wraps all errors in a thin enum. This function
3611/// peels back the layers to surface the actionable cause: connection refused,
3612/// unauthorized, RBAC-forbidden, or a generic API / transport error.
3613fn diagnose_reflector_error(e: &watcher::Error) -> String {
3614    // Extract the phase label and the inner kube client error, handling the
3615    // two variants that don't carry a kube::Error directly.
3616    let (phase, client_err) = match e {
3617        watcher::Error::InitialListFailed(e) => ("initial list", e),
3618        watcher::Error::WatchStartFailed(e) => ("watch start", e),
3619        watcher::Error::WatchFailed(e) => ("watch stream", e),
3620        watcher::Error::WatchError(status) => {
3621            return format!(
3622                "API server returned error during watch: {} (HTTP {})",
3623                status.message, status.code
3624            );
3625        }
3626        watcher::Error::NoResourceVersion => {
3627            return "resource does not support watch (no resourceVersion returned)".to_string();
3628        }
3629    };
3630
3631    let detail = match client_err {
3632        KubeError::Api(status) => match status.code {
3633            401 => format!(
3634                "unauthorized — check credentials/token ({})",
3635                status.message
3636            ),
3637            403 => format!("forbidden — check RBAC permissions ({})", status.message),
3638            code => format!("API error HTTP {code} — {}", status.message),
3639        },
3640        KubeError::Auth(e) => format!("authentication error — {e}"),
3641        KubeError::Service(e) => format!("cannot connect to API server — {e}"),
3642        KubeError::HyperError(e) => format!("HTTP transport error — {e}"),
3643        other => format!("{other}"),
3644    };
3645
3646    format!("{phase} failed: {detail}")
3647}
3648
3649/// Entry point for the `bindy scout` subcommand.
3650///
3651/// Initialises the Kubernetes client, builds reflector stores for `DNSZone`
3652/// resources (for zone validation), then runs the Ingress controller loop.
3653///
3654/// # Errors
3655///
3656/// Returns an error if the Kubernetes client cannot be initialised or if the
3657/// cluster name is not provided via CLI or the `BINDY_SCOUT_CLUSTER_NAME` env var.
3658pub async fn run_scout(
3659    cli_cluster_name: Option<String>,
3660    cli_namespace: Option<String>,
3661    cli_default_ips: Vec<String>,
3662    cli_gateway_services: Vec<String>,
3663    cli_default_zone: Option<String>,
3664    cli_namespace_selector: Option<String>,
3665) -> Result<()> {
3666    let config = ScoutConfig::from_env(
3667        cli_cluster_name,
3668        cli_namespace,
3669        cli_default_ips,
3670        cli_gateway_services,
3671        cli_default_zone,
3672        cli_namespace_selector,
3673    )?;
3674
3675    let local_client = Client::try_default().await?;
3676
3677    let remote_client = if let Some(ref secret_name) = config.remote_secret_name {
3678        info!(
3679            cluster = %config.cluster_name,
3680            target_ns = %config.target_namespace,
3681            secret = %secret_name,
3682            secret_ns = %config.remote_secret_namespace,
3683            excluded = ?config.excluded_namespaces,
3684            default_ips = ?config.default_ips,
3685            default_zone = ?config.default_zone,
3686            namespace_selector = ?config.namespace_selector,
3687            "Starting bindy scout in remote cluster mode"
3688        );
3689        build_remote_client(&local_client, secret_name, &config.remote_secret_namespace).await?
3690    } else {
3691        info!(
3692            cluster = %config.cluster_name,
3693            target_ns = %config.target_namespace,
3694            excluded = ?config.excluded_namespaces,
3695            default_ips = ?config.default_ips,
3696            default_zone = ?config.default_zone,
3697            namespace_selector = ?config.namespace_selector,
3698            "Starting bindy scout in same-cluster mode"
3699        );
3700        local_client.clone()
3701    };
3702
3703    if config.namespace_selector.is_none() {
3704        warn!(
3705            "No --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR configured — scout will \
3706             act in EVERY namespace in the cluster (subject only to each source object's own \
3707             opt-in annotation and --exclude-namespaces). Setting a namespace-selector so scout \
3708             only considers explicitly-whitelisted namespaces is strongly recommended for \
3709             production deployments; running without one is not recommended."
3710        );
3711    }
3712
3713    // Build a reflector store for DNSZone resources using the REMOTE client.
3714    // In same-cluster mode this is the local cluster; in Phase 2 this is the bindy cluster.
3715    // Scoped to the target namespace: DNSZones and ARecords always live in the same namespace
3716    // on the bindy cluster, so a namespaced watch is sufficient and avoids the need for a
3717    // cluster-scoped ClusterRole.
3718    let dnszone_api: Api<DNSZone> =
3719        Api::namespaced(remote_client.clone(), &config.target_namespace);
3720    let (dnszone_reader, dnszone_writer) = reflector::store();
3721    let dnszone_reflector = reflector(
3722        dnszone_writer,
3723        watcher(dnszone_api, WatcherConfig::default()),
3724    );
3725
3726    // Start the DNSZone reflector in the background.
3727    // The kube-runtime watcher relies on the consumer to apply backoff: "You can apply your own
3728    // backoff by not polling the stream for a duration after errors." We sleep on each error so
3729    // that a repeated Connect failure doesn't spin in a tight logging loop.
3730    tokio::spawn(async move {
3731        dnszone_reflector
3732            .for_each(|event| async move {
3733                match event {
3734                    Ok(_) => {}
3735                    Err(e) => {
3736                        error!(diagnosis = %diagnose_reflector_error(&e), "DNSZone reflector error");
3737                        tokio::time::sleep(tokio::time::Duration::from_secs(
3738                            REFLECTOR_ERROR_BACKOFF_SECS,
3739                        ))
3740                        .await;
3741                    }
3742                }
3743            })
3744            .await;
3745    });
3746
3747    let ctx = Arc::new(ScoutContext {
3748        client: local_client.clone(),
3749        remote_client,
3750        target_namespace: config.target_namespace,
3751        cluster_name: config.cluster_name,
3752        excluded_namespaces: config.excluded_namespaces,
3753        default_ips: config.default_ips,
3754        gateway_services: config.gateway_services,
3755        default_zone: config.default_zone,
3756        namespace_selector: config.namespace_selector,
3757        zone_store: dnszone_reader,
3758    });
3759
3760    // Watch Ingresses across all namespaces using the LOCAL client
3761    let ingress_api: Api<Ingress> = Api::all(local_client.clone());
3762    // Watch Services across all namespaces using the LOCAL client
3763    let svc_api: Api<Service> = Api::all(local_client.clone());
3764    // Watch HTTPRoutes across all namespaces using the LOCAL client
3765    let httproute_api: Api<HTTPRoute> = Api::all(local_client.clone());
3766    // Watch TLSRoutes across all namespaces using the LOCAL client
3767    let tlsroute_api: Api<TLSRoute> = Api::all(local_client.clone());
3768    // Watch TCPRoutes across all namespaces using the LOCAL client
3769    let tcproute_api: Api<TCPRoute> = Api::all(local_client.clone());
3770
3771    info!("Scout controller running — watching Ingresses, Services, HTTPRoutes, TLSRoutes, and TCPRoutes");
3772
3773    let ingress_controller = Controller::new(ingress_api, WatcherConfig::default())
3774        .run(reconcile, error_policy, ctx.clone())
3775        .for_each(|res| async move {
3776            match res {
3777                Ok(obj) => debug!(obj = ?obj, "Reconciled Ingress"),
3778                Err(e) => error!(error = %e, "Ingress reconcile failed"),
3779            }
3780        });
3781
3782    let service_controller = Controller::new(svc_api, WatcherConfig::default())
3783        .run(reconcile_service, service_error_policy, ctx.clone())
3784        .for_each(|res| async move {
3785            match res {
3786                Ok(obj) => debug!(obj = ?obj, "Reconciled Service"),
3787                Err(e) => error!(error = %e, "Service reconcile failed"),
3788            }
3789        });
3790
3791    let httproute_controller = Controller::new(httproute_api, WatcherConfig::default())
3792        .run(reconcile_httproute, gateway_route_error_policy, ctx.clone())
3793        .for_each(|res| async move {
3794            match res {
3795                Ok(obj) => debug!(obj = ?obj, "Reconciled HTTPRoute"),
3796                Err(e) => error!(error = %e, "HTTPRoute reconcile failed"),
3797            }
3798        });
3799
3800    let tlsroute_controller = Controller::new(tlsroute_api, WatcherConfig::default())
3801        .run(reconcile_tlsroute, tlsroute_error_policy, ctx.clone())
3802        .for_each(|res| async move {
3803            match res {
3804                Ok(obj) => debug!(obj = ?obj, "Reconciled TLSRoute"),
3805                Err(e) => error!(error = %e, "TLSRoute reconcile failed"),
3806            }
3807        });
3808
3809    let tcproute_controller = Controller::new(tcproute_api, WatcherConfig::default())
3810        .run(reconcile_tcproute, tcproute_error_policy, ctx)
3811        .for_each(|res| async move {
3812            match res {
3813                Ok(obj) => debug!(obj = ?obj, "Reconciled TCPRoute"),
3814                Err(e) => error!(error = %e, "TCPRoute reconcile failed"),
3815            }
3816        });
3817
3818    futures::future::join5(
3819        ingress_controller,
3820        service_controller,
3821        httproute_controller,
3822        tlsroute_controller,
3823        tcproute_controller,
3824    )
3825    .await;
3826
3827    Ok(())
3828}