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