1use anyhow::{anyhow, Context as _, Result};
56use base64::{engine::general_purpose::STANDARD, Engine as _};
57use k8s_openapi::api::apps::v1::Deployment;
58use k8s_openapi::api::core::v1::{Namespace, Secret, ServiceAccount};
59use k8s_openapi::api::rbac::v1::{
60 ClusterRole, ClusterRoleBinding, PolicyRule, Role, RoleBinding, RoleRef, Subject,
61};
62use k8s_openapi::apiextensions_apiserver::pkg::apis::apiextensions::v1::CustomResourceDefinition;
63use k8s_openapi::apimachinery::pkg::apis::meta::v1::ObjectMeta;
64use k8s_openapi::ByteString;
65use kube::{
66 api::{DeleteParams, Patch, PatchParams},
67 config::Kubeconfig,
68 Api, Client, CustomResourceExt,
69};
70use std::collections::BTreeMap;
71use std::time::Duration;
72
73use crate::crd::{
74 AAAARecord, ARecord, Bind9Cluster, Bind9Instance, CAARecord, CNAMERecord, ClusterBind9Provider,
75 DNSZone, MXRecord, NSRecord, SRVRecord, TXTRecord,
76};
77
78pub const DEFAULT_NAMESPACE: &str = "bindy-system";
80
81const FIELD_MANAGER: &str = "bindy-bootstrap";
83
84pub const SERVICE_ACCOUNT_NAME: &str = "bindy";
86
87pub const CLUSTER_ROLE_BINDING_NAME: &str = "bindy-rolebinding";
89
90pub const OPERATOR_ROLE_NAME: &str = "bindy-role";
92
93pub const SECRETS_WRITER_ROLE_NAME: &str = "bindy-secrets-writer";
99
100pub const SECRETS_WRITER_ROLE_BINDING_NAME: &str = "bindy-secrets-writer";
102
103pub const OPERATOR_DEPLOYMENT_NAME: &str = "bindy";
105
106pub const OPERATOR_IMAGE_BASE: &str = "ghcr.io/firestoned/bindy";
108
109pub const DEFAULT_IMAGE_TAG: &str = concat!("v", env!("CARGO_PKG_VERSION"));
114
115pub const BINDY_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role.yaml");
117pub const BINDY_ADMIN_ROLE_YAML: &str = include_str!("../deploy/operator/rbac/role-admin.yaml");
118
119pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML: &str =
125 include_str!("../deploy/operator/rbac/tokenreview-clusterrole.yaml");
126
127pub const BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML: &str =
133 include_str!("../deploy/operator/rbac/tokenreview-clusterrolebinding.yaml");
134
135pub const BINDCAR_TOKENREVIEW_NAME: &str = "bindcar-tokenreview";
137
138pub const OPERATOR_CLUSTER_ROLE_YAMLS: &[&str] = &[
140 BINDY_ROLE_YAML,
141 BINDY_ADMIN_ROLE_YAML,
142 BINDCAR_TOKENREVIEW_CLUSTER_ROLE_YAML,
143];
144
145pub const BINDCAR_TOKEN_VOLUME_NAME: &str = "bindcar-token";
149
150pub const BINDCAR_TOKEN_MOUNT_PATH: &str = "/var/run/secrets/bindcar";
156
157pub const BINDCAR_TOKEN_FILENAME: &str = "token";
159
160pub const BINDCAR_TOKEN_EXPIRATION_SECONDS: i64 = 3600;
165
166pub const POD_NAMESPACE_ENV: &str = "POD_NAMESPACE";
173
174pub const SCOUT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout";
180
181pub const SCOUT_CLUSTER_ROLE_NAME: &str = "bindy-scout";
183
184pub const SCOUT_CLUSTER_ROLE_BINDING_NAME: &str = "bindy-scout";
186
187pub const SCOUT_WRITER_ROLE_NAME: &str = "bindy-scout-writer";
189
190pub const SCOUT_WRITER_ROLE_BINDING_NAME: &str = "bindy-scout-writer";
192
193pub const SCOUT_SECRETS_READER_ROLE_NAME: &str = "bindy-scout-secrets-reader";
200
201pub const SCOUT_SECRETS_READER_ROLE_BINDING_NAME: &str = "bindy-scout-secrets-reader";
203
204pub const SCOUT_DEPLOYMENT_NAME: &str = "bindy-scout";
206
207pub const MC_DEFAULT_SERVICE_ACCOUNT_NAME: &str = "bindy-scout-remote";
213
214pub const DEFAULT_SCOUT_CLUSTER_NAME: &str = "default";
216
217const SCOUT_FIELD_MANAGER: &str = "bindy-bootstrap-scout";
219
220pub struct ScoutDeploymentOptions<'a> {
229 pub image_tag: &'a str,
231 pub registry: Option<&'a str>,
233 pub cluster_name: &'a str,
235 pub default_ips: &'a [String],
237 pub default_zone: Option<&'a str>,
239 pub remote_secret: Option<&'a str>,
242}
243
244const MC_FIELD_MANAGER: &str = "bindy-bootstrap-mc";
250
251pub const REMOTE_KUBECONFIG_SECRET_TYPE: &str = "bindy.firestoned.io/remote-kubeconfig";
256
257pub const SA_TOKEN_SECRET_SUFFIX: &str = "-token";
261
262pub const REMOTE_KUBECONFIG_SECRET_SUFFIX: &str = "-remote-kubeconfig";
266
267const MC_COMPONENT_LABEL: &str = "scout-remote";
269
270const HTTP_NOT_FOUND: u16 = 404;
273
274const SA_TOKEN_WAIT_MAX_ATTEMPTS: usize = 20;
276
277const SA_TOKEN_WAIT_INTERVAL_MS: u64 = 500;
279
280pub fn resolve_image(registry: Option<&str>, tag: &str) -> String {
295 match registry {
296 None => format!("{OPERATOR_IMAGE_BASE}:{tag}"),
297 Some(reg) => format!("{}/bindy:{}", reg.trim_end_matches('/'), tag),
298 }
299}
300
301pub async fn run_bootstrap_operator(
316 namespace: &str,
317 dry_run: bool,
318 image_tag: &str,
319 registry: Option<&str>,
320) -> Result<()> {
321 if dry_run {
322 return run_operator_dry_run(namespace, image_tag, registry);
323 }
324
325 let client = Client::try_default()
326 .await
327 .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
328
329 apply_namespace(&client, namespace).await?;
330 apply_crds(&client).await?;
331 apply_service_account(&client, namespace).await?;
332 for yaml in OPERATOR_CLUSTER_ROLE_YAMLS {
333 apply_cluster_role(&client, yaml).await?;
334 }
335 apply_cluster_role_binding(&client, namespace).await?;
336 apply_tokenreview_cluster_role_binding(&client, namespace).await?;
337 apply_secrets_writer_role(&client, namespace).await?;
338 apply_secrets_writer_role_binding(&client, namespace).await?;
339 apply_deployment(&client, namespace, image_tag, registry).await?;
340
341 println!("\nBootstrap complete! The operator is running in namespace {namespace}.");
342
343 Ok(())
344}
345
346pub async fn run_bootstrap_scout(
363 namespace: &str,
364 dry_run: bool,
365 opts: &ScoutDeploymentOptions<'_>,
366) -> Result<()> {
367 if dry_run {
368 return run_scout_dry_run(namespace, opts);
369 }
370
371 let client = Client::try_default()
372 .await
373 .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
374
375 apply_namespace(&client, namespace).await?;
376 apply_crds(&client).await?;
377 apply_scout_service_account(&client, namespace).await?;
378 apply_scout_cluster_role(&client).await?;
379 apply_scout_cluster_role_binding(&client, namespace).await?;
380 apply_scout_writer_role(&client, namespace).await?;
381 apply_scout_writer_role_binding(&client, namespace).await?;
382 if let Some(secret_name) = opts.remote_secret {
383 apply_scout_secrets_reader_role(&client, namespace, secret_name).await?;
384 apply_scout_secrets_reader_role_binding(&client, namespace).await?;
385 }
386 apply_scout_deployment(&client, namespace, opts).await?;
387
388 println!("\nBootstrap complete! Scout is running in namespace {namespace}.");
389
390 Ok(())
391}
392
393fn run_operator_dry_run(namespace: &str, image_tag: &str, registry: Option<&str>) -> Result<()> {
398 println!("# Dry-run mode — no resources will be applied\n");
399
400 print_resource("Namespace", &build_namespace(namespace))?;
401
402 for crd in build_all_crds()? {
403 let name = crd.metadata.name.as_deref().unwrap_or("unknown");
404 print_resource(&format!("CustomResourceDefinition/{name}"), &crd)?;
405 }
406
407 print_resource("ServiceAccount", &build_service_account(namespace))?;
408 for yaml in OPERATOR_CLUSTER_ROLE_YAMLS {
409 let role = parse_cluster_role(yaml)?;
410 let name = role.metadata.name.clone().unwrap_or_default();
411 print_resource(&format!("ClusterRole ({name})"), &role)?;
412 }
413 print_resource("ClusterRoleBinding", &build_cluster_role_binding(namespace))?;
414 print_resource(
415 "ClusterRoleBinding (bindcar-tokenreview)",
416 &build_tokenreview_cluster_role_binding(namespace)?,
417 )?;
418 print_resource(
419 "Role (secrets-writer)",
420 &build_secrets_writer_role(namespace),
421 )?;
422 print_resource(
423 "RoleBinding (secrets-writer)",
424 &build_secrets_writer_role_binding(namespace),
425 )?;
426 print_resource(
427 "Deployment",
428 &build_deployment(namespace, image_tag, registry)?,
429 )?;
430
431 println!("# Dry-run complete — no resources were applied");
432 Ok(())
433}
434
435fn run_scout_dry_run(namespace: &str, opts: &ScoutDeploymentOptions<'_>) -> Result<()> {
436 println!("# Dry-run mode (scout) — no resources will be applied\n");
437
438 print_resource("Namespace", &build_namespace(namespace))?;
439
440 for crd in build_all_crds()? {
441 let name = crd.metadata.name.as_deref().unwrap_or("unknown");
442 print_resource(&format!("CustomResourceDefinition/{name}"), &crd)?;
443 }
444
445 print_resource(
446 "ServiceAccount (scout)",
447 &build_scout_service_account(namespace),
448 )?;
449 print_resource("ClusterRole (scout)", &build_scout_cluster_role())?;
450 print_resource(
451 "ClusterRoleBinding (scout)",
452 &build_scout_cluster_role_binding(namespace),
453 )?;
454 print_resource("Role (scout-writer)", &build_scout_writer_role(namespace))?;
455 print_resource(
456 "RoleBinding (scout-writer)",
457 &build_scout_writer_role_binding(namespace),
458 )?;
459 if let Some(secret_name) = opts.remote_secret {
460 print_resource(
461 "Role (scout-secrets-reader)",
462 &build_scout_secrets_reader_role(namespace, secret_name),
463 )?;
464 print_resource(
465 "RoleBinding (scout-secrets-reader)",
466 &build_scout_secrets_reader_role_binding(namespace),
467 )?;
468 }
469 print_resource(
470 "Deployment (scout)",
471 &build_scout_deployment(namespace, opts)?,
472 )?;
473
474 println!("# Dry-run complete — no resources were applied");
475 Ok(())
476}
477
478fn print_resource<T: serde::Serialize>(label: &str, resource: &T) -> Result<()> {
479 let yaml =
480 serde_yaml::to_string(resource).with_context(|| format!("Failed to serialize {label}"))?;
481 println!("---\n# {label}");
482 print!("{yaml}");
483 Ok(())
484}
485
486async fn apply_namespace(client: &Client, name: &str) -> Result<()> {
491 let api: Api<Namespace> = Api::all(client.clone());
492 let ns = build_namespace(name);
493 api.patch(
494 name,
495 &PatchParams::apply(FIELD_MANAGER).force(),
496 &Patch::Apply(&ns),
497 )
498 .await
499 .with_context(|| format!("Failed to apply Namespace/{name}"))?;
500 println!("✓ Namespace: {name}");
501 Ok(())
502}
503
504async fn apply_crds(client: &Client) -> Result<()> {
505 let api: Api<CustomResourceDefinition> = Api::all(client.clone());
506 for crd in build_all_crds()? {
507 let name = crd.metadata.name.clone().unwrap_or_default();
508 api.patch(
509 &name,
510 &PatchParams::apply(FIELD_MANAGER).force(),
511 &Patch::Apply(&crd),
512 )
513 .await
514 .with_context(|| format!("Failed to apply CRD/{name}"))?;
515 println!("✓ CRD: {name}");
516 }
517 Ok(())
518}
519
520async fn apply_service_account(client: &Client, namespace: &str) -> Result<()> {
521 let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
522 let sa = build_service_account(namespace);
523 api.patch(
524 SERVICE_ACCOUNT_NAME,
525 &PatchParams::apply(FIELD_MANAGER).force(),
526 &Patch::Apply(&sa),
527 )
528 .await
529 .context("Failed to apply ServiceAccount/bindy")?;
530 println!("✓ ServiceAccount: {SERVICE_ACCOUNT_NAME} (namespace: {namespace})");
531 Ok(())
532}
533
534async fn apply_cluster_role(client: &Client, yaml: &str) -> Result<()> {
535 let role = parse_cluster_role(yaml)?;
536 let name = role.metadata.name.clone().unwrap_or_default();
537 let api: Api<ClusterRole> = Api::all(client.clone());
538 api.patch(
539 &name,
540 &PatchParams::apply(FIELD_MANAGER).force(),
541 &Patch::Apply(&role),
542 )
543 .await
544 .with_context(|| format!("Failed to apply ClusterRole/{name}"))?;
545 println!("✓ ClusterRole: {name}");
546 Ok(())
547}
548
549async fn apply_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
550 let api: Api<ClusterRoleBinding> = Api::all(client.clone());
551 let crb = build_cluster_role_binding(namespace);
552 api.patch(
553 CLUSTER_ROLE_BINDING_NAME,
554 &PatchParams::apply(FIELD_MANAGER).force(),
555 &Patch::Apply(&crb),
556 )
557 .await
558 .context("Failed to apply ClusterRoleBinding/bindy-rolebinding")?;
559 println!("✓ ClusterRoleBinding: {CLUSTER_ROLE_BINDING_NAME}");
560 Ok(())
561}
562
563async fn apply_tokenreview_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
566 let api: Api<ClusterRoleBinding> = Api::all(client.clone());
567 let crb = build_tokenreview_cluster_role_binding(namespace)?;
568 api.patch(
569 BINDCAR_TOKENREVIEW_NAME,
570 &PatchParams::apply(FIELD_MANAGER).force(),
571 &Patch::Apply(&crb),
572 )
573 .await
574 .with_context(|| format!("Failed to apply ClusterRoleBinding/{BINDCAR_TOKENREVIEW_NAME}"))?;
575 println!("✓ ClusterRoleBinding: {BINDCAR_TOKENREVIEW_NAME} (subject namespace: {namespace})");
576 Ok(())
577}
578
579async fn apply_secrets_writer_role(client: &Client, namespace: &str) -> Result<()> {
580 let api: Api<Role> = Api::namespaced(client.clone(), namespace);
581 let role = build_secrets_writer_role(namespace);
582 api.patch(
583 SECRETS_WRITER_ROLE_NAME,
584 &PatchParams::apply(FIELD_MANAGER).force(),
585 &Patch::Apply(&role),
586 )
587 .await
588 .with_context(|| format!("Failed to apply Role/{SECRETS_WRITER_ROLE_NAME}"))?;
589 println!("✓ Role: {SECRETS_WRITER_ROLE_NAME} (namespace: {namespace})");
590 Ok(())
591}
592
593async fn apply_secrets_writer_role_binding(client: &Client, namespace: &str) -> Result<()> {
594 let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
595 let rb = build_secrets_writer_role_binding(namespace);
596 api.patch(
597 SECRETS_WRITER_ROLE_BINDING_NAME,
598 &PatchParams::apply(FIELD_MANAGER).force(),
599 &Patch::Apply(&rb),
600 )
601 .await
602 .with_context(|| format!("Failed to apply RoleBinding/{SECRETS_WRITER_ROLE_BINDING_NAME}"))?;
603 println!("✓ RoleBinding: {SECRETS_WRITER_ROLE_BINDING_NAME} (namespace: {namespace})");
604 Ok(())
605}
606
607async fn apply_deployment(
608 client: &Client,
609 namespace: &str,
610 image_tag: &str,
611 registry: Option<&str>,
612) -> Result<()> {
613 let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
614 let deployment = build_deployment(namespace, image_tag, registry)?;
615 api.patch(
616 OPERATOR_DEPLOYMENT_NAME,
617 &PatchParams::apply(FIELD_MANAGER).force(),
618 &Patch::Apply(&deployment),
619 )
620 .await
621 .context("Failed to apply operator Deployment")?;
622 let image = resolve_image(registry, image_tag);
623 println!("✓ Deployment: {OPERATOR_DEPLOYMENT_NAME} (image: {image})");
624 Ok(())
625}
626
627async fn apply_scout_service_account(client: &Client, namespace: &str) -> Result<()> {
632 let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
633 let sa = build_scout_service_account(namespace);
634 api.patch(
635 SCOUT_SERVICE_ACCOUNT_NAME,
636 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
637 &Patch::Apply(&sa),
638 )
639 .await
640 .context("Failed to apply ServiceAccount/bindy-scout")?;
641 println!("✓ ServiceAccount: {SCOUT_SERVICE_ACCOUNT_NAME} (namespace: {namespace})");
642 Ok(())
643}
644
645async fn apply_scout_cluster_role(client: &Client) -> Result<()> {
646 let api: Api<ClusterRole> = Api::all(client.clone());
647 let role = build_scout_cluster_role();
648 api.patch(
649 SCOUT_CLUSTER_ROLE_NAME,
650 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
651 &Patch::Apply(&role),
652 )
653 .await
654 .with_context(|| format!("Failed to apply ClusterRole/{SCOUT_CLUSTER_ROLE_NAME}"))?;
655 println!("✓ ClusterRole: {SCOUT_CLUSTER_ROLE_NAME}");
656 Ok(())
657}
658
659async fn apply_scout_cluster_role_binding(client: &Client, namespace: &str) -> Result<()> {
660 let api: Api<ClusterRoleBinding> = Api::all(client.clone());
661 let crb = build_scout_cluster_role_binding(namespace);
662 api.patch(
663 SCOUT_CLUSTER_ROLE_BINDING_NAME,
664 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
665 &Patch::Apply(&crb),
666 )
667 .await
668 .context("Failed to apply ClusterRoleBinding/bindy-scout")?;
669 println!("✓ ClusterRoleBinding: {SCOUT_CLUSTER_ROLE_BINDING_NAME}");
670 Ok(())
671}
672
673async fn apply_scout_writer_role(client: &Client, namespace: &str) -> Result<()> {
674 let api: Api<Role> = Api::namespaced(client.clone(), namespace);
675 let role = build_scout_writer_role(namespace);
676 api.patch(
677 SCOUT_WRITER_ROLE_NAME,
678 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
679 &Patch::Apply(&role),
680 )
681 .await
682 .with_context(|| format!("Failed to apply Role/{SCOUT_WRITER_ROLE_NAME}"))?;
683 println!("✓ Role: {SCOUT_WRITER_ROLE_NAME} (namespace: {namespace})");
684 Ok(())
685}
686
687async fn apply_scout_writer_role_binding(client: &Client, namespace: &str) -> Result<()> {
688 let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
689 let rb = build_scout_writer_role_binding(namespace);
690 api.patch(
691 SCOUT_WRITER_ROLE_BINDING_NAME,
692 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
693 &Patch::Apply(&rb),
694 )
695 .await
696 .with_context(|| format!("Failed to apply RoleBinding/{SCOUT_WRITER_ROLE_BINDING_NAME}"))?;
697 println!("✓ RoleBinding: {SCOUT_WRITER_ROLE_BINDING_NAME} (namespace: {namespace})");
698 Ok(())
699}
700
701async fn apply_scout_secrets_reader_role(
705 client: &Client,
706 namespace: &str,
707 secret_name: &str,
708) -> Result<()> {
709 let api: Api<Role> = Api::namespaced(client.clone(), namespace);
710 let role = build_scout_secrets_reader_role(namespace, secret_name);
711 api.patch(
712 SCOUT_SECRETS_READER_ROLE_NAME,
713 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
714 &Patch::Apply(&role),
715 )
716 .await
717 .with_context(|| format!("Failed to apply Role/{SCOUT_SECRETS_READER_ROLE_NAME}"))?;
718 println!(
719 "✓ Role: {SCOUT_SECRETS_READER_ROLE_NAME} (namespace: {namespace}, secret: {secret_name})"
720 );
721 Ok(())
722}
723
724async fn apply_scout_secrets_reader_role_binding(client: &Client, namespace: &str) -> Result<()> {
727 let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
728 let rb = build_scout_secrets_reader_role_binding(namespace);
729 api.patch(
730 SCOUT_SECRETS_READER_ROLE_BINDING_NAME,
731 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
732 &Patch::Apply(&rb),
733 )
734 .await
735 .with_context(|| {
736 format!("Failed to apply RoleBinding/{SCOUT_SECRETS_READER_ROLE_BINDING_NAME}")
737 })?;
738 println!("✓ RoleBinding: {SCOUT_SECRETS_READER_ROLE_BINDING_NAME} (namespace: {namespace})");
739 Ok(())
740}
741
742async fn apply_scout_deployment(
743 client: &Client,
744 namespace: &str,
745 opts: &ScoutDeploymentOptions<'_>,
746) -> Result<()> {
747 let api: Api<Deployment> = Api::namespaced(client.clone(), namespace);
748 let deployment = build_scout_deployment(namespace, opts)?;
749 api.patch(
750 SCOUT_DEPLOYMENT_NAME,
751 &PatchParams::apply(SCOUT_FIELD_MANAGER).force(),
752 &Patch::Apply(&deployment),
753 )
754 .await
755 .context("Failed to apply scout Deployment")?;
756 let image = resolve_image(opts.registry, opts.image_tag);
757 println!("✓ Deployment: {SCOUT_DEPLOYMENT_NAME} (image: {image})");
758 Ok(())
759}
760
761pub fn build_namespace(name: &str) -> Namespace {
767 Namespace {
768 metadata: ObjectMeta {
769 name: Some(name.to_string()),
770 labels: Some(
771 [("kubernetes.io/metadata.name".to_string(), name.to_string())]
772 .into_iter()
773 .collect(),
774 ),
775 ..Default::default()
776 },
777 ..Default::default()
778 }
779}
780
781pub fn build_service_account(namespace: &str) -> ServiceAccount {
783 ServiceAccount {
784 metadata: ObjectMeta {
785 name: Some(SERVICE_ACCOUNT_NAME.to_string()),
786 namespace: Some(namespace.to_string()),
787 labels: Some(
788 [
789 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
790 (
791 "app.kubernetes.io/component".to_string(),
792 "rbac".to_string(),
793 ),
794 ]
795 .into_iter()
796 .collect(),
797 ),
798 ..Default::default()
799 },
800 ..Default::default()
801 }
802}
803
804pub fn build_cluster_role_binding(namespace: &str) -> ClusterRoleBinding {
808 ClusterRoleBinding {
809 metadata: ObjectMeta {
810 name: Some(CLUSTER_ROLE_BINDING_NAME.to_string()),
811 ..Default::default()
812 },
813 role_ref: RoleRef {
814 api_group: "rbac.authorization.k8s.io".to_string(),
815 kind: "ClusterRole".to_string(),
816 name: OPERATOR_ROLE_NAME.to_string(),
817 },
818 subjects: Some(vec![Subject {
819 kind: "ServiceAccount".to_string(),
820 name: SERVICE_ACCOUNT_NAME.to_string(),
821 namespace: Some(namespace.to_string()),
822 api_group: Some(String::new()),
823 }]),
824 }
825}
826
827pub fn build_secrets_writer_role(namespace: &str) -> Role {
834 Role {
835 metadata: ObjectMeta {
836 name: Some(SECRETS_WRITER_ROLE_NAME.to_string()),
837 namespace: Some(namespace.to_string()),
838 labels: Some(
839 [
840 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
841 (
842 "app.kubernetes.io/component".to_string(),
843 "rbac".to_string(),
844 ),
845 ]
846 .into_iter()
847 .collect(),
848 ),
849 ..Default::default()
850 },
851 rules: Some(vec![PolicyRule {
852 api_groups: Some(vec![String::new()]),
853 resources: Some(vec!["secrets".to_string()]),
854 verbs: vec![
855 "create".to_string(),
856 "update".to_string(),
857 "patch".to_string(),
858 "delete".to_string(),
859 ],
860 ..Default::default()
861 }]),
862 }
863}
864
865pub fn build_secrets_writer_role_binding(namespace: &str) -> RoleBinding {
868 RoleBinding {
869 metadata: ObjectMeta {
870 name: Some(SECRETS_WRITER_ROLE_BINDING_NAME.to_string()),
871 namespace: Some(namespace.to_string()),
872 labels: Some(
873 [
874 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
875 (
876 "app.kubernetes.io/component".to_string(),
877 "rbac".to_string(),
878 ),
879 ]
880 .into_iter()
881 .collect(),
882 ),
883 ..Default::default()
884 },
885 role_ref: RoleRef {
886 api_group: "rbac.authorization.k8s.io".to_string(),
887 kind: "Role".to_string(),
888 name: SECRETS_WRITER_ROLE_NAME.to_string(),
889 },
890 subjects: Some(vec![Subject {
891 kind: "ServiceAccount".to_string(),
892 name: SERVICE_ACCOUNT_NAME.to_string(),
893 namespace: Some(namespace.to_string()),
894 api_group: Some(String::new()),
895 }]),
896 }
897}
898
899pub fn build_deployment(
904 namespace: &str,
905 image_tag: &str,
906 registry: Option<&str>,
907) -> Result<Deployment> {
908 let image = resolve_image(registry, image_tag);
909 let value = serde_json::json!({
910 "apiVersion": "apps/v1",
911 "kind": "Deployment",
912 "metadata": {
913 "name": OPERATOR_DEPLOYMENT_NAME,
914 "namespace": namespace,
915 "labels": {"app": "bindy"}
916 },
917 "spec": {
918 "replicas": 1,
919 "selector": {"matchLabels": {"app": "bindy"}},
920 "template": {
921 "metadata": {"labels": {"app": "bindy"}},
922 "spec": {
923 "serviceAccountName": SERVICE_ACCOUNT_NAME,
924 "securityContext": {"runAsNonRoot": true, "fsGroup": 65_534_i64},
925 "containers": [{
926 "name": "bindy",
927 "image": image,
928 "imagePullPolicy": "IfNotPresent",
929 "args": ["run"],
930 "env": [
931 {"name": "RUST_LOG", "value": "info"},
932 {"name": "RUST_LOG_FORMAT", "value": "text"},
933 {"name": POD_NAMESPACE_ENV, "valueFrom": {"fieldRef": {"fieldPath": "metadata.namespace"}}},
936 {"name": "BINDY_ENABLE_LEADER_ELECTION", "value": "true"},
937 {"name": "BINDY_LEASE_NAME", "value": "bindy-leader"}
938 ],
939 "securityContext": {
940 "allowPrivilegeEscalation": false,
941 "capabilities": {"drop": ["ALL"]},
942 "readOnlyRootFilesystem": true,
943 "runAsNonRoot": true,
944 "runAsUser": 65_534_i64
945 },
946 "resources": {
947 "limits": {"cpu": "500m", "memory": "512Mi"},
948 "requests": {"cpu": "100m", "memory": "128Mi"}
949 },
950 "volumeMounts": [
951 {"name": "tmp", "mountPath": "/tmp"},
952 {
955 "name": BINDCAR_TOKEN_VOLUME_NAME,
956 "mountPath": BINDCAR_TOKEN_MOUNT_PATH,
957 "readOnly": true
958 }
959 ]
960 }],
961 "volumes": [
962 {"name": "tmp", "emptyDir": {}},
963 {
966 "name": BINDCAR_TOKEN_VOLUME_NAME,
967 "projected": {
968 "sources": [{
969 "serviceAccountToken": {
970 "audience": crate::constants::BINDCAR_TOKEN_AUDIENCE,
971 "expirationSeconds": BINDCAR_TOKEN_EXPIRATION_SECONDS,
972 "path": BINDCAR_TOKEN_FILENAME
973 }
974 }]
975 }
976 }
977 ]
978 }
979 }
980 }
981 });
982 serde_json::from_value(value).context("Failed to build operator Deployment")
983}
984
985pub fn parse_cluster_role(yaml: &str) -> Result<ClusterRole> {
987 serde_yaml::from_str(yaml).context("Failed to parse ClusterRole YAML")
988}
989
990pub fn parse_cluster_role_binding(yaml: &str) -> Result<ClusterRoleBinding> {
992 serde_yaml::from_str(yaml).context("Failed to parse ClusterRoleBinding YAML")
993}
994
995pub fn build_tokenreview_cluster_role_binding(namespace: &str) -> Result<ClusterRoleBinding> {
1009 let mut crb = parse_cluster_role_binding(BINDCAR_TOKENREVIEW_CLUSTER_ROLE_BINDING_YAML)?;
1010
1011 let Some(subjects) = crb.subjects.as_mut() else {
1012 return Err(anyhow!(
1013 "Embedded TokenReview ClusterRoleBinding has no subjects"
1014 ));
1015 };
1016
1017 for subject in subjects {
1018 subject.namespace = Some(namespace.to_string());
1019 }
1020
1021 Ok(crb)
1022}
1023
1024pub fn build_crd<T: CustomResourceExt>() -> Result<CustomResourceDefinition> {
1028 let crd = T::crd();
1029 let mut crd_json = serde_json::to_value(&crd).context("Failed to serialize CRD to JSON")?;
1030
1031 if let Some(versions) = crd_json["spec"]["versions"].as_array_mut() {
1032 if let Some(first) = versions.first_mut() {
1033 first["storage"] = serde_json::Value::Bool(true);
1034 first["served"] = serde_json::Value::Bool(true);
1035 }
1036 }
1037
1038 serde_json::from_value(crd_json).context("Failed to deserialize CRD from JSON")
1039}
1040
1041pub fn build_all_crds() -> Result<Vec<CustomResourceDefinition>> {
1043 Ok(vec![
1044 build_crd::<ARecord>()?,
1045 build_crd::<AAAARecord>()?,
1046 build_crd::<CNAMERecord>()?,
1047 build_crd::<MXRecord>()?,
1048 build_crd::<NSRecord>()?,
1049 build_crd::<TXTRecord>()?,
1050 build_crd::<SRVRecord>()?,
1051 build_crd::<CAARecord>()?,
1052 build_crd::<DNSZone>()?,
1053 build_crd::<Bind9Cluster>()?,
1054 build_crd::<ClusterBind9Provider>()?,
1055 build_crd::<Bind9Instance>()?,
1056 ])
1057}
1058
1059pub fn build_scout_service_account(namespace: &str) -> ServiceAccount {
1065 ServiceAccount {
1066 metadata: ObjectMeta {
1067 name: Some(SCOUT_SERVICE_ACCOUNT_NAME.to_string()),
1068 namespace: Some(namespace.to_string()),
1069 labels: Some(
1070 [
1071 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1072 (
1073 "app.kubernetes.io/component".to_string(),
1074 "scout".to_string(),
1075 ),
1076 ]
1077 .into_iter()
1078 .collect(),
1079 ),
1080 ..Default::default()
1081 },
1082 ..Default::default()
1083 }
1084}
1085
1086pub fn build_scout_cluster_role() -> ClusterRole {
1092 ClusterRole {
1093 metadata: ObjectMeta {
1094 name: Some(SCOUT_CLUSTER_ROLE_NAME.to_string()),
1095 labels: Some(
1096 [
1097 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1098 (
1099 "app.kubernetes.io/component".to_string(),
1100 "scout".to_string(),
1101 ),
1102 ]
1103 .into_iter()
1104 .collect(),
1105 ),
1106 ..Default::default()
1107 },
1108 rules: Some(vec![
1109 PolicyRule {
1114 api_groups: Some(vec!["networking.k8s.io".to_string()]),
1115 resources: Some(vec!["ingresses".to_string()]),
1116 verbs: vec![
1117 "get".to_string(),
1118 "list".to_string(),
1119 "watch".to_string(),
1120 "patch".to_string(),
1121 "update".to_string(),
1122 ],
1123 ..Default::default()
1124 },
1125 PolicyRule {
1127 api_groups: Some(vec!["networking.k8s.io".to_string()]),
1128 resources: Some(vec!["ingresses/finalizers".to_string()]),
1129 verbs: vec!["update".to_string()],
1130 ..Default::default()
1131 },
1132 PolicyRule {
1135 api_groups: Some(vec![String::new()]),
1136 resources: Some(vec!["services".to_string()]),
1137 verbs: vec![
1138 "get".to_string(),
1139 "list".to_string(),
1140 "watch".to_string(),
1141 "patch".to_string(),
1142 "update".to_string(),
1143 ],
1144 ..Default::default()
1145 },
1146 PolicyRule {
1148 api_groups: Some(vec![String::new()]),
1149 resources: Some(vec!["services/finalizers".to_string()]),
1150 verbs: vec!["update".to_string()],
1151 ..Default::default()
1152 },
1153 PolicyRule {
1159 api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1160 resources: Some(vec![
1161 "httproutes".to_string(),
1162 "tlsroutes".to_string(),
1163 "tcproutes".to_string(),
1164 ]),
1165 verbs: vec![
1166 "get".to_string(),
1167 "list".to_string(),
1168 "watch".to_string(),
1169 "patch".to_string(),
1170 "update".to_string(),
1171 ],
1172 ..Default::default()
1173 },
1174 PolicyRule {
1176 api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1177 resources: Some(vec![
1178 "httproutes/finalizers".to_string(),
1179 "tlsroutes/finalizers".to_string(),
1180 "tcproutes/finalizers".to_string(),
1181 ]),
1182 verbs: vec!["update".to_string()],
1183 ..Default::default()
1184 },
1185 PolicyRule {
1187 api_groups: Some(vec!["gateway.networking.k8s.io".to_string()]),
1188 resources: Some(vec!["gateways".to_string()]),
1189 verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1190 ..Default::default()
1191 },
1192 PolicyRule {
1194 api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1195 resources: Some(vec!["dnszones".to_string()]),
1196 verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1197 ..Default::default()
1198 },
1199 PolicyRule {
1211 api_groups: Some(vec![String::new()]),
1212 resources: Some(vec!["namespaces".to_string()]),
1213 verbs: vec!["get".to_string(), "list".to_string()],
1214 ..Default::default()
1215 },
1216 ]),
1217 ..Default::default()
1218 }
1219}
1220
1221pub fn build_scout_cluster_role_binding(namespace: &str) -> ClusterRoleBinding {
1225 ClusterRoleBinding {
1226 metadata: ObjectMeta {
1227 name: Some(SCOUT_CLUSTER_ROLE_BINDING_NAME.to_string()),
1228 labels: Some(
1229 [
1230 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1231 (
1232 "app.kubernetes.io/component".to_string(),
1233 "scout".to_string(),
1234 ),
1235 ]
1236 .into_iter()
1237 .collect(),
1238 ),
1239 ..Default::default()
1240 },
1241 role_ref: RoleRef {
1242 api_group: "rbac.authorization.k8s.io".to_string(),
1243 kind: "ClusterRole".to_string(),
1244 name: SCOUT_CLUSTER_ROLE_NAME.to_string(),
1245 },
1246 subjects: Some(vec![Subject {
1247 kind: "ServiceAccount".to_string(),
1248 name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1249 namespace: Some(namespace.to_string()),
1250 api_group: Some(String::new()),
1251 }]),
1252 }
1253}
1254
1255pub fn build_scout_writer_role(namespace: &str) -> Role {
1257 Role {
1258 metadata: ObjectMeta {
1259 name: Some(SCOUT_WRITER_ROLE_NAME.to_string()),
1260 namespace: Some(namespace.to_string()),
1261 labels: Some(
1262 [
1263 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1264 (
1265 "app.kubernetes.io/component".to_string(),
1266 "scout".to_string(),
1267 ),
1268 ]
1269 .into_iter()
1270 .collect(),
1271 ),
1272 ..Default::default()
1273 },
1274 rules: Some(vec![PolicyRule {
1275 api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1276 resources: Some(vec!["arecords".to_string()]),
1277 verbs: vec![
1278 "get".to_string(),
1279 "list".to_string(),
1280 "watch".to_string(),
1281 "create".to_string(),
1282 "update".to_string(),
1283 "patch".to_string(),
1284 "delete".to_string(),
1285 ],
1286 ..Default::default()
1287 }]),
1288 }
1289}
1290
1291pub fn build_scout_writer_role_binding(namespace: &str) -> RoleBinding {
1293 RoleBinding {
1294 metadata: ObjectMeta {
1295 name: Some(SCOUT_WRITER_ROLE_BINDING_NAME.to_string()),
1296 namespace: Some(namespace.to_string()),
1297 labels: Some(
1298 [
1299 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1300 (
1301 "app.kubernetes.io/component".to_string(),
1302 "scout".to_string(),
1303 ),
1304 ]
1305 .into_iter()
1306 .collect(),
1307 ),
1308 ..Default::default()
1309 },
1310 role_ref: RoleRef {
1311 api_group: "rbac.authorization.k8s.io".to_string(),
1312 kind: "Role".to_string(),
1313 name: SCOUT_WRITER_ROLE_NAME.to_string(),
1314 },
1315 subjects: Some(vec![Subject {
1316 kind: "ServiceAccount".to_string(),
1317 name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1318 namespace: Some(namespace.to_string()),
1319 api_group: Some(String::new()),
1320 }]),
1321 }
1322}
1323
1324pub fn build_scout_secrets_reader_role(namespace: &str, secret_name: &str) -> Role {
1332 Role {
1333 metadata: ObjectMeta {
1334 name: Some(SCOUT_SECRETS_READER_ROLE_NAME.to_string()),
1335 namespace: Some(namespace.to_string()),
1336 labels: Some(
1337 [
1338 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1339 (
1340 "app.kubernetes.io/component".to_string(),
1341 "scout".to_string(),
1342 ),
1343 ]
1344 .into_iter()
1345 .collect(),
1346 ),
1347 ..Default::default()
1348 },
1349 rules: Some(vec![PolicyRule {
1350 api_groups: Some(vec![String::new()]),
1351 resources: Some(vec!["secrets".to_string()]),
1352 resource_names: Some(vec![secret_name.to_string()]),
1353 verbs: vec!["get".to_string()],
1354 ..Default::default()
1355 }]),
1356 }
1357}
1358
1359pub fn build_scout_secrets_reader_role_binding(namespace: &str) -> RoleBinding {
1362 RoleBinding {
1363 metadata: ObjectMeta {
1364 name: Some(SCOUT_SECRETS_READER_ROLE_BINDING_NAME.to_string()),
1365 namespace: Some(namespace.to_string()),
1366 labels: Some(
1367 [
1368 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1369 (
1370 "app.kubernetes.io/component".to_string(),
1371 "scout".to_string(),
1372 ),
1373 ]
1374 .into_iter()
1375 .collect(),
1376 ),
1377 ..Default::default()
1378 },
1379 role_ref: RoleRef {
1380 api_group: "rbac.authorization.k8s.io".to_string(),
1381 kind: "Role".to_string(),
1382 name: SCOUT_SECRETS_READER_ROLE_NAME.to_string(),
1383 },
1384 subjects: Some(vec![Subject {
1385 kind: "ServiceAccount".to_string(),
1386 name: SCOUT_SERVICE_ACCOUNT_NAME.to_string(),
1387 namespace: Some(namespace.to_string()),
1388 api_group: Some(String::new()),
1389 }]),
1390 }
1391}
1392
1393#[derive(serde::Serialize)]
1399struct BootstrapKubeconfig {
1400 #[serde(rename = "apiVersion")]
1401 api_version: String,
1402 kind: String,
1403 clusters: Vec<BootstrapNamedCluster>,
1404 contexts: Vec<BootstrapNamedContext>,
1405 #[serde(rename = "current-context")]
1406 current_context: String,
1407 users: Vec<BootstrapNamedUser>,
1408}
1409
1410#[derive(serde::Serialize)]
1411struct BootstrapNamedCluster {
1412 name: String,
1413 cluster: BootstrapCluster,
1414}
1415
1416#[derive(serde::Serialize)]
1417struct BootstrapCluster {
1418 server: String,
1419 #[serde(
1420 rename = "certificate-authority-data",
1421 skip_serializing_if = "Option::is_none"
1422 )]
1423 certificate_authority_data: Option<String>,
1424 #[serde(
1425 rename = "insecure-skip-tls-verify",
1426 skip_serializing_if = "Option::is_none"
1427 )]
1428 insecure_skip_tls_verify: Option<bool>,
1429}
1430
1431#[derive(serde::Serialize)]
1432struct BootstrapNamedContext {
1433 name: String,
1434 context: BootstrapContext,
1435}
1436
1437#[derive(serde::Serialize)]
1438struct BootstrapContext {
1439 cluster: String,
1440 user: String,
1441}
1442
1443#[derive(serde::Serialize)]
1444struct BootstrapNamedUser {
1445 name: String,
1446 user: BootstrapUser,
1447}
1448
1449#[derive(serde::Serialize)]
1450struct BootstrapUser {
1451 token: String,
1452}
1453
1454pub async fn run_bootstrap_multi_cluster(
1490 namespace: &str,
1491 service_account: &str,
1492 server_override: Option<&str>,
1493 allow_insecure: bool,
1494) -> Result<()> {
1495 let client = Client::try_default()
1496 .await
1497 .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
1498
1499 let (kubeconfig_server, ca_data_b64, cluster_name) = read_cluster_info()?;
1500 let server = server_override.unwrap_or(&kubeconfig_server);
1501 if server_override.is_some() {
1502 eprintln!("ℹ Using server override: {server} (KUBECONFIG had: {kubeconfig_server})");
1503 }
1504
1505 apply_mc_service_account(&client, namespace, service_account).await?;
1506 apply_mc_writer_role(&client, namespace, service_account).await?;
1507 apply_mc_writer_role_binding(&client, namespace, service_account).await?;
1508
1509 let token_secret_name = format!("{service_account}{SA_TOKEN_SECRET_SUFFIX}");
1510 apply_mc_sa_token_secret(&client, namespace, service_account).await?;
1511 eprintln!("⏳ Waiting for SA token to be populated...");
1512 let token = wait_for_sa_token(&client, namespace, &token_secret_name).await?;
1513
1514 let kubeconfig_yaml = build_kubeconfig_yaml(
1515 &cluster_name,
1516 server,
1517 ca_data_b64.as_deref(),
1518 service_account,
1519 &token,
1520 allow_insecure,
1521 )?;
1522
1523 let secret = build_mc_kubeconfig_secret(namespace, service_account, &kubeconfig_yaml);
1524 let secret_name = format!("{service_account}{REMOTE_KUBECONFIG_SECRET_SUFFIX}");
1525 let secret_yaml =
1526 serde_yaml::to_string(&secret).context("Failed to serialize kubeconfig Secret")?;
1527
1528 println!("---");
1529 print!("{secret_yaml}");
1530
1531 eprintln!("\n✓ Apply the above Secret to each child cluster:");
1532 eprintln!(" bindy bootstrap mc | kubectl --context=<child-cluster> apply -f -");
1533 eprintln!("Then set BINDY_SCOUT_REMOTE_SECRET={secret_name} on the scout Deployment.");
1534
1535 Ok(())
1536}
1537
1538pub fn build_kubeconfig_yaml(
1563 cluster_name: &str,
1564 server: &str,
1565 ca_data_b64: Option<&str>,
1566 sa_name: &str,
1567 token: &str,
1568 allow_insecure: bool,
1569) -> Result<String> {
1570 if ca_data_b64.is_none() && !allow_insecure {
1571 return Err(anyhow!(
1572 "refusing to build kubeconfig for {server}: KUBECONFIG has no \
1573 certificate-authority-data and --insecure-skip-tls-verify was not set. \
1574 Provide a CA bundle or re-run with the explicit insecure opt-out."
1575 ));
1576 }
1577
1578 let cfg = BootstrapKubeconfig {
1579 api_version: "v1".to_string(),
1580 kind: "Config".to_string(),
1581 clusters: vec![BootstrapNamedCluster {
1582 name: cluster_name.to_string(),
1583 cluster: BootstrapCluster {
1584 server: server.to_string(),
1585 certificate_authority_data: ca_data_b64.map(str::to_string),
1586 insecure_skip_tls_verify: ca_data_b64.is_none().then_some(true),
1587 },
1588 }],
1589 contexts: vec![BootstrapNamedContext {
1590 name: "default".to_string(),
1591 context: BootstrapContext {
1592 cluster: cluster_name.to_string(),
1593 user: sa_name.to_string(),
1594 },
1595 }],
1596 current_context: "default".to_string(),
1597 users: vec![BootstrapNamedUser {
1598 name: sa_name.to_string(),
1599 user: BootstrapUser {
1600 token: token.to_string(),
1601 },
1602 }],
1603 };
1604 serde_yaml::to_string(&cfg).context("Failed to serialize kubeconfig YAML")
1605}
1606
1607pub fn build_mc_service_account(namespace: &str, sa_name: &str) -> ServiceAccount {
1609 ServiceAccount {
1610 metadata: ObjectMeta {
1611 name: Some(sa_name.to_string()),
1612 namespace: Some(namespace.to_string()),
1613 labels: Some(
1614 [
1615 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1616 (
1617 "app.kubernetes.io/component".to_string(),
1618 MC_COMPONENT_LABEL.to_string(),
1619 ),
1620 ]
1621 .into_iter()
1622 .collect(),
1623 ),
1624 ..Default::default()
1625 },
1626 ..Default::default()
1627 }
1628}
1629
1630pub fn build_mc_writer_role(namespace: &str, sa_name: &str) -> Role {
1644 Role {
1645 metadata: ObjectMeta {
1646 name: Some(sa_name.to_string()),
1647 namespace: Some(namespace.to_string()),
1648 labels: Some(
1649 [
1650 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1651 (
1652 "app.kubernetes.io/component".to_string(),
1653 MC_COMPONENT_LABEL.to_string(),
1654 ),
1655 ]
1656 .into_iter()
1657 .collect(),
1658 ),
1659 ..Default::default()
1660 },
1661 rules: Some(vec![
1662 PolicyRule {
1663 api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1664 resources: Some(vec!["arecords".to_string()]),
1665 verbs: vec![
1666 "get".to_string(),
1667 "list".to_string(),
1668 "watch".to_string(),
1669 "create".to_string(),
1670 "update".to_string(),
1671 "patch".to_string(),
1672 "delete".to_string(),
1673 ],
1674 ..Default::default()
1675 },
1676 PolicyRule {
1677 api_groups: Some(vec!["bindy.firestoned.io".to_string()]),
1678 resources: Some(vec!["dnszones".to_string()]),
1679 verbs: vec!["get".to_string(), "list".to_string(), "watch".to_string()],
1680 ..Default::default()
1681 },
1682 ]),
1683 }
1684}
1685
1686pub fn build_mc_writer_role_binding(namespace: &str, sa_name: &str) -> RoleBinding {
1691 RoleBinding {
1692 metadata: ObjectMeta {
1693 name: Some(sa_name.to_string()),
1694 namespace: Some(namespace.to_string()),
1695 labels: Some(
1696 [
1697 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1698 (
1699 "app.kubernetes.io/component".to_string(),
1700 MC_COMPONENT_LABEL.to_string(),
1701 ),
1702 ]
1703 .into_iter()
1704 .collect(),
1705 ),
1706 ..Default::default()
1707 },
1708 role_ref: RoleRef {
1709 api_group: "rbac.authorization.k8s.io".to_string(),
1710 kind: "Role".to_string(),
1711 name: sa_name.to_string(),
1712 },
1713 subjects: Some(vec![Subject {
1714 kind: "ServiceAccount".to_string(),
1715 name: sa_name.to_string(),
1716 namespace: Some(namespace.to_string()),
1717 api_group: Some(String::new()),
1718 }]),
1719 }
1720}
1721
1722pub fn build_mc_sa_token_secret(namespace: &str, sa_name: &str) -> Secret {
1727 let mut annotations = BTreeMap::new();
1728 annotations.insert(
1729 "kubernetes.io/service-account.name".to_string(),
1730 sa_name.to_string(),
1731 );
1732
1733 Secret {
1734 metadata: ObjectMeta {
1735 name: Some(format!("{sa_name}{SA_TOKEN_SECRET_SUFFIX}")),
1736 namespace: Some(namespace.to_string()),
1737 labels: Some(
1738 [
1739 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1740 (
1741 "app.kubernetes.io/component".to_string(),
1742 MC_COMPONENT_LABEL.to_string(),
1743 ),
1744 ]
1745 .into_iter()
1746 .collect(),
1747 ),
1748 annotations: Some(annotations),
1749 ..Default::default()
1750 },
1751 type_: Some("kubernetes.io/service-account-token".to_string()),
1752 ..Default::default()
1753 }
1754}
1755
1756pub fn build_mc_kubeconfig_secret(namespace: &str, sa_name: &str, kubeconfig_yaml: &str) -> Secret {
1761 let mut data = BTreeMap::new();
1762 data.insert(
1763 "kubeconfig".to_string(),
1764 ByteString(kubeconfig_yaml.as_bytes().to_vec()),
1765 );
1766
1767 Secret {
1768 metadata: ObjectMeta {
1769 name: Some(format!("{sa_name}{REMOTE_KUBECONFIG_SECRET_SUFFIX}")),
1770 namespace: Some(namespace.to_string()),
1771 labels: Some(
1772 [
1773 ("app.kubernetes.io/name".to_string(), "bindy".to_string()),
1774 (
1775 "app.kubernetes.io/component".to_string(),
1776 MC_COMPONENT_LABEL.to_string(),
1777 ),
1778 (
1779 "bindy.firestoned.io/service-account".to_string(),
1780 sa_name.to_string(),
1781 ),
1782 ]
1783 .into_iter()
1784 .collect(),
1785 ),
1786 ..Default::default()
1787 },
1788 type_: Some(REMOTE_KUBECONFIG_SECRET_TYPE.to_string()),
1789 data: Some(data),
1790 ..Default::default()
1791 }
1792}
1793
1794async fn apply_mc_service_account(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1799 let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
1800 let sa = build_mc_service_account(namespace, sa_name);
1801 api.patch(
1802 sa_name,
1803 &PatchParams::apply(MC_FIELD_MANAGER).force(),
1804 &Patch::Apply(&sa),
1805 )
1806 .await
1807 .with_context(|| format!("Failed to apply ServiceAccount/{sa_name}"))?;
1808 eprintln!("✓ ServiceAccount: {sa_name} (namespace: {namespace})");
1809 Ok(())
1810}
1811
1812async fn apply_mc_writer_role(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1813 let api: Api<Role> = Api::namespaced(client.clone(), namespace);
1814 let role = build_mc_writer_role(namespace, sa_name);
1815 api.patch(
1816 sa_name,
1817 &PatchParams::apply(MC_FIELD_MANAGER).force(),
1818 &Patch::Apply(&role),
1819 )
1820 .await
1821 .with_context(|| format!("Failed to apply Role/{sa_name}"))?;
1822 eprintln!("✓ Role: {sa_name} (namespace: {namespace})");
1823 Ok(())
1824}
1825
1826async fn apply_mc_writer_role_binding(
1827 client: &Client,
1828 namespace: &str,
1829 sa_name: &str,
1830) -> Result<()> {
1831 let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
1832 let rb = build_mc_writer_role_binding(namespace, sa_name);
1833 api.patch(
1834 sa_name,
1835 &PatchParams::apply(MC_FIELD_MANAGER).force(),
1836 &Patch::Apply(&rb),
1837 )
1838 .await
1839 .with_context(|| format!("Failed to apply RoleBinding/{sa_name}"))?;
1840 eprintln!("✓ RoleBinding: {sa_name} (namespace: {namespace})");
1841 Ok(())
1842}
1843
1844async fn apply_mc_sa_token_secret(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
1845 let secret_name = format!("{sa_name}{SA_TOKEN_SECRET_SUFFIX}");
1846 let api: Api<Secret> = Api::namespaced(client.clone(), namespace);
1847 let secret = build_mc_sa_token_secret(namespace, sa_name);
1848 api.patch(
1849 &secret_name,
1850 &PatchParams::apply(MC_FIELD_MANAGER).force(),
1851 &Patch::Apply(&secret),
1852 )
1853 .await
1854 .with_context(|| format!("Failed to apply Secret/{secret_name}"))?;
1855 eprintln!("✓ Secret: {secret_name} (namespace: {namespace})");
1856 Ok(())
1857}
1858
1859async fn wait_for_sa_token(client: &Client, namespace: &str, secret_name: &str) -> Result<String> {
1865 let secret_api: Api<Secret> = Api::namespaced(client.clone(), namespace);
1866
1867 for _ in 0..SA_TOKEN_WAIT_MAX_ATTEMPTS {
1868 let secret = secret_api
1869 .get(secret_name)
1870 .await
1871 .with_context(|| format!("Failed to read Secret/{secret_name}"))?;
1872
1873 if let Some(data) = &secret.data {
1874 if let Some(token_bytes) = data.get("token") {
1875 return String::from_utf8(token_bytes.0.clone())
1876 .context("SA token bytes are not valid UTF-8");
1877 }
1878 }
1879
1880 tokio::time::sleep(Duration::from_millis(SA_TOKEN_WAIT_INTERVAL_MS)).await;
1881 }
1882
1883 Err(anyhow::anyhow!(
1884 "Timed out waiting for Secret/{secret_name} to be populated with a token"
1885 ))
1886}
1887
1888fn read_cluster_info() -> Result<(String, Option<String>, String)> {
1894 let raw = Kubeconfig::read().context(
1895 "Failed to read KUBECONFIG — ensure KUBECONFIG env var is set or ~/.kube/config exists",
1896 )?;
1897
1898 let current_context = raw.current_context.as_deref().unwrap_or_default();
1899
1900 let named_context = raw
1901 .contexts
1902 .iter()
1903 .find(|c| c.name == current_context)
1904 .ok_or_else(|| {
1905 anyhow::anyhow!("Current context '{current_context}' not found in KUBECONFIG")
1906 })?;
1907
1908 let ctx = named_context
1909 .context
1910 .as_ref()
1911 .ok_or_else(|| anyhow::anyhow!("Context '{current_context}' has no data in KUBECONFIG"))?;
1912
1913 let cluster_name = ctx.cluster.clone();
1914
1915 let named_cluster = raw
1916 .clusters
1917 .iter()
1918 .find(|c| c.name == cluster_name)
1919 .ok_or_else(|| anyhow::anyhow!("Cluster '{cluster_name}' not found in KUBECONFIG"))?;
1920
1921 let cluster = named_cluster
1922 .cluster
1923 .as_ref()
1924 .ok_or_else(|| anyhow::anyhow!("Cluster '{cluster_name}' has no data in KUBECONFIG"))?;
1925
1926 let server = cluster
1927 .server
1928 .clone()
1929 .unwrap_or_else(|| "https://kubernetes.default.svc".to_string());
1930
1931 let ca_data = if let Some(ca_b64) = &cluster.certificate_authority_data {
1933 Some(ca_b64.clone())
1934 } else if let Some(ca_path) = &cluster.certificate_authority {
1935 let bytes = std::fs::read(ca_path)
1936 .with_context(|| format!("Failed to read CA certificate file: {ca_path}"))?;
1937 Some(STANDARD.encode(bytes))
1938 } else {
1939 None
1940 };
1941
1942 Ok((server, ca_data, cluster_name))
1943}
1944
1945pub fn build_scout_deployment(
1953 namespace: &str,
1954 opts: &ScoutDeploymentOptions<'_>,
1955) -> Result<Deployment> {
1956 let image = resolve_image(opts.registry, opts.image_tag);
1957
1958 let mut args: Vec<serde_json::Value> = vec![
1959 serde_json::json!("scout"),
1960 serde_json::json!("--cluster-name"),
1961 serde_json::json!(opts.cluster_name),
1962 ];
1963 if !opts.default_ips.is_empty() {
1964 args.push(serde_json::json!("--default-ips"));
1965 args.push(serde_json::json!(opts.default_ips.join(",")));
1966 }
1967 if let Some(zone) = opts.default_zone {
1968 args.push(serde_json::json!("--default-zone"));
1969 args.push(serde_json::json!(zone));
1970 }
1971
1972 let mut env: Vec<serde_json::Value> = vec![
1973 serde_json::json!({
1974 "name": "POD_NAMESPACE",
1975 "valueFrom": {"fieldRef": {"fieldPath": "metadata.namespace"}}
1976 }),
1977 serde_json::json!({"name": "RUST_LOG", "value": "info"}),
1978 serde_json::json!({"name": "RUST_LOG_FORMAT", "value": "text"}),
1979 ];
1980 if let Some(secret) = opts.remote_secret {
1981 env.push(serde_json::json!({"name": "BINDY_SCOUT_REMOTE_SECRET", "value": secret}));
1982 }
1983
1984 let value = serde_json::json!({
1985 "apiVersion": "apps/v1",
1986 "kind": "Deployment",
1987 "metadata": {
1988 "name": SCOUT_DEPLOYMENT_NAME,
1989 "namespace": namespace,
1990 "labels": {
1991 "app.kubernetes.io/name": "bindy",
1992 "app.kubernetes.io/component": "scout"
1993 }
1994 },
1995 "spec": {
1996 "replicas": 1,
1997 "selector": {
1998 "matchLabels": {
1999 "app.kubernetes.io/name": "bindy",
2000 "app.kubernetes.io/component": "scout"
2001 }
2002 },
2003 "template": {
2004 "metadata": {
2005 "labels": {
2006 "app.kubernetes.io/name": "bindy",
2007 "app.kubernetes.io/component": "scout"
2008 }
2009 },
2010 "spec": {
2011 "serviceAccountName": SCOUT_SERVICE_ACCOUNT_NAME,
2012 "securityContext": {"runAsNonRoot": true, "fsGroup": 65_534_i64},
2013 "containers": [{
2014 "name": "scout",
2015 "image": image,
2016 "imagePullPolicy": "IfNotPresent",
2017 "args": args,
2018 "env": env,
2019 "securityContext": {
2020 "allowPrivilegeEscalation": false,
2021 "capabilities": {"drop": ["ALL"]},
2022 "readOnlyRootFilesystem": true,
2023 "runAsNonRoot": true,
2024 "runAsUser": 65_534_i64
2025 },
2026 "resources": {
2027 "limits": {"cpu": "200m", "memory": "128Mi"},
2028 "requests": {"cpu": "50m", "memory": "64Mi"}
2029 },
2030 "volumeMounts": [{"name": "tmp", "mountPath": "/tmp"}]
2031 }],
2032 "volumes": [{"name": "tmp", "emptyDir": {}}]
2033 }
2034 }
2035 }
2036 });
2037 serde_json::from_value(value).context("Failed to build scout Deployment")
2038}
2039
2040pub async fn run_revoke_multi_cluster(namespace: &str, service_account: &str) -> Result<()> {
2060 let client = Client::try_default()
2061 .await
2062 .context("Failed to connect to Kubernetes cluster — is KUBECONFIG set?")?;
2063
2064 delete_mc_role_binding(&client, namespace, service_account).await?;
2066 delete_mc_role(&client, namespace, service_account).await?;
2067
2068 let kubeconfig_secret = format!("{service_account}{REMOTE_KUBECONFIG_SECRET_SUFFIX}");
2069 delete_mc_secret(&client, namespace, &kubeconfig_secret).await?;
2070
2071 let token_secret = format!("{service_account}{SA_TOKEN_SECRET_SUFFIX}");
2072 delete_mc_secret(&client, namespace, &token_secret).await?;
2073
2074 delete_mc_service_account(&client, namespace, service_account).await?;
2075
2076 eprintln!("\n✓ Revoked multi-cluster access for: {service_account} (namespace: {namespace})");
2077 Ok(())
2078}
2079
2080async fn delete_mc_role_binding(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2081 let api: Api<RoleBinding> = Api::namespaced(client.clone(), namespace);
2082 match api.delete(sa_name, &DeleteParams::default()).await {
2083 Ok(_) => eprintln!("✓ Deleted RoleBinding: {sa_name} (namespace: {namespace})"),
2084 Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2085 eprintln!(" RoleBinding/{sa_name} not found, skipping");
2086 }
2087 Err(e) => {
2088 return Err(anyhow::Error::from(e))
2089 .with_context(|| format!("Failed to delete RoleBinding/{sa_name}"));
2090 }
2091 }
2092 Ok(())
2093}
2094
2095async fn delete_mc_role(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2096 let api: Api<Role> = Api::namespaced(client.clone(), namespace);
2097 match api.delete(sa_name, &DeleteParams::default()).await {
2098 Ok(_) => eprintln!("✓ Deleted Role: {sa_name} (namespace: {namespace})"),
2099 Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2100 eprintln!(" Role/{sa_name} not found, skipping");
2101 }
2102 Err(e) => {
2103 return Err(anyhow::Error::from(e))
2104 .with_context(|| format!("Failed to delete Role/{sa_name}"));
2105 }
2106 }
2107 Ok(())
2108}
2109
2110async fn delete_mc_secret(client: &Client, namespace: &str, secret_name: &str) -> Result<()> {
2111 let api: Api<Secret> = Api::namespaced(client.clone(), namespace);
2112 match api.delete(secret_name, &DeleteParams::default()).await {
2113 Ok(_) => eprintln!("✓ Deleted Secret: {secret_name} (namespace: {namespace})"),
2114 Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2115 eprintln!(" Secret/{secret_name} not found, skipping");
2116 }
2117 Err(e) => {
2118 return Err(anyhow::Error::from(e))
2119 .with_context(|| format!("Failed to delete Secret/{secret_name}"));
2120 }
2121 }
2122 Ok(())
2123}
2124
2125async fn delete_mc_service_account(client: &Client, namespace: &str, sa_name: &str) -> Result<()> {
2126 let api: Api<ServiceAccount> = Api::namespaced(client.clone(), namespace);
2127 match api.delete(sa_name, &DeleteParams::default()).await {
2128 Ok(_) => eprintln!("✓ Deleted ServiceAccount: {sa_name} (namespace: {namespace})"),
2129 Err(kube::Error::Api(ref s)) if s.code == HTTP_NOT_FOUND => {
2130 eprintln!(" ServiceAccount/{sa_name} not found, skipping");
2131 }
2132 Err(e) => {
2133 return Err(anyhow::Error::from(e))
2134 .with_context(|| format!("Failed to delete ServiceAccount/{sa_name}"));
2135 }
2136 }
2137 Ok(())
2138}