bindy/
bootstrap.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Bootstrap logic for `bindy bootstrap`.
5//!
6//! ## `bindy bootstrap operator`
7//! Applies all operator prerequisites to a Kubernetes cluster in order:
8//! 1. Namespace (`bindy-system` by default, or `--namespace`)
9//! 2. CRDs — generated from Rust types, always in sync with the operator
10//! 3. ServiceAccount (`bindy`)
11//! 4. ClusterRole (`bindy-role`) — operator permissions
12//! 5. ClusterRole (`bindy-admin-role`) — admin/destructive permissions
13//! 6. ClusterRole (`bindcar-tokenreview`) — `create tokenreviews` for the bindcar sidecar
14//! 7. ClusterRoleBinding (`bindy-rolebinding`) — binds SA to operator role
15//! 8. ClusterRoleBinding (`bindcar-tokenreview`) — binds the operand `bind9` SA
16//!    (in the requested `--namespace`) to the TokenReview ClusterRole
17//! 9. Role + RoleBinding (`bindy-secrets-writer`) — namespaced Secret write access
18//! 10. Deployment (`bindy`) — the operator itself, with a projected SA token
19//!     (`audience: bindcar`) and `POD_NAMESPACE` from the downward API so it can
20//!     authenticate to bindcar 0.7.0 sidecars
21//!
22//! ## `bindy bootstrap scout`
23//! Applies all scout prerequisites to a Kubernetes cluster in order:
24//! 1. Namespace (`bindy-system` by default, or `--namespace`)
25//! 2. CRDs — same 12 CRDs as the operator (shared types)
26//! 3. ServiceAccount (`bindy-scout`)
27//! 4. ClusterRole (`bindy-scout`) — scout cluster-scoped permissions
28//! 5. ClusterRoleBinding (`bindy-scout`) — binds scout SA to scout ClusterRole
29//! 6. Role (`bindy-scout-writer`) — namespaced ARecord write permissions
30//! 7. RoleBinding (`bindy-scout-writer`) — binds scout SA to writer Role
31//! 8. Deployment (`bindy-scout`) — the scout controller itself
32//!
33//! ## `bindy bootstrap mc`
34//! Sets up remote access so a scout running on a child (workload) cluster can write
35//! ARecords to the queen-ship (bindy) cluster.  Run this command **against the
36//! queen-ship cluster** (`KUBECONFIG` must point at it):
37//!
38//! 1. ServiceAccount (`bindy-scout-remote` by default, or `--service-account`)
39//!    — one SA per child cluster so access can be revoked independently
40//! 2. Role (`bindy-scout-remote`) — namespaced ARecord CRUD + DNSZone read permissions
41//!    on the queen-ship.  A namespaced Role is sufficient because the scout watches
42//!    DNSZones via `Api::namespaced` (not `Api::all`) in the same target namespace.
43//! 3. RoleBinding (`bindy-scout-remote`) — binds the SA to the namespaced Role
44//! 4. SA token Secret — a long-lived token for the SA
45//! 5. Kubeconfig Secret (`bindy-scout-remote-remote-kubeconfig`) — a ready-to-use
46//!    kubeconfig for the SA, printed to **stdout** as YAML
47//!
48//! The stdout output is applied to the **child cluster** where scout runs:
49//! ```text
50//! bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -
51//! ```
52//! Then set `BINDY_SCOUT_REMOTE_SECRET=bindy-scout-remote-kubeconfig` on the
53//! scout Deployment so it picks up the remote kubeconfig at startup.
54
55use anyhow::{anyhow, Context as _, Result};
56use base64::{engine::general_purpose::STANDARD, Engine as _};
57use k8s_openapi::api::apps::v1::Deployment;
58use k8s_openapi::api::core::v1::{Namespace, Secret, ServiceAccount};
59use k8s_openapi::api::rbac::v1::{
60    ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
61};
62use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
63use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
64use k8s_openapi::ByteString;
65use kube::{
66    api::{DeleteParams, Patch, PatchParams},
67    config::Kubeconfig,
68    Api, Client, CustomResourceExt,
69};
70use std::collections::BTreeMap;
71use std::time::Duration;
72
73use crate::crd::{
74    AAAARecord, ARecord, Bind9Cluster, Bind9Instance, CAARecord, CNAMERecord, ClusterBind9Provider,
75    DNSZone, MXRecord, NSRecord, SRVRecord, TXTRecord,
76};
77
78/// Default namespace for the bindy operator deployment.
79pub const DEFAULT_NAMESPACE: &str = "bindy-system";
80
81/// Field manager name used for server-side apply.
82const FIELD_MANAGER: &str = "bindy-bootstrap";
83
84/// ServiceAccount name created for the operator.
85pub const SERVICE_ACCOUNT_NAME: &str = "bindy";
86
87/// ClusterRoleBinding name.
88pub const CLUSTER_ROLE_BINDING_NAME: &str = "bindy-rolebinding";
89
90/// Operator ClusterRole name.
91pub const OPERATOR_ROLE_NAME: &str = "bindy-role";
92
93/// Namespaced Role granting the operator the mutating verbs on Secrets (B-5 hardening).
94///
95/// The operator ClusterRole grants only read-only (get/list/watch) on Secrets
96/// cluster-wide; create/update/patch/delete are confined to this namespaced Role,
97/// bound only in the operator namespace.
98pub const SECRETS_WRITER_ROLE_NAME: &str = "bindy-secrets-writer";
99
100/// Namespaced RoleBinding name for the secrets-writer Role.
101pub const SECRETS_WRITER_ROLE_BINDING_NAME: &str = "bindy-secrets-writer";
102
103/// Operator Deployment name.
104pub const OPERATOR_DEPLOYMENT_NAME: &str = "bindy";
105
106/// Container image registry and repository (without tag).
107pub const OPERATOR_IMAGE_BASE: &str = "ghcr.io/firestoned/bindy";
108
109/// Default image tag for operator and scout Deployments.
110///
111/// Always matches the binary's own version (e.g. `"v0.5.0"`) so that
112/// `bindy bootstrap` installs exactly the image that was shipped with this binary.
113pub const DEFAULT_IMAGE_TAG: &str = concat!("v", env!("CARGO_PKG_VERSION"));
114
115/// Embedded RBAC YAML files — compiled into the binary so bootstrap is self-contained.
116pub const BINDY_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role.yaml");
117pub const BINDY_ADMIN_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role-admin.yaml");
118
119/// Embedded TokenReview ClusterRole (bindcar `0.7.0` Mode B).
120///
121/// Grants `create tokenreviews` so the bindcar sidecar (running as the operand
122/// `bind9` ServiceAccount) can validate the operator's bearer token against the
123/// API server. Mirrors `deploy/operator/rbac/tokenreview-clusterrole.yaml`.
124pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML: &str =
125    include_str!("../deploy/operator/rbac/tokenreview-clusterrole.yaml");
126
127/// Embedded TokenReview ClusterRoleBinding (bindcar `0.7.0` Mode B).
128///
129/// The static manifest binds the operand `bind9` ServiceAccount in
130/// `bindy-system`; the bootstrap path rewrites the subject namespace to the
131/// requested `--namespace` via [`build_tokenreview_cluster_role_binding`].
132pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML: &str =
133    include_str!("../deploy/operator/rbac/tokenreview-clusterrolebinding.yaml");
134
135/// Name shared by the TokenReview ClusterRole and ClusterRoleBinding.
136pub const BINDCAR_TOKENREVIEW_NAME: &str = "bindcar-tokenreview";
137
138/// Embedded ClusterRole manifests applied by `bindy bootstrap operator`, in apply order.
139pub const OPERATOR_CLUSTER_ROLE_YAMLS: &[&str] = &[
140    BINDY_ROLE_YAML,
141    BINDY_ADMIN_ROLE_YAML,
142    BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML,
143];
144
145/// Volume name for the projected ServiceAccount token carrying the `bindcar` audience.
146///
147/// Mirrors the `bindcar-token` volume in `deploy/operator/deployment.yaml`.
148pub const BINDCAR_TOKEN_VOLUME_NAME: &str = "bindcar-token";
149
150/// Mount path of the projected bindcar token volume inside the operator container.
151///
152/// Together with [`BINDCAR_TOKEN_FILENAME`] this composes
153/// [`crate::bind9::BINDCAR_TOKEN_PATH`] (`/var/run/secrets/bindcar/token`),
154/// the path the operator reads the bearer token from.
155pub const BINDCAR_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/bindcar";
156
157/// File name of the projected token inside [`BINDCAR_TOKEN_MOUNT_PATH`].
158pub const BINDCAR_TOKEN_FILENAME: &str = "token";
159
160/// Expiration (seconds) for the projected bindcar token.
161///
162/// The kubelet automatically rotates the token before expiry; mirrors
163/// `expirationSeconds: 3600` in `deploy/operator/deployment.yaml`.
164pub const BINDCAR_TOKEN_EXPIRATION_SECONDS: i64 = 3600;
165
166/// Downward-API environment variable carrying the operator pod's namespace.
167///
168/// `src/bind9_resources.rs` reads this to compose the
169/// `BIND_ALLOWED_SERVICE_ACCOUNTS` value for operand bindcar sidecars; without
170/// it the operator falls back to `bindy-system` and bindcar rejects tokens
171/// from operators installed in any other namespace.
172pub const POD_NAMESPACE_ENV: &str = "POD_NAMESPACE";
173
174// ---------------------------------------------------------------------------
175// Scout constants
176// ---------------------------------------------------------------------------
177
178/// Scout ServiceAccount name.
179pub const SCOUT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout";
180
181/// Scout ClusterRole name.
182pub const SCOUT_CLUSTER_ROLE_NAME: &str = "bindy-scout";
183
184/// Scout ClusterRoleBinding name.
185pub const SCOUT_CLUSTER_ROLE_BINDING_NAME: &str = "bindy-scout";
186
187/// Scout namespaced Role name (ARecord write permissions).
188pub const SCOUT_WRITER_ROLE_NAME: &str = "bindy-scout-writer";
189
190/// Scout namespaced RoleBinding name.
191pub const SCOUT_WRITER_ROLE_BINDING_NAME: &str = "bindy-scout-writer";
192
193/// Scout namespaced Role name (remote-cluster kubeconfig Secret read, Phase 2 only).
194///
195/// Namespaced and `resourceNames`-restricted to the single configured Secret —
196/// deliberately NOT part of the cluster-wide `bindy-scout` ClusterRole, which
197/// previously granted unscoped `secrets: get` across every namespace in the
198/// cluster (closed 2026-07-19).
199pub const SCOUT_SECRETS_READER_ROLE_NAME: &str = "bindy-scout-secrets-reader";
200
201/// Scout namespaced RoleBinding name for [`SCOUT_SECRETS_READER_ROLE_NAME`].
202pub const SCOUT_SECRETS_READER_ROLE_BINDING_NAME: &str = "bindy-scout-secrets-reader";
203
204/// Scout Deployment name.
205pub const SCOUT_DEPLOYMENT_NAME: &str = "bindy-scout";
206
207/// Default ServiceAccount name created by `bootstrap mc` on the queen-ship cluster.
208///
209/// Each child cluster gets its own SA so access can be revoked independently.
210/// The local in-cluster scout SA is named [`SCOUT_SERVICE_ACCOUNT_NAME`] (`bindy-scout`);
211/// the remote SA uses this distinct name to avoid confusion.
212pub const MC_DEFAULT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout-remote";
213
214/// Default logical cluster name stamped on ARecord labels by the scout controller.
215pub const DEFAULT_SCOUT_CLUSTER_NAME: &str = "default";
216
217/// Field manager name used for scout server-side apply.
218const SCOUT_FIELD_MANAGER: &str = "bindy-bootstrap-scout";
219
220// ---------------------------------------------------------------------------
221// Scout deployment configuration
222// ---------------------------------------------------------------------------
223
224/// Configuration options for the Scout Deployment and bootstrap process.
225///
226/// Groups all deployment-specific parameters to avoid functions exceeding the
227/// recommended argument count.
228pub struct ScoutDeploymentOptions<'a> {
229    /// Image tag for the scout container (e.g. `"v0.5.0"` or `"latest"`).
230    pub image_tag: &'a str,
231    /// Optional registry override (e.g. `"my.registry.io/org"`).
232    pub registry: Option<&'a str>,
233    /// Logical cluster name stamped on ARecord labels (`--cluster-name`).
234    pub cluster_name: &'a str,
235    /// Default IP addresses for Ingresses with no per-Ingress annotation or LB status.
236    pub default_ips: &'a [String],
237    /// Default DNS zone for Ingresses with no zone annotation.
238    pub default_zone: Option<&'a str>,
239    /// Name of the Secret containing the remote cluster kubeconfig.
240    /// When set, `BINDY_SCOUT_REMOTE_SECRET` is injected into the Deployment env.
241    pub remote_secret: Option<&'a str>,
242}
243
244// ---------------------------------------------------------------------------
245// Multi-cluster (MC) constants
246// ---------------------------------------------------------------------------
247
248/// Field manager for multi-cluster bootstrap.
249const MC_FIELD_MANAGER: &str = "bindy-bootstrap-mc";
250
251/// Secret type for the kubeconfig Secret placed on a child (workload) cluster.
252///
253/// Secrets of this type hold a kubeconfig that the scout controller uses to connect
254/// back to the queen-ship (bindy operator) cluster to create ARecords and read DNSZones.
255pub const REMOTE_KUBECONFIG_SECRET_TYPE: &str = "bindy.firestoned.io/remote-kubeconfig";
256
257/// Suffix appended to the service account name when naming the SA token Secret.
258///
259/// For example, SA `scout` produces token Secret `scout-token`.
260pub const SA_TOKEN_SECRET_SUFFIX: &str = "-token";
261
262/// Suffix appended to the service account name when naming the remote kubeconfig Secret.
263///
264/// For example, SA `bindy-scout` produces kubeconfig Secret `bindy-scout-remote-kubeconfig`.
265pub const REMOTE_KUBECONFIG_SECRET_SUFFIX: &str = "-remote-kubeconfig";
266
267/// `app.kubernetes.io/component` label value for all resources created by `bootstrap mc`.
268const MC_COMPONENT_LABEL: &str = "scout-remote";
269
270/// HTTP 404 Not Found — used to detect missing resources during revoke so they can be
271/// skipped rather than treated as errors.
272const HTTP_NOT_FOUND: u16 = 404;
273
274/// Maximum polling attempts while waiting for the SA token Secret to be populated.
275const SA_TOKEN_WAIT_MAX_ATTEMPTS: usize = 20;
276
277/// Milliseconds between SA token Secret polling attempts.
278const SA_TOKEN_WAIT_INTERVAL_MS: u64 = 500;
279
280// ---------------------------------------------------------------------------
281// Image resolution
282// ---------------------------------------------------------------------------
283
284/// Resolve the full container image reference for the bindy image.
285///
286/// The image name is always `bindy`; only the registry/org prefix and tag vary:
287///
288/// | `registry`              | `tag`    | result                              |
289/// |-------------------------|----------|-------------------------------------|
290/// | `None`                  | `latest` | `ghcr.io/firestoned/bindy:latest`   |
291/// | `Some("my.reg.io/org")` | `v0.5.0` | `my.reg.io/org/bindy:v0.5.0`        |
292///
293/// Trailing slashes on `registry` are stripped before composing the reference.
294pub fn resolve_image(registry: Option<&str>, tag: &str) -> String {
295    match registry {
296        None => format!("{OPERATOR_IMAGE_BASE}:{tag}"),
297        Some(reg) => format!("{}/bindy:{}", reg.trim_end_matches('/'), tag),
298    }
299}
300
301/// Run the operator bootstrap process (`bindy bootstrap operator`).
302///
303/// When `dry_run` is `true`, prints the resources that would be applied to stdout (as YAML)
304/// without connecting to a cluster. When `false`, applies each resource via server-side apply
305/// (idempotent — safe to run multiple times).
306///
307/// # Arguments
308/// * `namespace` - Namespace to install bindy into (default: `bindy-system`)
309/// * `dry_run` - If true, print what would be applied without applying
310/// * `image_tag` - Image tag for the operator Deployment (e.g. `"v0.5.0"` or `"latest"`)
311/// * `registry` - Optional registry override for air-gapped environments
312///
313/// # Errors
314/// Returns error if Kubernetes API calls fail (in non-dry-run mode).
315pub async fn run_bootstrap_operator(
316    namespace: &str,
317    dry_run: bool,
318    image_tag: &str,
319    registry: Option<&str>,
320) -> Result<()> {
321    if dry_run {
322        return run_operator_dry_run(namespace, image_tag, registry);
323    }
324
325    let client = Client::try_default()
326        .await
327        .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
328
329    apply_namespace(&client, namespace).await?;
330    apply_crds(&client).await?;
331    apply_service_account(&client, namespace).await?;
332    for yaml in OPERATOR_CLUSTER_ROLE_YAMLS {
333        apply_cluster_role(&client, yaml).await?;
334    }
335    apply_cluster_role_binding(&client, namespace).await?;
336    apply_tokenreview_cluster_role_binding(&client, namespace).await?;
337    apply_secrets_writer_role(&client, namespace).await?;
338    apply_secrets_writer_role_binding(&client, namespace).await?;
339    apply_deployment(&client, namespace, image_tag, registry).await?;
340
341    println!("\nBootstrap complete! The operator is running in namespace {namespace}.");
342
343    Ok(())
344}
345
346/// Run the scout bootstrap process (`bindy bootstrap scout`).
347///
348/// Applies the namespace, all CRDs (shared with the operator), and all scout-specific
349/// RBAC resources and the scout Deployment.
350///
351/// When `dry_run` is `true`, prints the resources that would be applied to stdout (as YAML)
352/// without connecting to a cluster. When `false`, applies each resource via server-side apply
353/// (idempotent — safe to run multiple times).
354///
355/// # Arguments
356/// * `namespace` - Namespace to install scout into (default: `bindy-system`)
357/// * `dry_run` - If true, print what would be applied without applying
358/// * `opts` - Deployment configuration (image, registry, cluster name, IPs, zone, remote secret)
359///
360/// # Errors
361/// Returns error if Kubernetes API calls fail (in non-dry-run mode).
362pub async fn run_bootstrap_scout(
363    namespace: &str,
364    dry_run: bool,
365    opts: &ScoutDeploymentOptions<'_>,
366) -> Result<()> {
367    if dry_run {
368        return run_scout_dry_run(namespace, opts);
369    }
370
371    let client = Client::try_default()
372        .await
373        .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
374
375    apply_namespace(&client, namespace).await?;
376    apply_crds(&client).await?;
377    apply_scout_service_account(&client, namespace).await?;
378    apply_scout_cluster_role(&client).await?;
379    apply_scout_cluster_role_binding(&client, namespace).await?;
380    apply_scout_writer_role(&client, namespace).await?;
381    apply_scout_writer_role_binding(&client, namespace).await?;
382    if let Some(secret_name) = opts.remote_secret {
383        apply_scout_secrets_reader_role(&client, namespace, secret_name).await?;
384        apply_scout_secrets_reader_role_binding(&client, namespace).await?;
385    }
386    apply_scout_deployment(&client, namespace, opts).await?;
387
388    println!("\nBootstrap complete! Scout is running in namespace {namespace}.");
389
390    Ok(())
391}
392
393// ---------------------------------------------------------------------------
394// Dry-run paths — no cluster connection needed
395// ---------------------------------------------------------------------------
396
397fn run_operator_dry_run(namespace: &str, image_tag: &str, registry: Option<&str>) -> Result<()> {
398    println!("# Dry-run mode — no resources will be applied\n");
399
400    print_resource("Namespace", &build_namespace(namespace))?;
401
402    for crd in build_all_crds()? {
403        let name = crd.metadata.name.as_deref().unwrap_or("unknown");
404        print_resource(&format!("CustomResourceDefinition/{name}"), &crd)?;
405    }
406
407    print_resource("ServiceAccount", &build_service_account(namespace))?;
408    for yaml in OPERATOR_CLUSTER_ROLE_YAMLS {
409        let role = parse_cluster_role(yaml)?;
410        let name = role.metadata.name.clone().unwrap_or_default();
411        print_resource(&format!("ClusterRole ({name})"), &role)?;
412    }
413    print_resource("ClusterRoleBinding", &build_cluster_role_binding(namespace))?;
414    print_resource(
415        "ClusterRoleBinding (bindcar-tokenreview)",
416        &build_tokenreview_cluster_role_binding(namespace)?,
417    )?;
418    print_resource(
419        "Role (secrets-writer)",
420        &build_secrets_writer_role(namespace),
421    )?;
422    print_resource(
423        "RoleBinding (secrets-writer)",
424        &build_secrets_writer_role_binding(namespace),
425    )?;
426    print_resource(
427        "Deployment",
428        &build_deployment(namespace, image_tag, registry)?,
429    )?;
430
431    println!("# Dry-run complete — no resources were applied");
432    Ok(())
433}
434
435fn run_scout_dry_run(namespace: &str, opts: &ScoutDeploymentOptions<'_>) -> Result<()> {
436    println!("# Dry-run mode (scout) — no resources will be applied\n");
437
438    print_resource("Namespace", &build_namespace(namespace))?;
439
440    for crd in build_all_crds()? {
441        let name = crd.metadata.name.as_deref().unwrap_or("unknown");
442        print_resource(&format!("CustomResourceDefinition/{name}"), &crd)?;
443    }
444
445    print_resource(
446        "ServiceAccount (scout)",
447        &build_scout_service_account(namespace),
448    )?;
449    print_resource("ClusterRole (scout)", &build_scout_cluster_role())?;
450    print_resource(
451        "ClusterRoleBinding (scout)",
452        &build_scout_cluster_role_binding(namespace),
453    )?;
454    print_resource("Role (scout-writer)", &build_scout_writer_role(namespace))?;
455    print_resource(
456        "RoleBinding (scout-writer)",
457        &build_scout_writer_role_binding(namespace),
458    )?;
459    if let Some(secret_name) = opts.remote_secret {
460        print_resource(
461            "Role (scout-secrets-reader)",
462            &build_scout_secrets_reader_role(namespace, secret_name),
463        )?;
464        print_resource(
465            "RoleBinding (scout-secrets-reader)",
466            &build_scout_secrets_reader_role_binding(namespace),
467        )?;
468    }
469    print_resource(
470        "Deployment (scout)",
471        &build_scout_deployment(namespace, opts)?,
472    )?;
473
474    println!("# Dry-run complete — no resources were applied");
475    Ok(())
476}
477
478fn print_resource<T: serde::Serialize>(label: &str, resource: &T) -> Result<()> {
479    let yaml =
480        serde_yaml::to_string(resource).with_context(|| format!("Failed to serialize {label}"))?;
481    println!("---\n# {label}");
482    print!("{yaml}");
483    Ok(())
484}
485
486// ---------------------------------------------------------------------------
487// Apply helpers
488// ---------------------------------------------------------------------------
489
490async fn apply_namespace(client: &Client, name: &str) -> Result<()> {
491    let api: Api<Namespace> = Api::all(client.clone());
492    let ns = build_namespace(name);
493    api.patch(
494        name,
495        &PatchParams::apply(FIELD_MANAGER).force(),
496        &Patch::Apply(&ns),
497    )
498    .await
499    .with_context(|| format!("Failed to apply Namespace/{name}"))?;
500    println!("✓ Namespace: {name}");
501    Ok(())
502}
503
504async fn apply_crds(client: &Client) -> Result<()> {
505    let api: Api<CustomResourceDefinition> = Api::all(client.clone());
506    for crd in build_all_crds()? {
507        let name = crd.metadata.name.clone().unwrap_or_default();
508        api.patch(
509            &name,
510            &PatchParams::apply(FIELD_MANAGER).force(),
511            &Patch::Apply(&crd),
512        )
513        .await
514        .with_context(|| format!("Failed to apply CRD/{name}"))?;
515        println!("✓ CRD: {name}");
516    }
517    Ok(())
518}
519
520async fn apply_service_account(client: &Client, namespace: &str) -> Result<()> {
521    let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
522    let sa = build_service_account(namespace);
523    api.patch(
524        SERVICE_ACCOUNT_NAME,
525        &PatchParams::apply(FIELD_MANAGER).force(),
526        &Patch::Apply(&sa),
527    )
528    .await
529    .context("Failed to apply ServiceAccount/bindy")?;
530    println!("✓ ServiceAccount: {SERVICE_ACCOUNT_NAME} (namespace: {namespace})");
531    Ok(())
532}
533
534async fn apply_cluster_role(client: &Client, yaml: &str) -> Result<()> {
535    let role = parse_cluster_role(yaml)?;
536    let name = role.metadata.name.clone().unwrap_or_default();
537    let api: Api<ClusterRole> = Api::all(client.clone());
538    api.patch(
539        &name,
540        &PatchParams::apply(FIELD_MANAGER).force(),
541        &Patch::Apply(&role),
542    )
543    .await
544    .with_context(|| format!("Failed to apply ClusterRole/{name}"))?;
545    println!("✓ ClusterRole: {name}");
546    Ok(())
547}
548
549async fn apply_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
550    let api: Api<ClusterRoleBinding> = Api::all(client.clone());
551    let crb = build_cluster_role_binding(namespace);
552    api.patch(
553        CLUSTER_ROLE_BINDING_NAME,
554        &PatchParams::apply(FIELD_MANAGER).force(),
555        &Patch::Apply(&crb),
556    )
557    .await
558    .context("Failed to apply ClusterRoleBinding/bindy-rolebinding")?;
559    println!("✓ ClusterRoleBinding: {CLUSTER_ROLE_BINDING_NAME}");
560    Ok(())
561}
562
563/// Apply the TokenReview ClusterRoleBinding, re-homing the subject namespace
564/// to the requested install namespace (see [`build_tokenreview_cluster_role_binding`]).
565async fn apply_tokenreview_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
566    let api: Api<ClusterRoleBinding> = Api::all(client.clone());
567    let crb = build_tokenreview_cluster_role_binding(namespace)?;
568    api.patch(
569        BINDCAR_TOKENREVIEW_NAME,
570        &PatchParams::apply(FIELD_MANAGER).force(),
571        &Patch::Apply(&crb),
572    )
573    .await
574    .with_context(|| format!("Failed to apply ClusterRoleBinding/{BINDCAR_TOKENREVIEW_NAME}"))?;
575    println!("✓ ClusterRoleBinding: {BINDCAR_TOKENREVIEW_NAME} (subject namespace: {namespace})");
576    Ok(())
577}
578
579async fn apply_secrets_writer_role(client: &Client, namespace: &str) -> Result<()> {
580    let api: Api<Role> = Api::namespaced(client.clone(), namespace);
581    let role = build_secrets_writer_role(namespace);
582    api.patch(
583        SECRETS_WRITER_ROLE_NAME,
584        &PatchParams::apply(FIELD_MANAGER).force(),
585        &Patch::Apply(&role),
586    )
587    .await
588    .with_context(|| format!("Failed to apply Role/{SECRETS_WRITER_ROLE_NAME}"))?;
589    println!("✓ Role: {SECRETS_WRITER_ROLE_NAME} (namespace: {namespace})");
590    Ok(())
591}
592
593async fn apply_secrets_writer_role_binding(client: &Client, namespace: &str) -> Result<()> {
594    let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
595    let rb = build_secrets_writer_role_binding(namespace);
596    api.patch(
597        SECRETS_WRITER_ROLE_BINDING_NAME,
598        &PatchParams::apply(FIELD_MANAGER).force(),
599        &Patch::Apply(&rb),
600    )
601    .await
602    .with_context(|| format!("Failed to apply RoleBinding/{SECRETS_WRITER_ROLE_BINDING_NAME}"))?;
603    println!("✓ RoleBinding: {SECRETS_WRITER_ROLE_BINDING_NAME} (namespace: {namespace})");
604    Ok(())
605}
606
607async fn apply_deployment(
608    client: &Client,
609    namespace: &str,
610    image_tag: &str,
611    registry: Option<&str>,
612) -> Result<()> {
613    let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
614    let deployment = build_deployment(namespace, image_tag, registry)?;
615    api.patch(
616        OPERATOR_DEPLOYMENT_NAME,
617        &PatchParams::apply(FIELD_MANAGER).force(),
618        &Patch::Apply(&deployment),
619    )
620    .await
621    .context("Failed to apply operator Deployment")?;
622    let image = resolve_image(registry, image_tag);
623    println!("✓ Deployment: {OPERATOR_DEPLOYMENT_NAME} (image: {image})");
624    Ok(())
625}
626
627// ---------------------------------------------------------------------------
628// Scout apply helpers
629// ---------------------------------------------------------------------------
630
631async fn apply_scout_service_account(client: &Client, namespace: &str) -> Result<()> {
632    let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
633    let sa = build_scout_service_account(namespace);
634    api.patch(
635        SCOUT_SERVICE_ACCOUNT_NAME,
636        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
637        &Patch::Apply(&sa),
638    )
639    .await
640    .context("Failed to apply ServiceAccount/bindy-scout")?;
641    println!("✓ ServiceAccount: {SCOUT_SERVICE_ACCOUNT_NAME} (namespace: {namespace})");
642    Ok(())
643}
644
645async fn apply_scout_cluster_role(client: &Client) -> Result<()> {
646    let api: Api<ClusterRole> = Api::all(client.clone());
647    let role = build_scout_cluster_role();
648    api.patch(
649        SCOUT_CLUSTER_ROLE_NAME,
650        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
651        &Patch::Apply(&role),
652    )
653    .await
654    .with_context(|| format!("Failed to apply ClusterRole/{SCOUT_CLUSTER_ROLE_NAME}"))?;
655    println!("✓ ClusterRole: {SCOUT_CLUSTER_ROLE_NAME}");
656    Ok(())
657}
658
659async fn apply_scout_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
660    let api: Api<ClusterRoleBinding> = Api::all(client.clone());
661    let crb = build_scout_cluster_role_binding(namespace);
662    api.patch(
663        SCOUT_CLUSTER_ROLE_BINDING_NAME,
664        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
665        &Patch::Apply(&crb),
666    )
667    .await
668    .context("Failed to apply ClusterRoleBinding/bindy-scout")?;
669    println!("✓ ClusterRoleBinding: {SCOUT_CLUSTER_ROLE_BINDING_NAME}");
670    Ok(())
671}
672
673async fn apply_scout_writer_role(client: &Client, namespace: &str) -> Result<()> {
674    let api: Api<Role> = Api::namespaced(client.clone(), namespace);
675    let role = build_scout_writer_role(namespace);
676    api.patch(
677        SCOUT_WRITER_ROLE_NAME,
678        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
679        &Patch::Apply(&role),
680    )
681    .await
682    .with_context(|| format!("Failed to apply Role/{SCOUT_WRITER_ROLE_NAME}"))?;
683    println!("✓ Role: {SCOUT_WRITER_ROLE_NAME} (namespace: {namespace})");
684    Ok(())
685}
686
687async fn apply_scout_writer_role_binding(client: &Client, namespace: &str) -> Result<()> {
688    let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
689    let rb = build_scout_writer_role_binding(namespace);
690    api.patch(
691        SCOUT_WRITER_ROLE_BINDING_NAME,
692        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
693        &Patch::Apply(&rb),
694    )
695    .await
696    .with_context(|| format!("Failed to apply RoleBinding/{SCOUT_WRITER_ROLE_BINDING_NAME}"))?;
697    println!("✓ RoleBinding: {SCOUT_WRITER_ROLE_BINDING_NAME} (namespace: {namespace})");
698    Ok(())
699}
700
701/// Applies the namespaced, `resourceNames`-restricted Role granting read access to the
702/// single remote-cluster kubeconfig Secret. Only called when `--remote-secret` (Phase 2
703/// mode) is configured — same-cluster-only deployments get no Secret access at all.
704async fn apply_scout_secrets_reader_role(
705    client: &Client,
706    namespace: &str,
707    secret_name: &str,
708) -> Result<()> {
709    let api: Api<Role> = Api::namespaced(client.clone(), namespace);
710    let role = build_scout_secrets_reader_role(namespace, secret_name);
711    api.patch(
712        SCOUT_SECRETS_READER_ROLE_NAME,
713        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
714        &Patch::Apply(&role),
715    )
716    .await
717    .with_context(|| format!("Failed to apply Role/{SCOUT_SECRETS_READER_ROLE_NAME}"))?;
718    println!(
719        "✓ Role: {SCOUT_SECRETS_READER_ROLE_NAME} (namespace: {namespace}, secret: {secret_name})"
720    );
721    Ok(())
722}
723
724/// Applies the RoleBinding pairing [`apply_scout_secrets_reader_role`]'s Role with the
725/// Scout ServiceAccount. Only called when `--remote-secret` (Phase 2 mode) is configured.
726async fn apply_scout_secrets_reader_role_binding(client: &Client, namespace: &str) -> Result<()> {
727    let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
728    let rb = build_scout_secrets_reader_role_binding(namespace);
729    api.patch(
730        SCOUT_SECRETS_READER_ROLE_BINDING_NAME,
731        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
732        &Patch::Apply(&rb),
733    )
734    .await
735    .with_context(|| {
736        format!("Failed to apply RoleBinding/{SCOUT_SECRETS_READER_ROLE_BINDING_NAME}")
737    })?;
738    println!("✓ RoleBinding: {SCOUT_SECRETS_READER_ROLE_BINDING_NAME} (namespace: {namespace})");
739    Ok(())
740}
741
742async fn apply_scout_deployment(
743    client: &Client,
744    namespace: &str,
745    opts: &ScoutDeploymentOptions<'_>,
746) -> Result<()> {
747    let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
748    let deployment = build_scout_deployment(namespace, opts)?;
749    api.patch(
750        SCOUT_DEPLOYMENT_NAME,
751        &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
752        &Patch::Apply(&deployment),
753    )
754    .await
755    .context("Failed to apply scout Deployment")?;
756    let image = resolve_image(opts.registry, opts.image_tag);
757    println!("✓ Deployment: {SCOUT_DEPLOYMENT_NAME} (image: {image})");
758    Ok(())
759}
760
761// ---------------------------------------------------------------------------
762// Resource builders (pub so tests can access them)
763// ---------------------------------------------------------------------------
764
765/// Build the operator namespace object.
766pub fn build_namespace(name: &str) -> Namespace {
767    Namespace {
768        metadata: ObjectMeta {
769            name: Some(name.to_string()),
770            labels: Some(
771                [("kubernetes.io/metadata.name".to_string(), name.to_string())]
772                    .into_iter()
773                    .collect(),
774            ),
775            ..Default::default()
776        },
777        ..Default::default()
778    }
779}
780
781/// Build the bindy ServiceAccount in the given namespace.
782pub fn build_service_account(namespace: &str) -> ServiceAccount {
783    ServiceAccount {
784        metadata: ObjectMeta {
785            name: Some(SERVICE_ACCOUNT_NAME.to_string()),
786            namespace: Some(namespace.to_string()),
787            labels: Some(
788                [
789                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
790                    (
791                        "app.kubernetes.io/component".to_string(),
792                        "rbac".to_string(),
793                    ),
794                ]
795                .into_iter()
796                .collect(),
797            ),
798            ..Default::default()
799        },
800        ..Default::default()
801    }
802}
803
804/// Build the ClusterRoleBinding that binds the bindy ServiceAccount to `bindy-role`.
805///
806/// The subject namespace is set to `namespace` so bootstrap works for custom namespaces.
807pub fn build_cluster_role_binding(namespace: &str) -> ClusterRoleBinding {
808    ClusterRoleBinding {
809        metadata: ObjectMeta {
810            name: Some(CLUSTER_ROLE_BINDING_NAME.to_string()),
811            ..Default::default()
812        },
813        role_ref: RoleRef {
814            api_group: "rbac.authorization.k8s.io".to_string(),
815            kind: "ClusterRole".to_string(),
816            name: OPERATOR_ROLE_NAME.to_string(),
817        },
818        subjects: Some(vec![Subject {
819            kind: "ServiceAccount".to_string(),
820            name: SERVICE_ACCOUNT_NAME.to_string(),
821            namespace: Some(namespace.to_string()),
822            api_group: Some(String::new()),
823        }]),
824    }
825}
826
827/// Build the namespaced Role granting the operator the mutating verbs on Secrets.
828///
829/// B-5 hardening: cluster-wide Secret access in the operator ClusterRole is
830/// read-only; `create`/`update`/`patch`/`delete` are confined to this namespaced
831/// Role, bound only in the operator namespace. This prevents a compromised
832/// operator from creating, modifying, or deleting Secrets in other namespaces.
833pub fn build_secrets_writer_role(namespace: &str) -> Role {
834    Role {
835        metadata: ObjectMeta {
836            name: Some(SECRETS_WRITER_ROLE_NAME.to_string()),
837            namespace: Some(namespace.to_string()),
838            labels: Some(
839                [
840                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
841                    (
842                        "app.kubernetes.io/component".to_string(),
843                        "rbac".to_string(),
844                    ),
845                ]
846                .into_iter()
847                .collect(),
848            ),
849            ..Default::default()
850        },
851        rules: Some(vec![PolicyRule {
852            api_groups: Some(vec![String::new()]),
853            resources: Some(vec!["secrets".to_string()]),
854            verbs: vec![
855                "create".to_string(),
856                "update".to_string(),
857                "patch".to_string(),
858                "delete".to_string(),
859            ],
860            ..Default::default()
861        }]),
862    }
863}
864
865/// Build the RoleBinding that binds the operator ServiceAccount to the namespaced
866/// `bindy-secrets-writer` Role.
867pub fn build_secrets_writer_role_binding(namespace: &str) -> RoleBinding {
868    RoleBinding {
869        metadata: ObjectMeta {
870            name: Some(SECRETS_WRITER_ROLE_BINDING_NAME.to_string()),
871            namespace: Some(namespace.to_string()),
872            labels: Some(
873                [
874                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
875                    (
876                        "app.kubernetes.io/component".to_string(),
877                        "rbac".to_string(),
878                    ),
879                ]
880                .into_iter()
881                .collect(),
882            ),
883            ..Default::default()
884        },
885        role_ref: RoleRef {
886            api_group: "rbac.authorization.k8s.io".to_string(),
887            kind: "Role".to_string(),
888            name: SECRETS_WRITER_ROLE_NAME.to_string(),
889        },
890        subjects: Some(vec![Subject {
891            kind: "ServiceAccount".to_string(),
892            name: SERVICE_ACCOUNT_NAME.to_string(),
893            namespace: Some(namespace.to_string()),
894            api_group: Some(String::new()),
895        }]),
896    }
897}
898
899/// Build the operator Deployment manifest.
900///
901/// The container image defaults to `ghcr.io/firestoned/bindy:<image_tag>`.
902/// Pass `registry` to override the registry/org prefix for air-gapped environments.
903pub fn build_deployment(
904    namespace: &str,
905    image_tag: &str,
906    registry: Option<&str>,
907) -> Result<Deployment> {
908    let image = resolve_image(registry, image_tag);
909    let value = serde_json::json!({
910        "apiVersion": "apps/v1",
911        "kind": "Deployment",
912        "metadata": {
913            "name": OPERATOR_DEPLOYMENT_NAME,
914            "namespace": namespace,
915            "labels": {"app": "bindy"}
916        },
917        "spec": {
918            "replicas": 1,
919            "selector": {"matchLabels": {"app": "bindy"}},
920            "template": {
921                "metadata": {"labels": {"app": "bindy"}},
922                "spec": {
923                    "serviceAccountName": SERVICE_ACCOUNT_NAME,
924                    "securityContext": {"runAsNonRoot": true, "fsGroup": 65_534_i64},
925                    "containers": [{
926                        "name": "bindy",
927                        "image": image,
928                        "imagePullPolicy": "IfNotPresent",
929                        "args": ["run"],
930                        "env": [
931                            {"name": "RUST_LOG", "value": "info"},
932                            {"name": "RUST_LOG_FORMAT", "value": "text"},
933                            // Downward API: the operator derives BIND_ALLOWED_SERVICE_ACCOUNTS
934                            // for operand bindcar sidecars from its own namespace.
935                            {"name": POD_NAMESPACE_ENV, "valueFrom": {"fieldRef": {"fieldPath": "metadata.namespace"}}},
936                            {"name": "BINDY_ENABLE_LEADER_ELECTION", "value": "true"},
937                            {"name": "BINDY_LEASE_NAME", "value": "bindy-leader"}
938                        ],
939                        "securityContext": {
940                            "allowPrivilegeEscalation": false,
941                            "capabilities": {"drop": ["ALL"]},
942                            "readOnlyRootFilesystem": true,
943                            "runAsNonRoot": true,
944                            "runAsUser": 65_534_i64
945                        },
946                        "resources": {
947                            "limits": {"cpu": "500m", "memory": "512Mi"},
948                            "requests": {"cpu": "100m", "memory": "128Mi"}
949                        },
950                        "volumeMounts": [
951                            {"name": "tmp", "mountPath": "/tmp"},
952                            // bindcar 0.7.0 (Mode B / TokenReview) bearer token,
953                            // read from /var/run/secrets/bindcar/token.
954                            {
955                                "name": BINDCAR_TOKEN_VOLUME_NAME,
956                                "mountPath": BINDCAR_TOKEN_MOUNT_PATH,
957                                "readOnly": true
958                            }
959                        ]
960                    }],
961                    "volumes": [
962                        {"name": "tmp", "emptyDir": {}},
963                        // Short-lived SA token minted with the `bindcar` audience so
964                        // bindcar 0.7.0 accepts it (default BIND_TOKEN_AUDIENCES is `bindcar`).
965                        {
966                            "name": BINDCAR_TOKEN_VOLUME_NAME,
967                            "projected": {
968                                "sources": [{
969                                    "serviceAccountToken": {
970                                        "audience": crate::constants::BINDCAR_TOKEN_AUDIENCE,
971                                        "expirationSeconds": BINDCAR_TOKEN_EXPIRATION_SECONDS,
972                                        "path": BINDCAR_TOKEN_FILENAME
973                                    }
974                                }]
975                            }
976                        }
977                    ]
978                }
979            }
980        }
981    });
982    serde_json::from_value(value).context("Failed to build operator Deployment")
983}
984
985/// Parse a ClusterRole from embedded YAML.
986pub fn parse_cluster_role(yaml: &str) -> Result<ClusterRole> {
987    serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML")
988}
989
990/// Parse a ClusterRoleBinding from embedded YAML.
991pub fn parse_cluster_role_binding(yaml: &str) -> Result<ClusterRoleBinding> {
992    serde_yaml::from_str(yaml).context("Failed to parse ClusterRoleBinding YAML")
993}
994
995/// Build the TokenReview ClusterRoleBinding for the requested install namespace.
996///
997/// Parses the embedded manifest ([`BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML`])
998/// and rewrites every subject's namespace to `namespace`. The static manifest
999/// hardcodes `bindy-system`, but `bindy bootstrap operator --namespace foo`
1000/// creates the operand `bind9` ServiceAccount in `foo`, so the binding must
1001/// follow the requested namespace or bindcar's TokenReview calls are denied.
1002///
1003/// # Arguments
1004/// * `namespace` - Namespace the operator (and operand `bind9` SA) is installed into
1005///
1006/// # Errors
1007/// Returns an error if the embedded YAML fails to parse or has no subjects.
1008pub fn build_tokenreview_cluster_role_binding(namespace: &str) -> Result<ClusterRoleBinding> {
1009    let mut crb = parse_cluster_role_binding(BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML)?;
1010
1011    let Some(subjects) = crb.subjects.as_mut() else {
1012        return Err(anyhow!(
1013            "Embedded TokenReview ClusterRoleBinding has no subjects"
1014        ));
1015    };
1016
1017    for subject in subjects {
1018        subject.namespace = Some(namespace.to_string());
1019    }
1020
1021    Ok(crb)
1022}
1023
1024/// Build a single CRD from a Rust type, ensuring `storage: true` and `served: true`.
1025///
1026/// Mirrors the logic in `src/bin/crdgen.rs` so bootstrap and crdgen stay in sync.
1027pub fn build_crd<T: CustomResourceExt>() -> Result<CustomResourceDefinition> {
1028    let crd = T::crd();
1029    let mut crd_json = serde_json::to_value(&crd).context("Failed to serialize CRD to JSON")?;
1030
1031    if let Some(versions) = crd_json["spec"]["versions"].as_array_mut() {
1032        if let Some(first) = versions.first_mut() {
1033            first["storage"] = serde_json::Value::Bool(true);
1034            first["served"] = serde_json::Value::Bool(true);
1035        }
1036    }
1037
1038    serde_json::from_value(crd_json).context("Failed to deserialize CRD from JSON")
1039}
1040
1041/// Build all 12 CRDs in the same order as `crdgen`.
1042pub fn build_all_crds() -> Result<Vec<CustomResourceDefinition>> {
1043    Ok(vec![
1044        build_crd::<ARecord>()?,
1045        build_crd::<AAAARecord>()?,
1046        build_crd::<CNAMERecord>()?,
1047        build_crd::<MXRecord>()?,
1048        build_crd::<NSRecord>()?,
1049        build_crd::<TXTRecord>()?,
1050        build_crd::<SRVRecord>()?,
1051        build_crd::<CAARecord>()?,
1052        build_crd::<DNSZone>()?,
1053        build_crd::<Bind9Cluster>()?,
1054        build_crd::<ClusterBind9Provider>()?,
1055        build_crd::<Bind9Instance>()?,
1056    ])
1057}
1058
1059// ---------------------------------------------------------------------------
1060// Scout resource builders (pub so tests can access them)
1061// ---------------------------------------------------------------------------
1062
1063/// Build the scout ServiceAccount in the given namespace.
1064pub fn build_scout_service_account(namespace: &str) -> ServiceAccount {
1065    ServiceAccount {
1066        metadata: ObjectMeta {
1067            name: Some(SCOUT_SERVICE_ACCOUNT_NAME.to_string()),
1068            namespace: Some(namespace.to_string()),
1069            labels: Some(
1070                [
1071                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1072                    (
1073                        "app.kubernetes.io/component".to_string(),
1074                        "scout".to_string(),
1075                    ),
1076                ]
1077                .into_iter()
1078                .collect(),
1079            ),
1080            ..Default::default()
1081        },
1082        ..Default::default()
1083    }
1084}
1085
1086/// Build the scout ClusterRole with cluster-scoped permissions.
1087///
1088/// Grants watch/patch/update on Ingresses and Services (kube-rs finalizer patches the
1089/// main resource metadata to add/remove finalizers), read on DNSZones, and read on
1090/// Secrets (for remote kubeconfig).
1091pub fn build_scout_cluster_role() -> ClusterRole {
1092    ClusterRole {
1093        metadata: ObjectMeta {
1094            name: Some(SCOUT_CLUSTER_ROLE_NAME.to_string()),
1095            labels: Some(
1096                [
1097                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1098                    (
1099                        "app.kubernetes.io/component".to_string(),
1100                        "scout".to_string(),
1101                    ),
1102                ]
1103                .into_iter()
1104                .collect(),
1105            ),
1106            ..Default::default()
1107        },
1108        rules: Some(vec![
1109            // Watch and mutate Ingresses across all namespaces.
1110            // kube-rs finalizer::finalizer() patches the main resource metadata to
1111            // add/remove finalizers, so patch+update on ingresses (not just the
1112            // ingresses/finalizers subresource) is required.
1113            PolicyRule {
1114                api_groups: Some(vec!["networking.k8s.io".to_string()]),
1115                resources: Some(vec!["ingresses".to_string()]),
1116                verbs: vec![
1117                    "get".to_string(),
1118                    "list".to_string(),
1119                    "watch".to_string(),
1120                    "patch".to_string(),
1121                    "update".to_string(),
1122                ],
1123                ..Default::default()
1124            },
1125            // Also grant the finalizers subresource for forward-compatibility.
1126            PolicyRule {
1127                api_groups: Some(vec!["networking.k8s.io".to_string()]),
1128                resources: Some(vec!["ingresses/finalizers".to_string()]),
1129                verbs: vec!["update".to_string()],
1130                ..Default::default()
1131            },
1132            // Watch LoadBalancer Services for external IP → ARecord automation.
1133            // patch+update required to add/remove the Scout finalizer on the Service metadata.
1134            PolicyRule {
1135                api_groups: Some(vec![String::new()]),
1136                resources: Some(vec!["services".to_string()]),
1137                verbs: vec![
1138                    "get".to_string(),
1139                    "list".to_string(),
1140                    "watch".to_string(),
1141                    "patch".to_string(),
1142                    "update".to_string(),
1143                ],
1144                ..Default::default()
1145            },
1146            // services/finalizers subresource for forward-compatibility.
1147            PolicyRule {
1148                api_groups: Some(vec![String::new()]),
1149                resources: Some(vec!["services/finalizers".to_string()]),
1150                verbs: vec!["update".to_string()],
1151                ..Default::default()
1152            },
1153            // Watch HTTPRoutes, TLSRoutes, and TCPRoutes from the Gateway API
1154            // (gateway.networking.k8s.io) to automate A record creation for Gateway
1155            // routes with opt-in annotations. Gateways are read to follow a route's
1156            // parentRefs back to the serving gateway and discover its external IP.
1157            // patch+update required to add/remove the Scout finalizer on route metadata.
1158            PolicyRule {
1159                api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1160                resources: Some(vec![
1161                    "httproutes".to_string(),
1162                    "tlsroutes".to_string(),
1163                    "tcproutes".to_string(),
1164                ]),
1165                verbs: vec![
1166                    "get".to_string(),
1167                    "list".to_string(),
1168                    "watch".to_string(),
1169                    "patch".to_string(),
1170                    "update".to_string(),
1171                ],
1172                ..Default::default()
1173            },
1174            // route/finalizers subresource for forward-compatibility.
1175            PolicyRule {
1176                api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1177                resources: Some(vec![
1178                    "httproutes/finalizers".to_string(),
1179                    "tlsroutes/finalizers".to_string(),
1180                    "tcproutes/finalizers".to_string(),
1181                ]),
1182                verbs: vec!["update".to_string()],
1183                ..Default::default()
1184            },
1185            // Gateways are read-only — Scout reads status.addresses to discover external IPs.
1186            PolicyRule {
1187                api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1188                resources: Some(vec!["gateways".to_string()]),
1189                verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1190                ..Default::default()
1191            },
1192            // Read DNSZones for zone validation
1193            PolicyRule {
1194                api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1195                resources: Some(vec!["dnszones".to_string()]),
1196                verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1197                ..Default::default()
1198            },
1199            // NOTE: there is deliberately no cluster-wide Secret rule here. Reading the
1200            // remote-cluster kubeconfig Secret (Phase 2 only) is granted by a namespaced,
1201            // resourceNames-restricted Role/RoleBinding instead — see
1202            // `build_scout_secrets_reader_role` and `apply_scout_secrets_reader_role`,
1203            // applied only when `--remote-secret` is configured. This closes the previously
1204            // unscoped `secrets: get` grant that applied to every Secret in every namespace.
1205            // Read Namespace labels for --namespace-selector / BINDY_SCOUT_NAMESPACE_SELECTOR:
1206            // scout checks each source object's namespace against the configured selector
1207            // before acting, via a List scoped to that single namespace by name. Namespace
1208            // metadata (name/labels) is low-sensitivity, unlike the cluster-wide secrets
1209            // read above.
1210            PolicyRule {
1211                api_groups: Some(vec![String::new()]),
1212                resources: Some(vec!["namespaces".to_string()]),
1213                verbs: vec!["get".to_string(), "list".to_string()],
1214                ..Default::default()
1215            },
1216        ]),
1217        ..Default::default()
1218    }
1219}
1220
1221/// Build the ClusterRoleBinding that binds the scout ServiceAccount to the scout ClusterRole.
1222///
1223/// The subject namespace is set to `namespace` so bootstrap works for custom namespaces.
1224pub fn build_scout_cluster_role_binding(namespace: &str) -> ClusterRoleBinding {
1225    ClusterRoleBinding {
1226        metadata: ObjectMeta {
1227            name: Some(SCOUT_CLUSTER_ROLE_BINDING_NAME.to_string()),
1228            labels: Some(
1229                [
1230                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1231                    (
1232                        "app.kubernetes.io/component".to_string(),
1233                        "scout".to_string(),
1234                    ),
1235                ]
1236                .into_iter()
1237                .collect(),
1238            ),
1239            ..Default::default()
1240        },
1241        role_ref: RoleRef {
1242            api_group: "rbac.authorization.k8s.io".to_string(),
1243            kind: "ClusterRole".to_string(),
1244            name: SCOUT_CLUSTER_ROLE_NAME.to_string(),
1245        },
1246        subjects: Some(vec![Subject {
1247            kind: "ServiceAccount".to_string(),
1248            name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1249            namespace: Some(namespace.to_string()),
1250            api_group: Some(String::new()),
1251        }]),
1252    }
1253}
1254
1255/// Build the scout writer Role (namespaced ARecord write permissions).
1256pub fn build_scout_writer_role(namespace: &str) -> Role {
1257    Role {
1258        metadata: ObjectMeta {
1259            name: Some(SCOUT_WRITER_ROLE_NAME.to_string()),
1260            namespace: Some(namespace.to_string()),
1261            labels: Some(
1262                [
1263                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1264                    (
1265                        "app.kubernetes.io/component".to_string(),
1266                        "scout".to_string(),
1267                    ),
1268                ]
1269                .into_iter()
1270                .collect(),
1271            ),
1272            ..Default::default()
1273        },
1274        rules: Some(vec![PolicyRule {
1275            api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1276            resources: Some(vec!["arecords".to_string()]),
1277            verbs: vec![
1278                "get".to_string(),
1279                "list".to_string(),
1280                "watch".to_string(),
1281                "create".to_string(),
1282                "update".to_string(),
1283                "patch".to_string(),
1284                "delete".to_string(),
1285            ],
1286            ..Default::default()
1287        }]),
1288    }
1289}
1290
1291/// Build the scout writer RoleBinding (binds scout SA to writer Role).
1292pub fn build_scout_writer_role_binding(namespace: &str) -> RoleBinding {
1293    RoleBinding {
1294        metadata: ObjectMeta {
1295            name: Some(SCOUT_WRITER_ROLE_BINDING_NAME.to_string()),
1296            namespace: Some(namespace.to_string()),
1297            labels: Some(
1298                [
1299                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1300                    (
1301                        "app.kubernetes.io/component".to_string(),
1302                        "scout".to_string(),
1303                    ),
1304                ]
1305                .into_iter()
1306                .collect(),
1307            ),
1308            ..Default::default()
1309        },
1310        role_ref: RoleRef {
1311            api_group: "rbac.authorization.k8s.io".to_string(),
1312            kind: "Role".to_string(),
1313            name: SCOUT_WRITER_ROLE_NAME.to_string(),
1314        },
1315        subjects: Some(vec![Subject {
1316            kind: "ServiceAccount".to_string(),
1317            name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1318            namespace: Some(namespace.to_string()),
1319            api_group: Some(String::new()),
1320        }]),
1321    }
1322}
1323
1324/// Build the namespaced Role granting Scout read access to the single remote-cluster
1325/// kubeconfig Secret (Phase 2 / multi-cluster mode only).
1326///
1327/// Scoped two ways: namespaced (not a ClusterRole) and `resourceNames`-restricted to
1328/// `secret_name`, so this grants `get` on exactly one Secret object, not "every Secret
1329/// in this namespace" and certainly not "every Secret in the cluster" (the previous,
1330/// now-removed cluster-wide `bindy-scout` ClusterRole behavior).
1331pub fn build_scout_secrets_reader_role(namespace: &str, secret_name: &str) -> Role {
1332    Role {
1333        metadata: ObjectMeta {
1334            name: Some(SCOUT_SECRETS_READER_ROLE_NAME.to_string()),
1335            namespace: Some(namespace.to_string()),
1336            labels: Some(
1337                [
1338                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1339                    (
1340                        "app.kubernetes.io/component".to_string(),
1341                        "scout".to_string(),
1342                    ),
1343                ]
1344                .into_iter()
1345                .collect(),
1346            ),
1347            ..Default::default()
1348        },
1349        rules: Some(vec![PolicyRule {
1350            api_groups: Some(vec![String::new()]),
1351            resources: Some(vec!["secrets".to_string()]),
1352            resource_names: Some(vec![secret_name.to_string()]),
1353            verbs: vec!["get".to_string()],
1354            ..Default::default()
1355        }]),
1356    }
1357}
1358
1359/// Build the RoleBinding pairing [`build_scout_secrets_reader_role`] with the Scout
1360/// ServiceAccount.
1361pub fn build_scout_secrets_reader_role_binding(namespace: &str) -> RoleBinding {
1362    RoleBinding {
1363        metadata: ObjectMeta {
1364            name: Some(SCOUT_SECRETS_READER_ROLE_BINDING_NAME.to_string()),
1365            namespace: Some(namespace.to_string()),
1366            labels: Some(
1367                [
1368                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1369                    (
1370                        "app.kubernetes.io/component".to_string(),
1371                        "scout".to_string(),
1372                    ),
1373                ]
1374                .into_iter()
1375                .collect(),
1376            ),
1377            ..Default::default()
1378        },
1379        role_ref: RoleRef {
1380            api_group: "rbac.authorization.k8s.io".to_string(),
1381            kind: "Role".to_string(),
1382            name: SCOUT_SECRETS_READER_ROLE_NAME.to_string(),
1383        },
1384        subjects: Some(vec![Subject {
1385            kind: "ServiceAccount".to_string(),
1386            name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1387            namespace: Some(namespace.to_string()),
1388            api_group: Some(String::new()),
1389        }]),
1390    }
1391}
1392
1393// ---------------------------------------------------------------------------
1394// Multi-cluster kubeconfig serialization helpers (private)
1395// ---------------------------------------------------------------------------
1396
1397/// Top-level kubeconfig structure for serialization.
1398#[derive(serde::Serialize)]
1399struct BootstrapKubeconfig {
1400    #[serde(rename = "apiVersion")]
1401    api_version: String,
1402    kind: String,
1403    clusters: Vec<BootstrapNamedCluster>,
1404    contexts: Vec<BootstrapNamedContext>,
1405    #[serde(rename = "current-context")]
1406    current_context: String,
1407    users: Vec<BootstrapNamedUser>,
1408}
1409
1410#[derive(serde::Serialize)]
1411struct BootstrapNamedCluster {
1412    name: String,
1413    cluster: BootstrapCluster,
1414}
1415
1416#[derive(serde::Serialize)]
1417struct BootstrapCluster {
1418    server: String,
1419    #[serde(
1420        rename = "certificate-authority-data",
1421        skip_serializing_if = "Option::is_none"
1422    )]
1423    certificate_authority_data: Option<String>,
1424    #[serde(
1425        rename = "insecure-skip-tls-verify",
1426        skip_serializing_if = "Option::is_none"
1427    )]
1428    insecure_skip_tls_verify: Option<bool>,
1429}
1430
1431#[derive(serde::Serialize)]
1432struct BootstrapNamedContext {
1433    name: String,
1434    context: BootstrapContext,
1435}
1436
1437#[derive(serde::Serialize)]
1438struct BootstrapContext {
1439    cluster: String,
1440    user: String,
1441}
1442
1443#[derive(serde::Serialize)]
1444struct BootstrapNamedUser {
1445    name: String,
1446    user: BootstrapUser,
1447}
1448
1449#[derive(serde::Serialize)]
1450struct BootstrapUser {
1451    token: String,
1452}
1453
1454// ---------------------------------------------------------------------------
1455// Multi-cluster public API
1456// ---------------------------------------------------------------------------
1457
1458/// Run the multi-cluster bootstrap process (`bindy bootstrap multi-cluster`).
1459///
1460/// Run this command against the **queen-ship** (bindy operator) cluster. It creates a
1461/// `ServiceAccount`, namespaced `Role` (ARecord CRUD + DNSZone read), and `RoleBinding`
1462/// on the queen-ship, generates a kubeconfig for that service account, and writes a
1463/// `bindy.firestoned.io/remote-kubeconfig` Secret manifest to **stdout**.
1464///
1465/// Apply the stdout output to the child (workload) cluster where scout runs:
1466///
1467/// ```text
1468/// bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -
1469/// ```
1470///
1471/// Then configure the scout Deployment with:
1472/// ```text
1473/// BINDY_SCOUT_REMOTE_SECRET=<service-account>-remote-kubeconfig
1474/// ```
1475///
1476/// # Arguments
1477/// * `namespace` - Namespace on the queen-ship where the SA and Role are created
1478/// * `service_account` - Name of the ServiceAccount to create
1479/// * `server_override` - Optional API server URL to use in the kubeconfig instead of the
1480///   address from KUBECONFIG. Required when the KUBECONFIG address is not reachable from
1481///   inside the child cluster (e.g. `https://172.18.0.3:6443` for kind-to-kind).
1482/// * `allow_insecure` - Opt in to emitting `insecure-skip-tls-verify: true` when the
1483///   KUBECONFIG lacks `certificate-authority-data`. Defaults to `false`; the command
1484///   refuses rather than silently distributing MITM-susceptible kubeconfigs.
1485///
1486/// # Errors
1487/// Returns error if KUBECONFIG is unreadable, the Kubernetes API calls fail, or a
1488/// CA bundle is missing and `allow_insecure` is `false`.
1489pub async fn run_bootstrap_multi_cluster(
1490    namespace: &str,
1491    service_account: &str,
1492    server_override: Option<&str>,
1493    allow_insecure: bool,
1494) -> Result<()> {
1495    let client = Client::try_default()
1496        .await
1497        .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
1498
1499    let (kubeconfig_server, ca_data_b64, cluster_name) = read_cluster_info()?;
1500    let server = server_override.unwrap_or(&kubeconfig_server);
1501    if server_override.is_some() {
1502        eprintln!("ℹ Using server override: {server} (KUBECONFIG had: {kubeconfig_server})");
1503    }
1504
1505    apply_mc_service_account(&client, namespace, service_account).await?;
1506    apply_mc_writer_role(&client, namespace, service_account).await?;
1507    apply_mc_writer_role_binding(&client, namespace, service_account).await?;
1508
1509    let token_secret_name = format!("{service_account}{SA_TOKEN_SECRET_SUFFIX}");
1510    apply_mc_sa_token_secret(&client, namespace, service_account).await?;
1511    eprintln!("⏳ Waiting for SA token to be populated...");
1512    let token = wait_for_sa_token(&client, namespace, &token_secret_name).await?;
1513
1514    let kubeconfig_yaml = build_kubeconfig_yaml(
1515        &cluster_name,
1516        server,
1517        ca_data_b64.as_deref(),
1518        service_account,
1519        &token,
1520        allow_insecure,
1521    )?;
1522
1523    let secret = build_mc_kubeconfig_secret(namespace, service_account, &kubeconfig_yaml);
1524    let secret_name = format!("{service_account}{REMOTE_KUBECONFIG_SECRET_SUFFIX}");
1525    let secret_yaml =
1526        serde_yaml::to_string(&secret).context("Failed to serialize kubeconfig Secret")?;
1527
1528    println!("---");
1529    print!("{secret_yaml}");
1530
1531    eprintln!("\n✓ Apply the above Secret to each child cluster:");
1532    eprintln!("    bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -");
1533    eprintln!("Then set BINDY_SCOUT_REMOTE_SECRET={secret_name} on the scout Deployment.");
1534
1535    Ok(())
1536}
1537
1538/// Build a kubeconfig YAML string for the given service account token.
1539///
1540/// When `ca_data_b64` is `Some(...)` the CA data is embedded and TLS
1541/// verification is enforced. When `ca_data_b64` is `None` the function
1542/// refuses to produce output unless `allow_insecure` is `true`; in that case
1543/// it sets `insecure-skip-tls-verify: true` on the cluster entry.
1544///
1545/// Refusing insecure output by default prevents a bootstrap run against a
1546/// KUBECONFIG that lacks CA data from silently distributing kubeconfigs that
1547/// skip TLS verification (MITM risk against the child-cluster scout).
1548///
1549/// # Arguments
1550/// * `cluster_name` - Name of the cluster entry in the kubeconfig
1551/// * `server` - Kubernetes API server URL (e.g. `https://192.0.2.1:6443`)
1552/// * `ca_data_b64` - Base64-encoded PEM CA certificate, or `None` to skip TLS verify
1553/// * `sa_name` - Name of the service account / kubeconfig user entry
1554/// * `token` - Bearer token for the service account
1555/// * `allow_insecure` - Must be `true` to allow emitting `insecure-skip-tls-verify`
1556///   when `ca_data_b64` is `None`. Intended to be wired to an explicit CLI
1557///   opt-out flag (e.g. `--insecure-skip-tls-verify`).
1558///
1559/// # Errors
1560/// - Returns an error if `ca_data_b64` is `None` and `allow_insecure` is `false`.
1561/// - Returns an error if YAML serialization fails.
1562pub fn build_kubeconfig_yaml(
1563    cluster_name: &str,
1564    server: &str,
1565    ca_data_b64: Option<&str>,
1566    sa_name: &str,
1567    token: &str,
1568    allow_insecure: bool,
1569) -> Result<String> {
1570    if ca_data_b64.is_none() && !allow_insecure {
1571        return Err(anyhow!(
1572            "refusing to build kubeconfig for {server}: KUBECONFIG has no \
1573             certificate-authority-data and --insecure-skip-tls-verify was not set. \
1574             Provide a CA bundle or re-run with the explicit insecure opt-out."
1575        ));
1576    }
1577
1578    let cfg = BootstrapKubeconfig {
1579        api_version: "v1".to_string(),
1580        kind: "Config".to_string(),
1581        clusters: vec![BootstrapNamedCluster {
1582            name: cluster_name.to_string(),
1583            cluster: BootstrapCluster {
1584                server: server.to_string(),
1585                certificate_authority_data: ca_data_b64.map(str::to_string),
1586                insecure_skip_tls_verify: ca_data_b64.is_none().then_some(true),
1587            },
1588        }],
1589        contexts: vec![BootstrapNamedContext {
1590            name: "default".to_string(),
1591            context: BootstrapContext {
1592                cluster: cluster_name.to_string(),
1593                user: sa_name.to_string(),
1594            },
1595        }],
1596        current_context: "default".to_string(),
1597        users: vec![BootstrapNamedUser {
1598            name: sa_name.to_string(),
1599            user: BootstrapUser {
1600                token: token.to_string(),
1601            },
1602        }],
1603    };
1604    serde_yaml::to_string(&cfg).context("Failed to serialize kubeconfig YAML")
1605}
1606
1607/// Build the multi-cluster ServiceAccount on the queen-ship cluster.
1608pub fn build_mc_service_account(namespace: &str, sa_name: &str) -> ServiceAccount {
1609    ServiceAccount {
1610        metadata: ObjectMeta {
1611            name: Some(sa_name.to_string()),
1612            namespace: Some(namespace.to_string()),
1613            labels: Some(
1614                [
1615                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1616                    (
1617                        "app.kubernetes.io/component".to_string(),
1618                        MC_COMPONENT_LABEL.to_string(),
1619                    ),
1620                ]
1621                .into_iter()
1622                .collect(),
1623            ),
1624            ..Default::default()
1625        },
1626        ..Default::default()
1627    }
1628}
1629
1630/// Build the namespaced Role for the multi-cluster service account on the queen-ship.
1631///
1632/// Grants:
1633/// - Full CRUD on `arecords` — scout creates/deletes ARecords via the remote client
1634/// - Read-only on `dnszones` — scout validates zones before creating ARecords
1635///
1636/// Both resources live in the same target namespace on the queen-ship cluster, so a
1637/// namespaced `Role` is sufficient.  The scout watches DNSZones via
1638/// `Api::namespaced(remote_client, target_namespace)` (not `Api::all`), which means no
1639/// cluster-scoped `ClusterRole` is required.
1640///
1641/// The Role name matches the service account name, mirroring the convention in
1642/// `deploy/scout/remote-cluster-rbac.yaml`.
1643pub fn build_mc_writer_role(namespace: &str, sa_name: &str) -> Role {
1644    Role {
1645        metadata: ObjectMeta {
1646            name: Some(sa_name.to_string()),
1647            namespace: Some(namespace.to_string()),
1648            labels: Some(
1649                [
1650                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1651                    (
1652                        "app.kubernetes.io/component".to_string(),
1653                        MC_COMPONENT_LABEL.to_string(),
1654                    ),
1655                ]
1656                .into_iter()
1657                .collect(),
1658            ),
1659            ..Default::default()
1660        },
1661        rules: Some(vec![
1662            PolicyRule {
1663                api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1664                resources: Some(vec!["arecords".to_string()]),
1665                verbs: vec![
1666                    "get".to_string(),
1667                    "list".to_string(),
1668                    "watch".to_string(),
1669                    "create".to_string(),
1670                    "update".to_string(),
1671                    "patch".to_string(),
1672                    "delete".to_string(),
1673                ],
1674                ..Default::default()
1675            },
1676            PolicyRule {
1677                api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1678                resources: Some(vec!["dnszones".to_string()]),
1679                verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1680                ..Default::default()
1681            },
1682        ]),
1683    }
1684}
1685
1686/// Build the RoleBinding that binds the multi-cluster SA to its Role on the queen-ship.
1687///
1688/// The RoleBinding name matches the service account name, mirroring the convention in
1689/// `deploy/scout/remote-cluster-rbac.yaml`.
1690pub fn build_mc_writer_role_binding(namespace: &str, sa_name: &str) -> RoleBinding {
1691    RoleBinding {
1692        metadata: ObjectMeta {
1693            name: Some(sa_name.to_string()),
1694            namespace: Some(namespace.to_string()),
1695            labels: Some(
1696                [
1697                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1698                    (
1699                        "app.kubernetes.io/component".to_string(),
1700                        MC_COMPONENT_LABEL.to_string(),
1701                    ),
1702                ]
1703                .into_iter()
1704                .collect(),
1705            ),
1706            ..Default::default()
1707        },
1708        role_ref: RoleRef {
1709            api_group: "rbac.authorization.k8s.io".to_string(),
1710            kind: "Role".to_string(),
1711            name: sa_name.to_string(),
1712        },
1713        subjects: Some(vec![Subject {
1714            kind: "ServiceAccount".to_string(),
1715            name: sa_name.to_string(),
1716            namespace: Some(namespace.to_string()),
1717            api_group: Some(String::new()),
1718        }]),
1719    }
1720}
1721
1722/// Build the `kubernetes.io/service-account-token` Secret that triggers token generation.
1723///
1724/// After this Secret is applied, the Kubernetes token controller populates `data.token`
1725/// with a long-lived bearer token for the specified service account.
1726pub fn build_mc_sa_token_secret(namespace: &str, sa_name: &str) -> Secret {
1727    let mut annotations = BTreeMap::new();
1728    annotations.insert(
1729        "kubernetes.io/service-account.name".to_string(),
1730        sa_name.to_string(),
1731    );
1732
1733    Secret {
1734        metadata: ObjectMeta {
1735            name: Some(format!("{sa_name}{SA_TOKEN_SECRET_SUFFIX}")),
1736            namespace: Some(namespace.to_string()),
1737            labels: Some(
1738                [
1739                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1740                    (
1741                        "app.kubernetes.io/component".to_string(),
1742                        MC_COMPONENT_LABEL.to_string(),
1743                    ),
1744                ]
1745                .into_iter()
1746                .collect(),
1747            ),
1748            annotations: Some(annotations),
1749            ..Default::default()
1750        },
1751        type_: Some("kubernetes.io/service-account-token".to_string()),
1752        ..Default::default()
1753    }
1754}
1755
1756/// Build the `bindy.firestoned.io/remote-kubeconfig` Secret containing the kubeconfig YAML.
1757///
1758/// The kubeconfig is stored under the `kubeconfig` key in `data`. Copy this Secret to the
1759/// queen-ship cluster to grant the operator access to the remote child cluster.
1760pub fn build_mc_kubeconfig_secret(namespace: &str, sa_name: &str, kubeconfig_yaml: &str) -> Secret {
1761    let mut data = BTreeMap::new();
1762    data.insert(
1763        "kubeconfig".to_string(),
1764        ByteString(kubeconfig_yaml.as_bytes().to_vec()),
1765    );
1766
1767    Secret {
1768        metadata: ObjectMeta {
1769            name: Some(format!("{sa_name}{REMOTE_KUBECONFIG_SECRET_SUFFIX}")),
1770            namespace: Some(namespace.to_string()),
1771            labels: Some(
1772                [
1773                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1774                    (
1775                        "app.kubernetes.io/component".to_string(),
1776                        MC_COMPONENT_LABEL.to_string(),
1777                    ),
1778                    (
1779                        "bindy.firestoned.io/service-account".to_string(),
1780                        sa_name.to_string(),
1781                    ),
1782                ]
1783                .into_iter()
1784                .collect(),
1785            ),
1786            ..Default::default()
1787        },
1788        type_: Some(REMOTE_KUBECONFIG_SECRET_TYPE.to_string()),
1789        data: Some(data),
1790        ..Default::default()
1791    }
1792}
1793
1794// ---------------------------------------------------------------------------
1795// Multi-cluster apply helpers
1796// ---------------------------------------------------------------------------
1797
1798async fn apply_mc_service_account(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1799    let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
1800    let sa = build_mc_service_account(namespace, sa_name);
1801    api.patch(
1802        sa_name,
1803        &PatchParams::apply(MC_FIELD_MANAGER).force(),
1804        &Patch::Apply(&sa),
1805    )
1806    .await
1807    .with_context(|| format!("Failed to apply ServiceAccount/{sa_name}"))?;
1808    eprintln!("✓ ServiceAccount: {sa_name} (namespace: {namespace})");
1809    Ok(())
1810}
1811
1812async fn apply_mc_writer_role(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1813    let api: Api<Role> = Api::namespaced(client.clone(), namespace);
1814    let role = build_mc_writer_role(namespace, sa_name);
1815    api.patch(
1816        sa_name,
1817        &PatchParams::apply(MC_FIELD_MANAGER).force(),
1818        &Patch::Apply(&role),
1819    )
1820    .await
1821    .with_context(|| format!("Failed to apply Role/{sa_name}"))?;
1822    eprintln!("✓ Role: {sa_name} (namespace: {namespace})");
1823    Ok(())
1824}
1825
1826async fn apply_mc_writer_role_binding(
1827    client: &Client,
1828    namespace: &str,
1829    sa_name: &str,
1830) -> Result<()> {
1831    let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
1832    let rb = build_mc_writer_role_binding(namespace, sa_name);
1833    api.patch(
1834        sa_name,
1835        &PatchParams::apply(MC_FIELD_MANAGER).force(),
1836        &Patch::Apply(&rb),
1837    )
1838    .await
1839    .with_context(|| format!("Failed to apply RoleBinding/{sa_name}"))?;
1840    eprintln!("✓ RoleBinding: {sa_name} (namespace: {namespace})");
1841    Ok(())
1842}
1843
1844async fn apply_mc_sa_token_secret(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1845    let secret_name = format!("{sa_name}{SA_TOKEN_SECRET_SUFFIX}");
1846    let api: Api<Secret> = Api::namespaced(client.clone(), namespace);
1847    let secret = build_mc_sa_token_secret(namespace, sa_name);
1848    api.patch(
1849        &secret_name,
1850        &PatchParams::apply(MC_FIELD_MANAGER).force(),
1851        &Patch::Apply(&secret),
1852    )
1853    .await
1854    .with_context(|| format!("Failed to apply Secret/{secret_name}"))?;
1855    eprintln!("✓ Secret: {secret_name} (namespace: {namespace})");
1856    Ok(())
1857}
1858
1859/// Poll the SA token Secret until the `token` key is populated, up to a bounded timeout.
1860///
1861/// The Kubernetes token controller populates the token typically within milliseconds.
1862/// This function retries up to `SA_TOKEN_WAIT_MAX_ATTEMPTS` times with
1863/// `SA_TOKEN_WAIT_INTERVAL_MS` ms between attempts (max ~10 seconds total).
1864async fn wait_for_sa_token(client: &Client, namespace: &str, secret_name: &str) -> Result<String> {
1865    let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
1866
1867    for _ in 0..SA_TOKEN_WAIT_MAX_ATTEMPTS {
1868        let secret = secret_api
1869            .get(secret_name)
1870            .await
1871            .with_context(|| format!("Failed to read Secret/{secret_name}"))?;
1872
1873        if let Some(data) = &secret.data {
1874            if let Some(token_bytes) = data.get("token") {
1875                return String::from_utf8(token_bytes.0.clone())
1876                    .context("SA token bytes are not valid UTF-8");
1877            }
1878        }
1879
1880        tokio::time::sleep(Duration::from_millis(SA_TOKEN_WAIT_INTERVAL_MS)).await;
1881    }
1882
1883    Err(anyhow::anyhow!(
1884        "Timed out waiting for Secret/{secret_name} to be populated with a token"
1885    ))
1886}
1887
1888/// Read the current KUBECONFIG context's cluster server URL and CA certificate.
1889///
1890/// Returns `(server_url, ca_data_base64, cluster_name)`.
1891/// `ca_data_base64` is `None` when neither inline data nor a CA file is configured,
1892/// in which case `build_kubeconfig_yaml` sets `insecure-skip-tls-verify: true`.
1893fn read_cluster_info() -> Result<(String, Option<String>, String)> {
1894    let raw = Kubeconfig::read().context(
1895        "Failed to read KUBECONFIG — ensure KUBECONFIG env var is set or ~/.kube/config exists",
1896    )?;
1897
1898    let current_context = raw.current_context.as_deref().unwrap_or_default();
1899
1900    let named_context = raw
1901        .contexts
1902        .iter()
1903        .find(|c| c.name == current_context)
1904        .ok_or_else(|| {
1905            anyhow::anyhow!("Current context '{current_context}' not found in KUBECONFIG")
1906        })?;
1907
1908    let ctx = named_context
1909        .context
1910        .as_ref()
1911        .ok_or_else(|| anyhow::anyhow!("Context '{current_context}' has no data in KUBECONFIG"))?;
1912
1913    let cluster_name = ctx.cluster.clone();
1914
1915    let named_cluster = raw
1916        .clusters
1917        .iter()
1918        .find(|c| c.name == cluster_name)
1919        .ok_or_else(|| anyhow::anyhow!("Cluster '{cluster_name}' not found in KUBECONFIG"))?;
1920
1921    let cluster = named_cluster
1922        .cluster
1923        .as_ref()
1924        .ok_or_else(|| anyhow::anyhow!("Cluster '{cluster_name}' has no data in KUBECONFIG"))?;
1925
1926    let server = cluster
1927        .server
1928        .clone()
1929        .unwrap_or_else(|| "https://kubernetes.default.svc".to_string());
1930
1931    // Prefer inline base64-encoded CA; fall back to reading from a file path.
1932    let ca_data = if let Some(ca_b64) = &cluster.certificate_authority_data {
1933        Some(ca_b64.clone())
1934    } else if let Some(ca_path) = &cluster.certificate_authority {
1935        let bytes = std::fs::read(ca_path)
1936            .with_context(|| format!("Failed to read CA certificate file: {ca_path}"))?;
1937        Some(STANDARD.encode(bytes))
1938    } else {
1939        None
1940    };
1941
1942    Ok((server, ca_data, cluster_name))
1943}
1944
1945/// Build the scout Deployment manifest.
1946///
1947/// The container image defaults to `ghcr.io/firestoned/bindy:<image_tag>`.
1948/// Pass `registry` to override the registry/org prefix for air-gapped environments.
1949///
1950/// Scout CLI args (`--cluster-name`, `--default-ips`, `--default-zone`) are passed
1951/// directly to the container command so the scout behaves consistently with `bindy scout`.
1952pub fn build_scout_deployment(
1953    namespace: &str,
1954    opts: &ScoutDeploymentOptions<'_>,
1955) -> Result<Deployment> {
1956    let image = resolve_image(opts.registry, opts.image_tag);
1957
1958    let mut args: Vec<serde_json::Value> = vec![
1959        serde_json::json!("scout"),
1960        serde_json::json!("--cluster-name"),
1961        serde_json::json!(opts.cluster_name),
1962    ];
1963    if !opts.default_ips.is_empty() {
1964        args.push(serde_json::json!("--default-ips"));
1965        args.push(serde_json::json!(opts.default_ips.join(",")));
1966    }
1967    if let Some(zone) = opts.default_zone {
1968        args.push(serde_json::json!("--default-zone"));
1969        args.push(serde_json::json!(zone));
1970    }
1971
1972    let mut env: Vec<serde_json::Value> = vec![
1973        serde_json::json!({
1974            "name": "POD_NAMESPACE",
1975            "valueFrom": {"fieldRef": {"fieldPath": "metadata.namespace"}}
1976        }),
1977        serde_json::json!({"name": "RUST_LOG", "value": "info"}),
1978        serde_json::json!({"name": "RUST_LOG_FORMAT", "value": "text"}),
1979    ];
1980    if let Some(secret) = opts.remote_secret {
1981        env.push(serde_json::json!({"name": "BINDY_SCOUT_REMOTE_SECRET", "value": secret}));
1982    }
1983
1984    let value = serde_json::json!({
1985        "apiVersion": "apps/v1",
1986        "kind": "Deployment",
1987        "metadata": {
1988            "name": SCOUT_DEPLOYMENT_NAME,
1989            "namespace": namespace,
1990            "labels": {
1991                "app.kubernetes.io/name": "bindy",
1992                "app.kubernetes.io/component": "scout"
1993            }
1994        },
1995        "spec": {
1996            "replicas": 1,
1997            "selector": {
1998                "matchLabels": {
1999                    "app.kubernetes.io/name": "bindy",
2000                    "app.kubernetes.io/component": "scout"
2001                }
2002            },
2003            "template": {
2004                "metadata": {
2005                    "labels": {
2006                        "app.kubernetes.io/name": "bindy",
2007                        "app.kubernetes.io/component": "scout"
2008                    }
2009                },
2010                "spec": {
2011                    "serviceAccountName": SCOUT_SERVICE_ACCOUNT_NAME,
2012                    "securityContext": {"runAsNonRoot": true, "fsGroup": 65_534_i64},
2013                    "containers": [{
2014                        "name": "scout",
2015                        "image": image,
2016                        "imagePullPolicy": "IfNotPresent",
2017                        "args": args,
2018                        "env": env,
2019                        "securityContext": {
2020                            "allowPrivilegeEscalation": false,
2021                            "capabilities": {"drop": ["ALL"]},
2022                            "readOnlyRootFilesystem": true,
2023                            "runAsNonRoot": true,
2024                            "runAsUser": 65_534_i64
2025                        },
2026                        "resources": {
2027                            "limits": {"cpu": "200m", "memory": "128Mi"},
2028                            "requests": {"cpu": "50m", "memory": "64Mi"}
2029                        },
2030                        "volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}]
2031                    }],
2032                    "volumes": [{"name": "tmp", "emptyDir": {}}]
2033                }
2034            }
2035        }
2036    });
2037    serde_json::from_value(value).context("Failed to build scout Deployment")
2038}
2039
2040// ---------------------------------------------------------------------------
2041// Multi-cluster revoke
2042// ---------------------------------------------------------------------------
2043
2044/// Revoke all resources that `bootstrap mc` created for a given service account.
2045///
2046/// Deletes in reverse creation order (bindings before roles, roles before SA) so
2047/// that access is cut off at the earliest possible step. Missing resources are
2048/// silently skipped — it is safe to call this function more than once.
2049///
2050/// Run this command **against the queen-ship cluster** (the same context used
2051/// when the resources were originally created).
2052///
2053/// # Arguments
2054/// * `namespace` - Namespace the resources were created in
2055/// * `service_account` - Name of the ServiceAccount that was created by `bootstrap mc`
2056///
2057/// # Errors
2058/// Returns an error if the Kubernetes API call fails for any reason other than 404.
2059pub async fn run_revoke_multi_cluster(namespace: &str, service_account: &str) -> Result<()> {
2060    let client = Client::try_default()
2061        .await
2062        .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
2063
2064    // Revoke in reverse creation order: bindings → roles → secrets → SA
2065    delete_mc_role_binding(&client, namespace, service_account).await?;
2066    delete_mc_role(&client, namespace, service_account).await?;
2067
2068    let kubeconfig_secret = format!("{service_account}{REMOTE_KUBECONFIG_SECRET_SUFFIX}");
2069    delete_mc_secret(&client, namespace, &kubeconfig_secret).await?;
2070
2071    let token_secret = format!("{service_account}{SA_TOKEN_SECRET_SUFFIX}");
2072    delete_mc_secret(&client, namespace, &token_secret).await?;
2073
2074    delete_mc_service_account(&client, namespace, service_account).await?;
2075
2076    eprintln!("\n✓ Revoked multi-cluster access for: {service_account} (namespace: {namespace})");
2077    Ok(())
2078}
2079
2080async fn delete_mc_role_binding(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2081    let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
2082    match api.delete(sa_name, &DeleteParams::default()).await {
2083        Ok(_) => eprintln!("✓ Deleted RoleBinding: {sa_name} (namespace: {namespace})"),
2084        Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2085            eprintln!("  RoleBinding/{sa_name} not found, skipping");
2086        }
2087        Err(e) => {
2088            return Err(anyhow::Error::from(e))
2089                .with_context(|| format!("Failed to delete RoleBinding/{sa_name}"));
2090        }
2091    }
2092    Ok(())
2093}
2094
2095async fn delete_mc_role(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2096    let api: Api<Role> = Api::namespaced(client.clone(), namespace);
2097    match api.delete(sa_name, &DeleteParams::default()).await {
2098        Ok(_) => eprintln!("✓ Deleted Role: {sa_name} (namespace: {namespace})"),
2099        Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2100            eprintln!("  Role/{sa_name} not found, skipping");
2101        }
2102        Err(e) => {
2103            return Err(anyhow::Error::from(e))
2104                .with_context(|| format!("Failed to delete Role/{sa_name}"));
2105        }
2106    }
2107    Ok(())
2108}
2109
2110async fn delete_mc_secret(client: &Client, namespace: &str, secret_name: &str) -> Result<()> {
2111    let api: Api<Secret> = Api::namespaced(client.clone(), namespace);
2112    match api.delete(secret_name, &DeleteParams::default()).await {
2113        Ok(_) => eprintln!("✓ Deleted Secret: {secret_name} (namespace: {namespace})"),
2114        Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2115            eprintln!("  Secret/{secret_name} not found, skipping");
2116        }
2117        Err(e) => {
2118            return Err(anyhow::Error::from(e))
2119                .with_context(|| format!("Failed to delete Secret/{secret_name}"));
2120        }
2121    }
2122    Ok(())
2123}
2124
2125async fn delete_mc_service_account(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2126    let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
2127    match api.delete(sa_name, &DeleteParams::default()).await {
2128        Ok(_) => eprintln!("✓ Deleted ServiceAccount: {sa_name} (namespace: {namespace})"),
2129        Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2130            eprintln!("  ServiceAccount/{sa_name} not found, skipping");
2131        }
2132        Err(e) => {
2133            return Err(anyhow::Error::from(e))
2134                .with_context(|| format!("Failed to delete ServiceAccount/{sa_name}"));
2135        }
2136    }
2137    Ok(())
2138}