1use 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
72pub const DEFAULT_NAMESPACE: &str = "bindy-system";
74
75const FIELD_MANAGER: &str = "bindy-bootstrap";
77
78pub const SERVICE_ACCOUNT_NAME: &str = "bindy";
80
81pub const CLUSTER_ROLE_BINDING_NAME: &str = "bindy-rolebinding";
83
84pub const OPERATOR_ROLE_NAME: &str = "bindy-role";
86
87pub const OPERATOR_DEPLOYMENT_NAME: &str = "bindy";
89
90pub const OPERATOR_IMAGE_BASE: &str = "ghcr.io/firestoned/bindy";
92
93pub const DEFAULT_IMAGE_TAG: &str = concat!("v", env!("CARGO_PKG_VERSION"));
98
99pub 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
103pub const SCOUT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout";
109
110pub const SCOUT_CLUSTER_ROLE_NAME: &str = "bindy-scout";
112
113pub const SCOUT_CLUSTER_ROLE_BINDING_NAME: &str = "bindy-scout";
115
116pub const SCOUT_WRITER_ROLE_NAME: &str = "bindy-scout-writer";
118
119pub const SCOUT_WRITER_ROLE_BINDING_NAME: &str = "bindy-scout-writer";
121
122pub const SCOUT_DEPLOYMENT_NAME: &str = "bindy-scout";
124
125pub const MC_DEFAULT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout-remote";
131
132pub const DEFAULT_SCOUT_CLUSTER_NAME: &str = "default";
134
135const SCOUT_FIELD_MANAGER: &str = "bindy-bootstrap-scout";
137
138pub struct ScoutDeploymentOptions<'a> {
147 pub image_tag: &'a str,
149 pub registry: Option<&'a str>,
151 pub cluster_name: &'a str,
153 pub default_ips: &'a [String],
155 pub default_zone: Option<&'a str>,
157 pub remote_secret: Option<&'a str>,
160}
161
162const MC_FIELD_MANAGER: &str = "bindy-bootstrap-mc";
168
169pub const REMOTE_KUBECONFIG_SECRET_TYPE: &str = "bindy.firestoned.io/remote-kubeconfig";
174
175pub const SA_TOKEN_SECRET_SUFFIX: &str = "-token";
179
180pub const REMOTE_KUBECONFIG_SECRET_SUFFIX: &str = "-remote-kubeconfig";
184
185const MC_COMPONENT_LABEL: &str = "scout-remote";
187
188const HTTP_NOT_FOUND: u16 = 404;
191
192const SA_TOKEN_WAIT_MAX_ATTEMPTS: usize = 20;
194
195const SA_TOKEN_WAIT_INTERVAL_MS: u64 = 500;
197
198pub 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
219pub 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
260pub 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
303fn 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
377async 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
474async 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
567pub 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
587pub 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
610pub 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
633pub 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
691pub fn parse_cluster_role(yaml: &str) -> Result<ClusterRole> {
693 serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML")
694}
695
696pub 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
713pub 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
731pub 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
758pub 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 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 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 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 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 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 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 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
853pub 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
887pub 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
923pub 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#[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
1017pub 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
1101pub 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
1170pub 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
1193pub 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
1249pub 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
1285pub 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
1319pub 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
1357async 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
1422async 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
1451fn 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 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
1508pub 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
1603pub 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 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}