1use 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#[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
46pub const ANNOTATION_RECORD_KIND: &str = "bindy.firestoned.io/recordKind";
53
54pub const RECORD_KIND_ARECORD: &str = "ARecord";
56
57pub const ANNOTATION_ZONE: &str = "bindy.firestoned.io/zone";
59
60pub const ANNOTATION_SCOUT_ENABLED: &str = "bindy.firestoned.io/scout-enabled";
64
65pub const ANNOTATION_IP: &str = "bindy.firestoned.io/ip";
68
69pub const ANNOTATION_TTL: &str = "bindy.firestoned.io/ttl";
72
73pub const FINALIZER_SCOUT: &str = "bindy.firestoned.io/arecord-finalizer";
75
76pub const LABEL_MANAGED_BY: &str = "bindy.firestoned.io/managed-by";
78
79pub const LABEL_MANAGED_BY_SCOUT: &str = "scout";
81
82pub const LABEL_SOURCE_CLUSTER: &str = "bindy.firestoned.io/source-cluster";
84
85pub const LABEL_SOURCE_NAMESPACE: &str = "bindy.firestoned.io/source-namespace";
87
88pub const LABEL_SOURCE_INGRESS: &str = "bindy.firestoned.io/source-ingress";
90
91pub const LABEL_ZONE: &str = "bindy.firestoned.io/zone";
93
94pub const DEFAULT_SCOUT_NAMESPACE: &str = "bindy-system";
96
97const MAX_K8S_NAME_LEN: usize = 253;
99
100const ARECORD_NAME_PREFIX: &str = "scout";
102
103const SCOUT_ERROR_REQUEUE_SECS: u64 = 30;
105
106pub struct ScoutContext {
112 pub client: Client,
115 pub remote_client: Client,
119 pub target_namespace: String,
121 pub cluster_name: String,
123 pub excluded_namespaces: Vec<String>,
125 pub default_ips: Vec<String>,
129 pub default_zone: Option<String>,
132 pub zone_store: reflector::Store<DNSZone>,
135}
136
137pub 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
152pub 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
169pub 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
182pub 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
192pub fn derive_record_name(host: &str, zone: &str) -> Result<String> {
204 let host = host.trim_end_matches('.');
206
207 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
223pub 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
233pub 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
254pub 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
283pub 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
301fn 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 if !last_was_hyphen {
319 result.push('-');
320 last_was_hyphen = true;
321 }
322 }
323 }
324
325 let trimmed = result.trim_end_matches('-');
327 trimmed.trim_start_matches('-').to_string()
329}
330
331pub 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
341pub fn is_being_deleted(ingress: &Ingress) -> bool {
343 ingress.metadata.deletion_timestamp.is_some()
344}
345
346pub 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
362pub struct ARecordParams<'a> {
368 pub name: &'a str,
370 pub target_namespace: &'a str,
372 pub record_name: &'a str,
374 pub ips: &'a [String],
376 pub ttl: Option<i32>,
378 pub cluster_name: &'a str,
380 pub ingress_namespace: &'a str,
382 pub ingress_name: &'a str,
384 pub zone: &'a str,
386}
387
388pub 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
427async 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
451async 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
474async 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
504async 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 if ctx.excluded_namespaces.contains(&namespace) {
524 debug!(ingress = %name, ns = %namespace, "Skipping excluded namespace");
525 return Ok(Action::await_change());
526 }
527
528 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 if !is_scout_opted_in(&annotations) {
558 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 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 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 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 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 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 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 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
695fn 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
701async 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
746struct ScoutConfig {
752 target_namespace: String,
753 cluster_name: String,
754 excluded_namespaces: Vec<String>,
755 default_ips: Vec<String>,
758 default_zone: Option<String>,
761 remote_secret_name: Option<String>,
764 remote_secret_namespace: String,
766}
767
768impl ScoutConfig {
769 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 if !excluded_namespaces.contains(&own_namespace) {
805 excluded_namespaces.push(own_namespace.clone());
806 }
807
808 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 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
847pub 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 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 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 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 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}