bindy/
bootstrap.rs

1// Copyright (c) 2025 Erick Bourgeois, firestoned
2// SPDX-License-Identifier: MIT
3
4//! Bootstrap logic for `bindy bootstrap`.
5//!
6//! Applies all prerequisites to a Kubernetes cluster in order:
7//! 1. Namespace (`bindy-system` by default, or `--namespace`)
8//! 2. CRDs — generated from Rust types, always in sync with the operator
9//! 3. ServiceAccount (`bindy`)
10//! 4. ClusterRole (`bindy-role`) — operator permissions
11//! 5. ClusterRole (`bindy-admin-role`) — admin/destructive permissions
12//! 6. ClusterRoleBinding (`bindy-rolebinding`) — binds SA to operator role
13
14use anyhow::{Context as _, Result};
15use k8s_openapi::api::apps::v1::Deployment;
16use k8s_openapi::api::core::v1::{Namespace, ServiceAccount};
17use k8s_openapi::api::rbac::v1::{ClusterRole, ClusterRoleBinding, RoleRef, Subject};
18use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
19use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
20use kube::{
21    api::{Patch, PatchParams},
22    Api, Client, CustomResourceExt,
23};
24
25use crate::crd::{
26    AAAARecord, ARecord, Bind9Cluster, Bind9Instance, CAARecord, CNAMERecord, ClusterBind9Provider,
27    DNSZone, MXRecord, NSRecord, SRVRecord, TXTRecord,
28};
29
30/// Default namespace for the bindy operator deployment.
31pub const DEFAULT_NAMESPACE: &str = "bindy-system";
32
33/// Field manager name used for server-side apply.
34const FIELD_MANAGER: &str = "bindy-bootstrap";
35
36/// ServiceAccount name created for the operator.
37pub const SERVICE_ACCOUNT_NAME: &str = "bindy";
38
39/// ClusterRoleBinding name.
40pub const CLUSTER_ROLE_BINDING_NAME: &str = "bindy-rolebinding";
41
42/// Operator ClusterRole name.
43pub const OPERATOR_ROLE_NAME: &str = "bindy-role";
44
45/// Operator Deployment name.
46pub const OPERATOR_DEPLOYMENT_NAME: &str = "bindy";
47
48/// Container image registry and repository (without tag).
49pub const OPERATOR_IMAGE_BASE: &str = "ghcr.io/firestoned/bindy";
50
51/// Default image tag for the operator Deployment.
52///
53/// In debug builds (`cargo build`) this is `"latest"` so local development always
54/// pulls the most recent image without needing a version bump.
55/// In release builds (`cargo build --release`) this is `"v{CARGO_PKG_VERSION}"`
56/// (e.g. `"v0.5.0"`) so `bindy bootstrap` installs exactly the same version
57/// that was released alongside the binary.
58pub const DEFAULT_IMAGE_TAG: &str = if cfg!(debug_assertions) {
59    "latest"
60} else {
61    concat!("v", env!("CARGO_PKG_VERSION"))
62};
63
64/// Embedded RBAC YAML files — compiled into the binary so bootstrap is self-contained.
65pub const BINDY_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role.yaml");
66pub const BINDY_ADMIN_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role-admin.yaml");
67
68/// Run the bootstrap process.
69///
70/// When `dry_run` is `true`, prints the resources that would be applied to stdout (as YAML)
71/// without connecting to a cluster. When `false`, applies each resource via server-side apply
72/// (idempotent — safe to run multiple times).
73///
74/// # Arguments
75/// * `namespace` - Namespace to install bindy into (default: `bindy-system`)
76/// * `dry_run` - If true, print what would be applied without applying
77/// * `image_tag` - Image tag for the operator Deployment (e.g. `"v0.5.0"` or `"latest"`)
78///
79/// # Errors
80/// Returns error if Kubernetes API calls fail (in non-dry-run mode).
81pub async fn run_bootstrap(namespace: &str, dry_run: bool, image_tag: &str) -> Result<()> {
82    if dry_run {
83        return run_dry_run(namespace, image_tag);
84    }
85
86    let client = Client::try_default()
87        .await
88        .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
89
90    apply_namespace(&client, namespace).await?;
91    apply_crds(&client).await?;
92    apply_service_account(&client, namespace).await?;
93    apply_cluster_role(&client, BINDY_ROLE_YAML).await?;
94    apply_cluster_role(&client, BINDY_ADMIN_ROLE_YAML).await?;
95    apply_cluster_role_binding(&client, namespace).await?;
96    apply_deployment(&client, namespace, image_tag).await?;
97
98    println!("\nBootstrap complete! The operator is running in namespace {namespace}.");
99
100    Ok(())
101}
102
103// ---------------------------------------------------------------------------
104// Dry-run path — no cluster connection needed
105// ---------------------------------------------------------------------------
106
107fn run_dry_run(namespace: &str, image_tag: &str) -> Result<()> {
108    println!("# Dry-run mode — no resources will be applied\n");
109
110    print_resource("Namespace", &build_namespace(namespace))?;
111
112    for crd in build_all_crds()? {
113        let name = crd.metadata.name.as_deref().unwrap_or("unknown");
114        print_resource(&format!("CustomResourceDefinition/{name}"), &crd)?;
115    }
116
117    print_resource("ServiceAccount", &build_service_account(namespace))?;
118    print_resource(
119        "ClusterRole (operator)",
120        &parse_cluster_role(BINDY_ROLE_YAML)?,
121    )?;
122    print_resource(
123        "ClusterRole (admin)",
124        &parse_cluster_role(BINDY_ADMIN_ROLE_YAML)?,
125    )?;
126    print_resource("ClusterRoleBinding", &build_cluster_role_binding(namespace))?;
127    print_resource("Deployment", &build_deployment(namespace, image_tag)?)?;
128
129    println!("# Dry-run complete — no resources were applied");
130    Ok(())
131}
132
133fn print_resource<T: serde::Serialize>(label: &str, resource: &T) -> Result<()> {
134    let yaml =
135        serde_yaml::to_string(resource).with_context(|| format!("Failed to serialize {label}"))?;
136    println!("---\n# {label}");
137    print!("{yaml}");
138    Ok(())
139}
140
141// ---------------------------------------------------------------------------
142// Apply helpers
143// ---------------------------------------------------------------------------
144
145async fn apply_namespace(client: &Client, name: &str) -> Result<()> {
146    let api: Api<Namespace> = Api::all(client.clone());
147    let ns = build_namespace(name);
148    api.patch(
149        name,
150        &PatchParams::apply(FIELD_MANAGER).force(),
151        &Patch::Apply(&ns),
152    )
153    .await
154    .with_context(|| format!("Failed to apply Namespace/{name}"))?;
155    println!("✓ Namespace: {name}");
156    Ok(())
157}
158
159async fn apply_crds(client: &Client) -> Result<()> {
160    let api: Api<CustomResourceDefinition> = Api::all(client.clone());
161    for crd in build_all_crds()? {
162        let name = crd.metadata.name.clone().unwrap_or_default();
163        api.patch(
164            &name,
165            &PatchParams::apply(FIELD_MANAGER).force(),
166            &Patch::Apply(&crd),
167        )
168        .await
169        .with_context(|| format!("Failed to apply CRD/{name}"))?;
170        println!("✓ CRD: {name}");
171    }
172    Ok(())
173}
174
175async fn apply_service_account(client: &Client, namespace: &str) -> Result<()> {
176    let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
177    let sa = build_service_account(namespace);
178    api.patch(
179        SERVICE_ACCOUNT_NAME,
180        &PatchParams::apply(FIELD_MANAGER).force(),
181        &Patch::Apply(&sa),
182    )
183    .await
184    .context("Failed to apply ServiceAccount/bindy")?;
185    println!("✓ ServiceAccount: {SERVICE_ACCOUNT_NAME} (namespace: {namespace})");
186    Ok(())
187}
188
189async fn apply_cluster_role(client: &Client, yaml: &str) -> Result<()> {
190    let role = parse_cluster_role(yaml)?;
191    let name = role.metadata.name.clone().unwrap_or_default();
192    let api: Api<ClusterRole> = Api::all(client.clone());
193    api.patch(
194        &name,
195        &PatchParams::apply(FIELD_MANAGER).force(),
196        &Patch::Apply(&role),
197    )
198    .await
199    .with_context(|| format!("Failed to apply ClusterRole/{name}"))?;
200    println!("✓ ClusterRole: {name}");
201    Ok(())
202}
203
204async fn apply_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
205    let api: Api<ClusterRoleBinding> = Api::all(client.clone());
206    let crb = build_cluster_role_binding(namespace);
207    api.patch(
208        CLUSTER_ROLE_BINDING_NAME,
209        &PatchParams::apply(FIELD_MANAGER).force(),
210        &Patch::Apply(&crb),
211    )
212    .await
213    .context("Failed to apply ClusterRoleBinding/bindy-rolebinding")?;
214    println!("✓ ClusterRoleBinding: {CLUSTER_ROLE_BINDING_NAME}");
215    Ok(())
216}
217
218async fn apply_deployment(client: &Client, namespace: &str, image_tag: &str) -> Result<()> {
219    let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
220    let deployment = build_deployment(namespace, image_tag)?;
221    api.patch(
222        OPERATOR_DEPLOYMENT_NAME,
223        &PatchParams::apply(FIELD_MANAGER).force(),
224        &Patch::Apply(&deployment),
225    )
226    .await
227    .context("Failed to apply operator Deployment")?;
228    let image = format!("{OPERATOR_IMAGE_BASE}:{image_tag}");
229    println!("✓ Deployment: {OPERATOR_DEPLOYMENT_NAME} (image: {image})");
230    Ok(())
231}
232
233// ---------------------------------------------------------------------------
234// Resource builders (pub so tests can access them)
235// ---------------------------------------------------------------------------
236
237/// Build the operator namespace object.
238pub fn build_namespace(name: &str) -> Namespace {
239    Namespace {
240        metadata: ObjectMeta {
241            name: Some(name.to_string()),
242            labels: Some(
243                [("kubernetes.io/metadata.name".to_string(), name.to_string())]
244                    .into_iter()
245                    .collect(),
246            ),
247            ..Default::default()
248        },
249        ..Default::default()
250    }
251}
252
253/// Build the bindy ServiceAccount in the given namespace.
254pub fn build_service_account(namespace: &str) -> ServiceAccount {
255    ServiceAccount {
256        metadata: ObjectMeta {
257            name: Some(SERVICE_ACCOUNT_NAME.to_string()),
258            namespace: Some(namespace.to_string()),
259            labels: Some(
260                [
261                    ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
262                    (
263                        "app.kubernetes.io/component".to_string(),
264                        "rbac".to_string(),
265                    ),
266                ]
267                .into_iter()
268                .collect(),
269            ),
270            ..Default::default()
271        },
272        ..Default::default()
273    }
274}
275
276/// Build the ClusterRoleBinding that binds the bindy ServiceAccount to `bindy-role`.
277///
278/// The subject namespace is set to `namespace` so bootstrap works for custom namespaces.
279pub fn build_cluster_role_binding(namespace: &str) -> ClusterRoleBinding {
280    ClusterRoleBinding {
281        metadata: ObjectMeta {
282            name: Some(CLUSTER_ROLE_BINDING_NAME.to_string()),
283            ..Default::default()
284        },
285        role_ref: RoleRef {
286            api_group: "rbac.authorization.k8s.io".to_string(),
287            kind: "ClusterRole".to_string(),
288            name: OPERATOR_ROLE_NAME.to_string(),
289        },
290        subjects: Some(vec![Subject {
291            kind: "ServiceAccount".to_string(),
292            name: SERVICE_ACCOUNT_NAME.to_string(),
293            namespace: Some(namespace.to_string()),
294            api_group: Some(String::new()),
295        }]),
296    }
297}
298
299/// Build the operator Deployment manifest.
300///
301/// The container image is `ghcr.io/firestoned/bindy:<image_tag>`.
302pub fn build_deployment(namespace: &str, image_tag: &str) -> Result<Deployment> {
303    let image = format!("{OPERATOR_IMAGE_BASE}:{image_tag}");
304    let value = serde_json::json!({
305        "apiVersion": "apps/v1",
306        "kind": "Deployment",
307        "metadata": {
308            "name": OPERATOR_DEPLOYMENT_NAME,
309            "namespace": namespace,
310            "labels": {"app": "bindy"}
311        },
312        "spec": {
313            "replicas": 1,
314            "selector": {"matchLabels": {"app": "bindy"}},
315            "template": {
316                "metadata": {"labels": {"app": "bindy"}},
317                "spec": {
318                    "serviceAccountName": SERVICE_ACCOUNT_NAME,
319                    "securityContext": {"runAsNonRoot": true, "fsGroup": 65_534_i64},
320                    "containers": [{
321                        "name": "bindy",
322                        "image": image,
323                        "imagePullPolicy": "IfNotPresent",
324                        "args": ["run"],
325                        "env": [
326                            {"name": "RUST_LOG", "value": "info"},
327                            {"name": "RUST_LOG_FORMAT", "value": "text"},
328                            {"name": "BINDY_ENABLE_LEADER_ELECTION", "value": "true"},
329                            {"name": "BINDY_LEASE_NAME", "value": "bindy-leader"}
330                        ],
331                        "securityContext": {
332                            "allowPrivilegeEscalation": false,
333                            "capabilities": {"drop": ["ALL"]},
334                            "readOnlyRootFilesystem": true,
335                            "runAsNonRoot": true,
336                            "runAsUser": 65_534_i64
337                        },
338                        "resources": {
339                            "limits": {"cpu": "500m", "memory": "512Mi"},
340                            "requests": {"cpu": "100m", "memory": "128Mi"}
341                        },
342                        "volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}]
343                    }],
344                    "volumes": [{"name": "tmp", "emptyDir": {}}]
345                }
346            }
347        }
348    });
349    serde_json::from_value(value).context("Failed to build operator Deployment")
350}
351
352/// Parse a ClusterRole from embedded YAML.
353pub fn parse_cluster_role(yaml: &str) -> Result<ClusterRole> {
354    serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML")
355}
356
357/// Build a single CRD from a Rust type, ensuring `storage: true` and `served: true`.
358///
359/// Mirrors the logic in `src/bin/crdgen.rs` so bootstrap and crdgen stay in sync.
360pub fn build_crd<T: CustomResourceExt>() -> Result<CustomResourceDefinition> {
361    let crd = T::crd();
362    let mut crd_json = serde_json::to_value(&crd).context("Failed to serialize CRD to JSON")?;
363
364    if let Some(versions) = crd_json["spec"]["versions"].as_array_mut() {
365        if let Some(first) = versions.first_mut() {
366            first["storage"] = serde_json::Value::Bool(true);
367            first["served"] = serde_json::Value::Bool(true);
368        }
369    }
370
371    serde_json::from_value(crd_json).context("Failed to deserialize CRD from JSON")
372}
373
374/// Build all 12 CRDs in the same order as `crdgen`.
375pub fn build_all_crds() -> Result<Vec<CustomResourceDefinition>> {
376    Ok(vec![
377        build_crd::<ARecord>()?,
378        build_crd::<AAAARecord>()?,
379        build_crd::<CNAMERecord>()?,
380        build_crd::<MXRecord>()?,
381        build_crd::<NSRecord>()?,
382        build_crd::<TXTRecord>()?,
383        build_crd::<SRVRecord>()?,
384        build_crd::<CAARecord>()?,
385        build_crd::<DNSZone>()?,
386        build_crd::<Bind9Cluster>()?,
387        build_crd::<ClusterBind9Provider>()?,
388        build_crd::<Bind9Instance>()?,
389    ])
390}