1use 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
30pub const DEFAULT_NAMESPACE: &str = "bindy-system";
32
33const FIELD_MANAGER: &str = "bindy-bootstrap";
35
36pub const SERVICE_ACCOUNT_NAME: &str = "bindy";
38
39pub const CLUSTER_ROLE_BINDING_NAME: &str = "bindy-rolebinding";
41
42pub const OPERATOR_ROLE_NAME: &str = "bindy-role";
44
45pub const OPERATOR_DEPLOYMENT_NAME: &str = "bindy";
47
48pub const OPERATOR_IMAGE_BASE: &str = "ghcr.io/firestoned/bindy";
50
51pub const DEFAULT_IMAGE_TAG: &str = if cfg!(debug_assertions) {
59 "latest"
60} else {
61 concat!("v", env!("CARGO_PKG_VERSION"))
62};
63
64pub 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
68pub 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
103fn 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
141async 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
233pub 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
253pub 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
276pub 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
299pub 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
352pub fn parse_cluster_role(yaml: &str) -> Result<ClusterRole> {
354 serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML")
355}
356
357pub 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
374pub 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}