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::crd::{ARecord, ARecordSpec, DNSZone};
25use anyhow::{anyhow, Result};
26use k8s_openapi::api::core::v1::Secret;
27use kube::api::{DeleteParams, ListParams, Patch, PatchParams};
28use kube::config::{KubeConfigOptions, Kubeconfig};
29
30/// Reconcile error type — wraps `anyhow::Error` so that it satisfies the
31/// `std::error::Error` bound required by `kube::runtime::Controller::run`.
32#[derive(Debug, thiserror::Error)]
33#[error(transparent)]
34pub struct ScoutError(#[from] anyhow::Error);
35use futures::StreamExt;
36use k8s_openapi::api::networking::v1::Ingress;
37use kube::{
38    runtime::{
39        controller::Action, reflector, watcher, watcher::Config as WatcherConfig, Controller,
40    },
41    Api, Client, ResourceExt,
42};
43use std::{collections::BTreeMap, sync::Arc, time::Duration};
44use tracing::{debug, error, info, warn};
45
46// ============================================================================
47// Constants
48// ============================================================================
49
50/// Annotation specifying the DNS record kind Scout should create for this Ingress.
51/// Set to `"ARecord"` to create an A record. Any other value (or absent) is ignored.
52pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
53
54/// Expected value of [`ANNOTATION_RECORD_KIND`] for A record creation.
55pub const RECORD_KIND_ARECORD: &str = "ARecord";
56
57/// Annotation specifying which DNS zone owns this Ingress host
58pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
59
60/// Simplified opt-in annotation — set to `"true"` to enable Scout for this Ingress.
61/// Takes precedence over (and is preferred to) [`ANNOTATION_RECORD_KIND`] for new users.
62/// Both annotations are accepted for backward compatibility.
63pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
64
65/// Annotation for explicitly overriding the IP used in the ARecord.
66/// When set, takes precedence over the IP resolved from Ingress LoadBalancer status.
67pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
68
69/// Annotation for overriding the TTL (in seconds) on the created ARecord.
70/// When absent, the ARecord inherits the TTL from the DNSZone spec.
71pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
72
73/// Finalizer added to Ingresses managed by Scout to ensure cleanup on deletion
74pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
75
76/// Label placed on created ARecords identifying Scout as the manager
77pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
78
79/// Label value for ARecords created by Scout
80pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
81
82/// Label identifying the source cluster on created ARecords
83pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
84
85/// Label identifying the source namespace on created ARecords
86pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
87
88/// Label identifying the source Ingress name on created ARecords
89pub const LABEL_SOURCE_INGRESS: &str = "bindy.firestoned.io/source-ingress";
90
91/// Label carrying the DNS zone name on created ARecords (for DNSZone selector matching)
92pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
93
94/// Default namespace where ARecords are created when `BINDY_SCOUT_NAMESPACE` is not set
95pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
96
97/// Maximum Kubernetes resource name length in characters
98const MAX_K8S_NAME_LEN: usize = 253;
99
100/// Prefix applied to all ARecord CR names created by Scout
101const ARECORD_NAME_PREFIX: &str = "scout";
102
103/// Requeue delay for non-fatal errors (seconds)
104const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
105
106// ============================================================================
107// Context
108// ============================================================================
109
110/// Shared context passed to every reconciler invocation.
111pub struct ScoutContext {
112    /// Local Kubernetes client — Ingress watching and finalizer management.
113    /// Always the in-cluster client regardless of mode.
114    pub client: Client,
115    /// Remote Kubernetes client — ARecord creation/deletion and DNSZone validation.
116    /// In same-cluster mode (Phase 1) this is identical to `client`.
117    /// In remote mode (Phase 2+) this targets the dedicated Bindy cluster.
118    pub remote_client: Client,
119    /// Namespace where ARecords are created (on the remote/target cluster)
120    pub target_namespace: String,
121    /// Logical cluster name stamped on created ARecord labels
122    pub cluster_name: String,
123    /// Namespaces excluded from Ingress watching (always includes Scout's own namespace)
124    pub excluded_namespaces: Vec<String>,
125    /// Default IPs used when no annotation override and no LB status IP is available.
126    /// Intended for shared-ingress topologies (e.g. Traefik) where all Ingresses resolve
127    /// to the same IP(s). Set via `BINDY_SCOUT_DEFAULT_IPS` or `--default-ips`.
128    pub default_ips: Vec<String>,
129    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
130    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone`.
131    pub default_zone: Option<String>,
132    /// Read-only store of DNSZone resources for zone validation.
133    /// Populated from the remote client so zones are validated against the bindy cluster.
134    pub zone_store: reflector::Store<DNSZone>,
135}
136
137// ============================================================================
138// Pure helper functions (tested in scout_tests.rs)
139// ============================================================================
140
141/// Returns `true` if the Ingress is annotated for ARecord creation.
142///
143/// The annotation `bindy.firestoned.io/recordKind` must have the value `"ARecord"` (case-sensitive).
144/// Any other value (or absence of the annotation) returns `false`.
145pub fn is_arecord_enabled(annotations: &BTreeMap<String, String>) -> bool {
146    annotations
147        .get(ANNOTATION_RECORD_KIND)
148        .map(|v| v == RECORD_KIND_ARECORD)
149        .unwrap_or(false)
150}
151
152/// Returns `true` if Scout should manage this Ingress.
153///
154/// Accepts either the simplified opt-in annotation:
155/// - `bindy.firestoned.io/scout-enabled: "true"` (preferred for new deployments)
156///
157/// Or the legacy annotation for backward compatibility:
158/// - `bindy.firestoned.io/recordKind: "ARecord"`
159///
160/// The record kind always defaults to `ARecord` — no further annotation is needed.
161pub fn is_scout_opted_in(annotations: &BTreeMap<String, String>) -> bool {
162    annotations
163        .get(ANNOTATION_SCOUT_ENABLED)
164        .map(|v| v == "true")
165        .unwrap_or(false)
166        || is_arecord_enabled(annotations)
167}
168
169/// Resolves the DNS zone for an Ingress, in priority order:
170///
171/// 1. `bindy.firestoned.io/zone` annotation — per-Ingress explicit override
172/// 2. `default_zone` — operator-configured default zone (e.g. `"example.com"`)
173///
174/// Returns `None` if neither is available. When `None`, Scout logs a warning and skips the Ingress.
175pub fn resolve_zone(
176    annotations: &BTreeMap<String, String>,
177    default_zone: Option<&str>,
178) -> Option<String> {
179    get_zone_annotation(annotations).or_else(|| default_zone.map(ToString::to_string))
180}
181
182/// Returns the DNS zone specified by the `bindy.firestoned.io/zone` annotation.
183///
184/// Returns `None` if the annotation is absent or has an empty value.
185pub fn get_zone_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
186    annotations
187        .get(ANNOTATION_ZONE)
188        .filter(|v| !v.is_empty())
189        .cloned()
190}
191
192/// Derives the DNS record name from a hostname and zone.
193///
194/// - `host.zone` → `host` (e.g. `"app.example.com"` + `"example.com"` → `"app"`)
195/// - `zone` (apex) → `"@"`
196/// - `deep.sub.zone` → `"deep.sub"`
197///
198/// Trailing dots on `host` are stripped before comparison.
199///
200/// # Errors
201///
202/// Returns an error if `host` does not end with the zone suffix.
203pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
204    // Strip trailing dot if present (some Ingress controllers append it)
205    let host = host.trim_end_matches('.');
206
207    // Apex record
208    if host == zone {
209        return Ok("@".to_string());
210    }
211
212    let zone_suffix = format!(".{zone}");
213    if !host.ends_with(&zone_suffix) {
214        return Err(anyhow!(
215            "host \"{host}\" does not belong to zone \"{zone}\""
216        ));
217    }
218
219    let record_name = &host[..host.len() - zone_suffix.len()];
220    Ok(record_name.to_string())
221}
222
223/// Returns the explicit IP override from `bindy.firestoned.io/ip`, if present.
224///
225/// Returns `None` if the annotation is absent or empty.
226pub fn resolve_ip_from_annotation(annotations: &BTreeMap<String, String>) -> Option<String> {
227    annotations
228        .get(ANNOTATION_IP)
229        .filter(|v| !v.is_empty())
230        .cloned()
231}
232
233/// Resolves the IP address(es) to use for an ARecord, in priority order:
234///
235/// 1. `bindy.firestoned.io/ip` annotation — explicit single-IP override
236/// 2. `default_ips` — operator-configured default IPs (e.g. shared Traefik ingress VIP)
237/// 3. Ingress LoadBalancer status — first non-empty IP
238///
239/// Returns `None` if no IP can be determined from any source.
240pub fn resolve_ips(
241    annotations: &BTreeMap<String, String>,
242    default_ips: &[String],
243    ingress: &Ingress,
244) -> Option<Vec<String>> {
245    if let Some(ip) = resolve_ip_from_annotation(annotations) {
246        return Some(vec![ip]);
247    }
248    if !default_ips.is_empty() {
249        return Some(default_ips.to_vec());
250    }
251    resolve_ip_from_lb_status(ingress).map(|ip| vec![ip])
252}
253
254/// Resolves the IP to use for an ARecord from the Ingress load-balancer status.
255///
256/// Returns the first non-empty `ip` field found in `status.loadBalancer.ingress`.
257/// Hostname-only entries (no IP) are ignored; a warning is logged for each.
258pub fn resolve_ip_from_lb_status(ingress: &Ingress) -> Option<String> {
259    let lb_ingresses = ingress
260        .status
261        .as_ref()?
262        .load_balancer
263        .as_ref()?
264        .ingress
265        .as_ref()?;
266
267    for lb in lb_ingresses {
268        if let Some(ip) = &lb.ip {
269            if !ip.is_empty() {
270                return Some(ip.clone());
271            }
272        }
273        if lb.hostname.is_some() {
274            warn!(
275                ingress = %ingress.name_any(),
276                "Ingress LB status has hostname but no IP — A record requires an IP address; skipping"
277            );
278        }
279    }
280    None
281}
282
283/// Builds a sanitized Kubernetes resource name for an ARecord CR.
284///
285/// Format: `scout-{cluster}-{namespace}-{ingress}-{index}`
286///
287/// All characters are lowercased. Underscores and any non-alphanumeric characters
288/// (other than hyphens) are replaced with hyphens. The result is truncated to
289/// 253 characters to stay within the Kubernetes name limit.
290pub fn arecord_cr_name(
291    cluster: &str,
292    namespace: &str,
293    ingress_name: &str,
294    host_index: usize,
295) -> String {
296    let raw = format!("{ARECORD_NAME_PREFIX}-{cluster}-{namespace}-{ingress_name}-{host_index}");
297    let sanitized = sanitize_k8s_name(&raw);
298    sanitized[..sanitized.len().min(MAX_K8S_NAME_LEN)].to_string()
299}
300
301/// Sanitizes a string for use as a Kubernetes resource name.
302///
303/// - Lowercases all characters
304/// - Replaces any character that is not `[a-z0-9-]` with `-`
305/// - Collapses consecutive hyphens into one
306/// - Strips leading and trailing hyphens
307fn sanitize_k8s_name(s: &str) -> String {
308    let lower = s.to_lowercase();
309    let mut result = String::with_capacity(lower.len());
310    let mut last_was_hyphen = false;
311
312    for ch in lower.chars() {
313        if ch.is_ascii_alphanumeric() {
314            result.push(ch);
315            last_was_hyphen = false;
316        } else {
317            // Replace any non-alphanumeric character with a hyphen (collapsing runs)
318            if !last_was_hyphen {
319                result.push('-');
320                last_was_hyphen = true;
321            }
322        }
323    }
324
325    // Strip trailing hyphens
326    let trimmed = result.trim_end_matches('-');
327    // Strip leading hyphens
328    trimmed.trim_start_matches('-').to_string()
329}
330
331/// Returns `true` if the Scout finalizer is present on the Ingress.
332pub fn has_finalizer(ingress: &Ingress) -> bool {
333    ingress
334        .metadata
335        .finalizers
336        .as_ref()
337        .map(|fs| fs.iter().any(|f| f == FINALIZER_SCOUT))
338        .unwrap_or(false)
339}
340
341/// Returns `true` if the Ingress has been marked for deletion.
342pub fn is_being_deleted(ingress: &Ingress) -> bool {
343    ingress.metadata.deletion_timestamp.is_some()
344}
345
346/// Builds a Kubernetes label selector string matching all ARecords created
347/// by Scout for a specific Ingress.
348///
349/// Selects on `managed-by=scout`, `source-cluster`, `source-namespace`, and
350/// `source-ingress` to precisely target only the records owned by this Ingress.
351pub fn arecord_label_selector(cluster: &str, namespace: &str, ingress_name: &str) -> String {
352    format!(
353        "{}={},{cluster_key}={cluster},{ns_key}={namespace},{ingress_key}={ingress_name}",
354        LABEL_MANAGED_BY,
355        LABEL_MANAGED_BY_SCOUT,
356        cluster_key = LABEL_SOURCE_CLUSTER,
357        ns_key = LABEL_SOURCE_NAMESPACE,
358        ingress_key = LABEL_SOURCE_INGRESS,
359    )
360}
361
362// ============================================================================
363// ARecord builder
364// ============================================================================
365
366/// Parameters for building an ARecord CR.
367pub struct ARecordParams<'a> {
368    /// Kubernetes resource name for the ARecord CR
369    pub name: &'a str,
370    /// Namespace where the ARecord CR will be created
371    pub target_namespace: &'a str,
372    /// DNS record name within the zone (e.g. `"app"` or `"@"`)
373    pub record_name: &'a str,
374    /// IPv4 addresses to use for the record (one or more)
375    pub ips: &'a [String],
376    /// Optional TTL override in seconds
377    pub ttl: Option<i32>,
378    /// Logical name of the source cluster (for labels)
379    pub cluster_name: &'a str,
380    /// Source Ingress namespace (for labels)
381    pub ingress_namespace: &'a str,
382    /// Source Ingress name (for labels)
383    pub ingress_name: &'a str,
384    /// DNS zone name (for labels)
385    pub zone: &'a str,
386}
387
388/// Builds the ARecord CR that Scout will create on the target cluster.
389pub fn build_arecord(params: ARecordParams<'_>) -> ARecord {
390    let mut labels = BTreeMap::new();
391    labels.insert(
392        LABEL_MANAGED_BY.to_string(),
393        LABEL_MANAGED_BY_SCOUT.to_string(),
394    );
395    labels.insert(
396        LABEL_SOURCE_CLUSTER.to_string(),
397        params.cluster_name.to_string(),
398    );
399    labels.insert(
400        LABEL_SOURCE_NAMESPACE.to_string(),
401        params.ingress_namespace.to_string(),
402    );
403    labels.insert(
404        LABEL_SOURCE_INGRESS.to_string(),
405        params.ingress_name.to_string(),
406    );
407    labels.insert(LABEL_ZONE.to_string(), params.zone.to_string());
408
409    let meta = kube::api::ObjectMeta {
410        name: Some(params.name.to_string()),
411        namespace: Some(params.target_namespace.to_string()),
412        labels: Some(labels),
413        ..Default::default()
414    };
415
416    ARecord {
417        metadata: meta,
418        spec: ARecordSpec {
419            name: params.record_name.to_string(),
420            ipv4_addresses: params.ips.to_vec(),
421            ttl: params.ttl,
422        },
423        status: None,
424    }
425}
426
427// ============================================================================
428// Finalizer helpers (async — require Kubernetes API access)
429// ============================================================================
430
431/// Adds the Scout finalizer to an Ingress.
432///
433/// Merges the finalizer into the existing list so any other finalizers
434/// already present are preserved.
435async fn add_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
436    let namespace = ingress.namespace().unwrap_or_default();
437    let name = ingress.name_any();
438    let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
439
440    let mut finalizers = ingress.metadata.finalizers.clone().unwrap_or_default();
441    if !finalizers.contains(&FINALIZER_SCOUT.to_string()) {
442        finalizers.push(FINALIZER_SCOUT.to_string());
443    }
444
445    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
446    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
447        .await?;
448    Ok(())
449}
450
451/// Removes the Scout finalizer from an Ingress.
452///
453/// Preserves any other finalizers that may be present.
454async fn remove_finalizer(client: &Client, ingress: &Ingress) -> Result<()> {
455    let namespace = ingress.namespace().unwrap_or_default();
456    let name = ingress.name_any();
457    let api: Api<Ingress> = Api::namespaced(client.clone(), &namespace);
458
459    let finalizers: Vec<String> = ingress
460        .metadata
461        .finalizers
462        .clone()
463        .unwrap_or_default()
464        .into_iter()
465        .filter(|f| f != FINALIZER_SCOUT)
466        .collect();
467
468    let patch = serde_json::json!({ "metadata": { "finalizers": finalizers } });
469    api.patch(&name, &PatchParams::default(), &Patch::Merge(&patch))
470        .await?;
471    Ok(())
472}
473
474/// Deletes all ARecords in `target_namespace` that were created by Scout for
475/// the given Ingress (identified by cluster + namespace + ingress name labels).
476///
477/// Must be called with the **remote** client so it targets the cluster where
478/// ARecords live (which may differ from the local cluster in Phase 2+).
479async fn delete_arecords_for_ingress(
480    remote_client: &Client,
481    target_namespace: &str,
482    cluster: &str,
483    ingress_namespace: &str,
484    ingress_name: &str,
485) -> Result<()> {
486    let api: Api<ARecord> = Api::namespaced(remote_client.clone(), target_namespace);
487    let selector = arecord_label_selector(cluster, ingress_namespace, ingress_name);
488    let lp = ListParams::default().labels(&selector);
489
490    let arecords = api.list(&lp).await?;
491    for ar in arecords.items {
492        let ar_name = ar.name_any();
493        api.delete(&ar_name, &DeleteParams::default()).await?;
494        info!(
495            arecord = %ar_name,
496            ingress = %ingress_name,
497            ns = %ingress_namespace,
498            "Deleted ARecord during Ingress cleanup"
499        );
500    }
501    Ok(())
502}
503
504// ============================================================================
505// Reconciler
506// ============================================================================
507
508/// Reconciles a single Ingress, creating or updating ARecord CRs as needed.
509///
510/// Handles the full lifecycle:
511/// - Adds a finalizer to opted-in Ingresses so deletion is intercepted.
512/// - On deletion, removes all ARecords Scout created then releases the finalizer.
513/// - If the opt-in annotation is removed, cleans up ARecords and the finalizer.
514///
515/// # Errors
516///
517/// Returns an error that will be retried by the controller runtime.
518async fn reconcile(ingress: Arc<Ingress>, ctx: Arc<ScoutContext>) -> Result<Action, ScoutError> {
519    let name = ingress.name_any();
520    let namespace = ingress.namespace().unwrap_or_default();
521
522    // Skip excluded namespaces
523    if ctx.excluded_namespaces.contains(&namespace) {
524        debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
525        return Ok(Action::await_change());
526    }
527
528    // Handle Ingress deletion — remove ARecords and release the finalizer
529    if is_being_deleted(&ingress) {
530        if has_finalizer(&ingress) {
531            info!(ingress = %name, ns = %namespace, "Ingress deleting — cleaning up ARecords");
532            delete_arecords_for_ingress(
533                &ctx.remote_client,
534                &ctx.target_namespace,
535                &ctx.cluster_name,
536                &namespace,
537                &name,
538            )
539            .await
540            .map_err(ScoutError::from)?;
541            remove_finalizer(&ctx.client, &ingress)
542                .await
543                .map_err(ScoutError::from)?;
544            info!(ingress = %name, ns = %namespace, "Finalizer removed — Ingress deletion unblocked");
545        }
546        return Ok(Action::await_change());
547    }
548
549    let annotations = ingress
550        .metadata
551        .annotations
552        .as_ref()
553        .cloned()
554        .unwrap_or_default();
555
556    // Guard: opt-in annotation required (scout-enabled: "true" or recordKind: "ARecord")
557    if !is_scout_opted_in(&annotations) {
558        // Annotation may have been removed after a finalizer was added — clean up
559        if has_finalizer(&ingress) {
560            info!(ingress = %name, ns = %namespace, "Scout opt-in annotation removed — cleaning up ARecords and finalizer");
561            delete_arecords_for_ingress(
562                &ctx.remote_client,
563                &ctx.target_namespace,
564                &ctx.cluster_name,
565                &namespace,
566                &name,
567            )
568            .await
569            .map_err(ScoutError::from)?;
570            remove_finalizer(&ctx.client, &ingress)
571                .await
572                .map_err(ScoutError::from)?;
573        }
574        debug!(ingress = %name, ns = %namespace, "No arecord annotation — skipping");
575        return Ok(Action::await_change());
576    }
577
578    // Ensure our finalizer is present before creating any ARecords.
579    // Adding the finalizer triggers a re-reconcile; return early to avoid
580    // doing record creation twice.
581    if !has_finalizer(&ingress) {
582        add_finalizer(&ctx.client, &ingress)
583            .await
584            .map_err(ScoutError::from)?;
585        debug!(ingress = %name, ns = %namespace, "Finalizer added — re-queuing for record creation");
586        return Ok(Action::await_change());
587    }
588
589    // Guard: zone required (annotation or operator default)
590    let zone = match resolve_zone(&annotations, ctx.default_zone.as_deref()) {
591        Some(z) => z,
592        None => {
593            warn!(ingress = %name, ns = %namespace, "No DNS zone available (set bindy.firestoned.io/zone annotation or BINDY_SCOUT_DEFAULT_ZONE) — skipping");
594            return Ok(Action::requeue(Duration::from_secs(
595                SCOUT_ERROR_REQUEUE_SECS,
596            )));
597        }
598    };
599
600    // Guard: zone must exist in the local DNSZone store
601    let zone_exists = ctx
602        .zone_store
603        .state()
604        .iter()
605        .any(|z| z.spec.zone_name == zone);
606    if !zone_exists {
607        warn!(
608            ingress = %name,
609            ns = %namespace,
610            zone = %zone,
611            "Zone not found in DNSZone store — skipping until zone appears"
612        );
613        return Ok(Action::requeue(Duration::from_secs(
614            SCOUT_ERROR_REQUEUE_SECS,
615        )));
616    }
617
618    // Resolve IPs: annotation override → default_ips → LB status
619    let ips = match resolve_ips(&annotations, &ctx.default_ips, &ingress) {
620        Some(ips) => ips,
621        None => {
622            warn!(ingress = %name, ns = %namespace, "No IP available (no annotation override, no default IPs, no LB status IP) — requeuing");
623            return Ok(Action::requeue(Duration::from_secs(
624                SCOUT_ERROR_REQUEUE_SECS,
625            )));
626        }
627    };
628
629    // Optional TTL override
630    let ttl: Option<i32> = annotations.get(ANNOTATION_TTL).and_then(|v| v.parse().ok());
631
632    let spec_rules = ingress
633        .spec
634        .as_ref()
635        .and_then(|s| s.rules.as_ref())
636        .cloned()
637        .unwrap_or_default();
638
639    // Use the remote client — ARecords live on the bindy cluster (Phase 2+)
640    // or the same cluster (Phase 1, where remote_client == client)
641    let arecord_api: Api<ARecord> =
642        Api::namespaced(ctx.remote_client.clone(), &ctx.target_namespace);
643
644    for (idx, rule) in spec_rules.iter().enumerate() {
645        let host = match rule.host.as_deref() {
646            Some(h) if !h.is_empty() => h,
647            _ => {
648                debug!(ingress = %name, rule_index = idx, "Ingress rule has no host — skipping");
649                continue;
650            }
651        };
652
653        let record_name = match derive_record_name(host, &zone) {
654            Ok(n) => n,
655            Err(e) => {
656                warn!(ingress = %name, host = %host, zone = %zone, error = %e, "Host does not belong to zone — skipping rule");
657                continue;
658            }
659        };
660
661        let cr_name = arecord_cr_name(&ctx.cluster_name, &namespace, &name, idx);
662        let arecord = build_arecord(ARecordParams {
663            name: &cr_name,
664            target_namespace: &ctx.target_namespace,
665            record_name: &record_name,
666            ips: &ips,
667            ttl,
668            cluster_name: &ctx.cluster_name,
669            ingress_namespace: &namespace,
670            ingress_name: &name,
671            zone: &zone,
672        });
673
674        // Server-side apply
675        let ssapply = kube::api::PatchParams::apply("bindy-scout").force();
676        match arecord_api
677            .patch(&cr_name, &ssapply, &kube::api::Patch::Apply(&arecord))
678            .await
679        {
680            Ok(_) => {
681                info!(arecord = %cr_name, ingress = %name, host = %host, ips = ?ips, "ARecord created/updated");
682            }
683            Err(e) => {
684                error!(arecord = %cr_name, ingress = %name, error = %e, "Failed to apply ARecord");
685                return Err(ScoutError::from(anyhow!(
686                    "Failed to apply ARecord {cr_name}: {e}"
687                )));
688            }
689        }
690    }
691
692    Ok(Action::await_change())
693}
694
695/// Error policy: requeue with a fixed backoff on any reconcile error.
696fn error_policy(_obj: Arc<Ingress>, error: &ScoutError, _ctx: Arc<ScoutContext>) -> Action {
697    error!(error = %error, "Scout reconcile error — requeuing");
698    Action::requeue(Duration::from_secs(SCOUT_ERROR_REQUEUE_SECS))
699}
700
701// ============================================================================
702// Remote client builder (Phase 2)
703// ============================================================================
704
705/// Builds a Kubernetes client from a kubeconfig stored in a Kubernetes Secret.
706///
707/// The Secret must contain a `kubeconfig` key in `.data` with a valid kubeconfig
708/// YAML document. Used in Phase 2 to connect Scout (running in the workload cluster)
709/// to the remote Bindy cluster where ARecords and DNSZones live.
710///
711/// # Errors
712///
713/// Returns an error if the Secret cannot be read, the `kubeconfig` key is absent,
714/// the YAML is malformed, or the resulting client configuration is invalid.
715async fn build_remote_client(
716    local_client: &Client,
717    secret_name: &str,
718    secret_namespace: &str,
719) -> Result<Client> {
720    let api: Api<Secret> = Api::namespaced(local_client.clone(), secret_namespace);
721    let secret = api.get(secret_name).await.map_err(|e| {
722        anyhow!("Failed to read kubeconfig Secret {secret_namespace}/{secret_name}: {e}")
723    })?;
724
725    let kubeconfig_bytes = secret
726        .data
727        .as_ref()
728        .and_then(|d| d.get("kubeconfig"))
729        .ok_or_else(|| {
730            anyhow!("Secret {secret_namespace}/{secret_name} has no 'kubeconfig' key in .data")
731        })?;
732
733    let kubeconfig_str = std::str::from_utf8(&kubeconfig_bytes.0)
734        .map_err(|e| anyhow!("kubeconfig in Secret is not valid UTF-8: {e}"))?;
735
736    let kubeconfig = Kubeconfig::from_yaml(kubeconfig_str)
737        .map_err(|e| anyhow!("Failed to parse kubeconfig from Secret: {e}"))?;
738
739    let config = kube::Config::from_custom_kubeconfig(kubeconfig, &KubeConfigOptions::default())
740        .await
741        .map_err(|e| anyhow!("Failed to build client config from kubeconfig: {e}"))?;
742
743    Client::try_from(config).map_err(|e| anyhow!("Failed to create remote Kubernetes client: {e}"))
744}
745
746// ============================================================================
747// Entry point
748// ============================================================================
749
750/// Reads scout configuration from environment variables.
751struct ScoutConfig {
752    target_namespace: String,
753    cluster_name: String,
754    excluded_namespaces: Vec<String>,
755    /// Default IPs used when no per-Ingress annotation override or LB status IP is available.
756    /// Set via `BINDY_SCOUT_DEFAULT_IPS` (comma-separated) or `--default-ips` CLI flag.
757    default_ips: Vec<String>,
758    /// Default DNS zone applied to all Ingresses when no `bindy.firestoned.io/zone` annotation
759    /// is present. Set via `BINDY_SCOUT_DEFAULT_ZONE` or `--default-zone` CLI flag.
760    default_zone: Option<String>,
761    /// Name of the Secret containing the remote cluster kubeconfig (Phase 2).
762    /// When `None`, Scout operates in same-cluster mode.
763    remote_secret_name: Option<String>,
764    /// Namespace of the remote kubeconfig Secret. Defaults to Scout's own namespace.
765    remote_secret_namespace: String,
766}
767
768impl ScoutConfig {
769    /// Build configuration from environment variables, with optional CLI overrides.
770    ///
771    /// CLI arguments take precedence over environment variables when provided.
772    fn from_env(
773        cli_cluster_name: Option<String>,
774        cli_namespace: Option<String>,
775        cli_default_ips: Vec<String>,
776        cli_default_zone: Option<String>,
777    ) -> Result<Self> {
778        let target_namespace = cli_namespace
779            .filter(|s| !s.is_empty())
780            .or_else(|| std::env::var("BINDY_SCOUT_NAMESPACE").ok())
781            .unwrap_or_else(|| DEFAULT_SCOUT_NAMESPACE.to_string());
782
783        let cluster_name = cli_cluster_name
784            .filter(|s| !s.is_empty())
785            .or_else(|| std::env::var("BINDY_SCOUT_CLUSTER_NAME").ok())
786            .ok_or_else(|| {
787                anyhow!(
788                    "BINDY_SCOUT_CLUSTER_NAME is required (set via --bind9-cluster-name or env var)"
789                )
790            })?;
791
792        let own_namespace =
793            std::env::var("POD_NAMESPACE").unwrap_or_else(|_| "default".to_string());
794
795        let mut excluded_namespaces: Vec<String> = std::env::var("BINDY_SCOUT_EXCLUDE_NAMESPACES")
796            .unwrap_or_default()
797            .split(',')
798            .map(str::trim)
799            .filter(|s| !s.is_empty())
800            .map(ToString::to_string)
801            .collect();
802
803        // Always exclude Scout's own namespace
804        if !excluded_namespaces.contains(&own_namespace) {
805            excluded_namespaces.push(own_namespace.clone());
806        }
807
808        // CLI --default-ips takes precedence over BINDY_SCOUT_DEFAULT_IPS env var
809        let default_ips = if !cli_default_ips.is_empty() {
810            cli_default_ips
811        } else {
812            std::env::var("BINDY_SCOUT_DEFAULT_IPS")
813                .unwrap_or_default()
814                .split(',')
815                .map(str::trim)
816                .filter(|s| !s.is_empty())
817                .map(ToString::to_string)
818                .collect()
819        };
820
821        // CLI --default-zone takes precedence over BINDY_SCOUT_DEFAULT_ZONE env var
822        let default_zone = cli_default_zone.filter(|s| !s.is_empty()).or_else(|| {
823            std::env::var("BINDY_SCOUT_DEFAULT_ZONE")
824                .ok()
825                .filter(|s| !s.is_empty())
826        });
827
828        let remote_secret_name = std::env::var("BINDY_SCOUT_REMOTE_SECRET")
829            .ok()
830            .filter(|s| !s.is_empty());
831
832        let remote_secret_namespace =
833            std::env::var("BINDY_SCOUT_REMOTE_SECRET_NAMESPACE").unwrap_or(own_namespace);
834
835        Ok(Self {
836            target_namespace,
837            cluster_name,
838            excluded_namespaces,
839            default_ips,
840            default_zone,
841            remote_secret_name,
842            remote_secret_namespace,
843        })
844    }
845}
846
847/// Entry point for the `bindy scout` subcommand.
848///
849/// Initialises the Kubernetes client, builds reflector stores for `DNSZone`
850/// resources (for zone validation), then runs the Ingress controller loop.
851///
852/// # Arguments
853///
854/// * `cli_cluster_name` — Optional cluster name from `--bind9-cluster-name` CLI arg.
855///   Takes precedence over the `BINDY_SCOUT_CLUSTER_NAME` environment variable.
856/// * `cli_namespace` — Optional namespace from `--namespace` CLI arg.
857///   Takes precedence over the `BINDY_SCOUT_NAMESPACE` environment variable.
858/// * `cli_default_ips` — Default IPs from `--default-ips` CLI arg (comma-separated values).
859///   Takes precedence over the `BINDY_SCOUT_DEFAULT_IPS` environment variable.
860///   Used in shared-ingress topologies (e.g. Traefik) where all Ingresses resolve to the same IP(s).
861/// * `cli_default_zone` — Default DNS zone from `--default-zone` CLI arg.
862///   Takes precedence over the `BINDY_SCOUT_DEFAULT_ZONE` environment variable.
863///   When set, Ingresses only need `bindy.firestoned.io/scout-enabled: "true"` — no zone annotation needed.
864///
865/// # Errors
866///
867/// Returns an error if the Kubernetes client cannot be initialised or if the
868/// cluster name is not provided via CLI or the `BINDY_SCOUT_CLUSTER_NAME` env var.
869pub async fn run_scout(
870    cli_cluster_name: Option<String>,
871    cli_namespace: Option<String>,
872    cli_default_ips: Vec<String>,
873    cli_default_zone: Option<String>,
874) -> Result<()> {
875    let config = ScoutConfig::from_env(
876        cli_cluster_name,
877        cli_namespace,
878        cli_default_ips,
879        cli_default_zone,
880    )?;
881
882    let local_client = Client::try_default().await?;
883
884    // Build remote client — either from a kubeconfig Secret (Phase 2) or
885    // the same in-cluster client (Phase 1, same-cluster mode).
886    let remote_client = if let Some(ref secret_name) = config.remote_secret_name {
887        info!(
888            cluster = %config.cluster_name,
889            target_ns = %config.target_namespace,
890            secret = %secret_name,
891            secret_ns = %config.remote_secret_namespace,
892            excluded = ?config.excluded_namespaces,
893            default_ips = ?config.default_ips,
894            default_zone = ?config.default_zone,
895            "Starting bindy scout (Phase 2 — remote cluster mode)"
896        );
897        build_remote_client(&local_client, secret_name, &config.remote_secret_namespace).await?
898    } else {
899        info!(
900            cluster = %config.cluster_name,
901            target_ns = %config.target_namespace,
902            excluded = ?config.excluded_namespaces,
903            default_ips = ?config.default_ips,
904            default_zone = ?config.default_zone,
905            "Starting bindy scout (Phase 1 — same-cluster mode)"
906        );
907        local_client.clone()
908    };
909
910    // Build a reflector store for DNSZone resources using the REMOTE client.
911    // In same-cluster mode this is the local cluster; in Phase 2 this is the bindy cluster.
912    let dnszone_api: Api<DNSZone> = Api::all(remote_client.clone());
913    let (dnszone_reader, dnszone_writer) = reflector::store();
914    let dnszone_reflector = reflector(
915        dnszone_writer,
916        watcher(dnszone_api, WatcherConfig::default()),
917    );
918
919    // Start the DNSZone reflector in the background
920    tokio::spawn(async move {
921        dnszone_reflector
922            .for_each(|event| async move {
923                match event {
924                    Ok(_) => {}
925                    Err(e) => error!(error = %e, "DNSZone reflector error"),
926                }
927            })
928            .await;
929    });
930
931    let ctx = Arc::new(ScoutContext {
932        client: local_client.clone(),
933        remote_client,
934        target_namespace: config.target_namespace,
935        cluster_name: config.cluster_name,
936        excluded_namespaces: config.excluded_namespaces,
937        default_ips: config.default_ips,
938        default_zone: config.default_zone,
939        zone_store: dnszone_reader,
940    });
941
942    // Watch Ingresses across all namespaces using the LOCAL client
943    let ingress_api: Api<Ingress> = Api::all(local_client.clone());
944
945    info!("Scout controller running — watching Ingresses");
946
947    Controller::new(ingress_api, WatcherConfig::default())
948        .run(reconcile, error_policy, ctx)
949        .for_each(|res| async move {
950            match res {
951                Ok(obj) => debug!(obj = ?obj, "Reconciled"),
952                Err(e) => error!(error = %e, "Reconcile failed"),
953            }
954        })
955        .await;
956
957    Ok(())
958}