Skip to main content

propel_cloud/
client.rs

1use crate::executor::{GcloudExecutor, RealExecutor};
2use crate::gcloud::GcloudError;
3use propel_core::CloudRunConfig;
4use std::path::Path;
5
6/// GCP operations client, parameterized over the executor for testability.
7pub struct GcloudClient<E: GcloudExecutor = RealExecutor> {
8    executor: E,
9}
10
11impl GcloudClient<RealExecutor> {
12    pub fn new() -> Self {
13        Self {
14            executor: RealExecutor,
15        }
16    }
17}
18
19impl Default for GcloudClient<RealExecutor> {
20    fn default() -> Self {
21        Self::new()
22    }
23}
24
25impl<E: GcloudExecutor> GcloudClient<E> {
26    pub fn with_executor(executor: E) -> Self {
27        Self { executor }
28    }
29
30    // ── Preflight ──
31
32    pub async fn check_prerequisites(
33        &self,
34        project_id: &str,
35    ) -> Result<PreflightReport, PreflightError> {
36        let mut report = PreflightReport::default();
37
38        // 1. gcloud CLI available
39        match self
40            .executor
41            .exec(&args(["version", "--format", "value(version)"]))
42            .await
43        {
44            Ok(version) => report.gcloud_version = Some(version.trim().to_owned()),
45            Err(_) => return Err(PreflightError::GcloudNotInstalled),
46        }
47
48        // 2. Authenticated
49        match self
50            .executor
51            .exec(&args(["auth", "print-access-token", "--quiet"]))
52            .await
53        {
54            Ok(_) => report.authenticated = true,
55            Err(_) => return Err(PreflightError::NotAuthenticated),
56        }
57
58        // 3. Project accessible
59        match self
60            .executor
61            .exec(&args([
62                "projects",
63                "describe",
64                project_id,
65                "--format",
66                "value(name)",
67            ]))
68            .await
69        {
70            Ok(name) => report.project_name = Some(name.trim().to_owned()),
71            Err(_) => return Err(PreflightError::ProjectNotAccessible(project_id.to_owned())),
72        }
73
74        // 4. Required APIs enabled
75        for api in &[
76            "cloudbuild.googleapis.com",
77            "run.googleapis.com",
78            "secretmanager.googleapis.com",
79        ] {
80            let enabled = self
81                .executor
82                .exec(&args([
83                    "services",
84                    "list",
85                    "--project",
86                    project_id,
87                    "--filter",
88                    &format!("config.name={api}"),
89                    "--format",
90                    "value(config.name)",
91                ]))
92                .await
93                .map(|out| !out.trim().is_empty())
94                .unwrap_or(false);
95
96            if !enabled {
97                report.disabled_apis.push((*api).to_owned());
98            }
99        }
100
101        Ok(report)
102    }
103
104    // ── Doctor ──
105
106    /// Run all diagnostic checks without early return.
107    /// Returns a report with pass/fail for each check item.
108    pub async fn doctor(&self, project_id: Option<&str>) -> DoctorReport {
109        let mut report = DoctorReport::default();
110
111        // 1. gcloud CLI
112        match self.executor.exec(&args(["version"])).await {
113            Ok(v) => {
114                // Parse "Google Cloud SDK X.Y.Z" from first line
115                let version = v
116                    .lines()
117                    .next()
118                    .and_then(|line| line.strip_prefix("Google Cloud SDK "))
119                    .unwrap_or(v.trim());
120                report.gcloud = CheckResult::ok(version.trim());
121            }
122            Err(e) => report.gcloud = CheckResult::fail(&e.to_string()),
123        }
124
125        // 2. Active account
126        match self
127            .executor
128            .exec(&args(["config", "get-value", "account"]))
129            .await
130        {
131            Ok(a) if !a.trim().is_empty() => report.account = CheckResult::ok(a.trim()),
132            _ => report.account = CheckResult::fail("no active account"),
133        }
134
135        // 3. Project
136        let Some(pid) = project_id else {
137            report.project = CheckResult::fail("gcp_project_id not set in propel.toml");
138            return report;
139        };
140
141        match self
142            .executor
143            .exec(&args([
144                "projects",
145                "describe",
146                pid,
147                "--format",
148                "value(name)",
149            ]))
150            .await
151        {
152            Ok(name) => {
153                report.project = CheckResult::ok(&format!("{pid} ({name})", name = name.trim()))
154            }
155            Err(_) => {
156                report.project = CheckResult::fail(&format!("{pid} — not accessible"));
157                return report;
158            }
159        }
160
161        // 4. Billing
162        match self
163            .executor
164            .exec(&args([
165                "billing",
166                "projects",
167                "describe",
168                pid,
169                "--format",
170                "value(billingEnabled)",
171            ]))
172            .await
173        {
174            Ok(v) if v.trim().eq_ignore_ascii_case("true") => {
175                report.billing = CheckResult::ok("Enabled");
176            }
177            _ => report.billing = CheckResult::fail("Billing not enabled"),
178        }
179
180        // 5. Required APIs
181        let required_apis = [
182            ("Cloud Build", "cloudbuild.googleapis.com"),
183            ("Cloud Run", "run.googleapis.com"),
184            ("Secret Manager", "secretmanager.googleapis.com"),
185            ("Artifact Registry", "artifactregistry.googleapis.com"),
186        ];
187
188        for (label, api) in &required_apis {
189            let enabled = self
190                .executor
191                .exec(&args([
192                    "services",
193                    "list",
194                    "--project",
195                    pid,
196                    "--filter",
197                    &format!("config.name={api}"),
198                    "--format",
199                    "value(config.name)",
200                ]))
201                .await
202                .map(|out| !out.trim().is_empty())
203                .unwrap_or(false);
204
205            report.apis.push(ApiCheck {
206                name: label.to_string(),
207                result: if enabled {
208                    CheckResult::ok("Enabled")
209                } else {
210                    CheckResult::fail("Not enabled")
211                },
212            });
213        }
214
215        report
216    }
217
218    // ── Artifact Registry ──
219
220    /// Ensure the Artifact Registry Docker repository exists, creating it if needed.
221    pub async fn ensure_artifact_repo(
222        &self,
223        project_id: &str,
224        region: &str,
225        repo_name: &str,
226    ) -> Result<(), DeployError> {
227        let exists = self
228            .executor
229            .exec(&args([
230                "artifacts",
231                "repositories",
232                "describe",
233                repo_name,
234                "--project",
235                project_id,
236                "--location",
237                region,
238            ]))
239            .await
240            .is_ok();
241
242        if !exists {
243            self.executor
244                .exec(&args([
245                    "artifacts",
246                    "repositories",
247                    "create",
248                    repo_name,
249                    "--project",
250                    project_id,
251                    "--location",
252                    region,
253                    "--repository-format",
254                    "docker",
255                    "--quiet",
256                ]))
257                .await
258                .map_err(|e| DeployError::Deploy { source: e })?;
259        }
260
261        Ok(())
262    }
263
264    /// Delete a container image from Artifact Registry.
265    pub async fn delete_image(&self, image_tag: &str, project_id: &str) -> Result<(), DeployError> {
266        self.executor
267            .exec(&args([
268                "artifacts",
269                "docker",
270                "images",
271                "delete",
272                image_tag,
273                "--project",
274                project_id,
275                "--delete-tags",
276                "--quiet",
277            ]))
278            .await
279            .map_err(|e| DeployError::Deploy { source: e })?;
280
281        Ok(())
282    }
283
284    // ── Cloud Build ──
285
286    pub async fn submit_build(
287        &self,
288        bundle_dir: &Path,
289        project_id: &str,
290        image_tag: &str,
291    ) -> Result<(), CloudBuildError> {
292        let bundle_str = bundle_dir
293            .to_str()
294            .ok_or_else(|| CloudBuildError::InvalidPath(bundle_dir.to_path_buf()))?;
295
296        self.executor
297            .exec_streaming(&args([
298                "builds",
299                "submit",
300                bundle_str,
301                "--project",
302                project_id,
303                "--tag",
304                image_tag,
305                "--quiet",
306            ]))
307            .await
308            .map_err(|e| CloudBuildError::Submit { source: e })
309    }
310
311    // ── Cloud Run Deploy ──
312
313    pub async fn deploy_to_cloud_run(
314        &self,
315        service_name: &str,
316        image_tag: &str,
317        project_id: &str,
318        region: &str,
319        config: &CloudRunConfig,
320        secrets: &[String],
321    ) -> Result<String, DeployError> {
322        let cpu = config.cpu.to_string();
323        let min = config.min_instances.to_string();
324        let max = config.max_instances.to_string();
325        let concurrency = config.concurrency.to_string();
326        let port = config.port.to_string();
327
328        // Build --update-secrets value: ENV_VAR=SECRET_NAME:latest,...
329        let secrets_flag = secrets
330            .iter()
331            .map(|s| format!("{s}={s}:latest"))
332            .collect::<Vec<_>>()
333            .join(",");
334
335        let mut cmd = vec![
336            "run",
337            "deploy",
338            service_name,
339            "--image",
340            image_tag,
341            "--project",
342            project_id,
343            "--region",
344            region,
345            "--platform",
346            "managed",
347            "--memory",
348            &config.memory,
349            "--cpu",
350            &cpu,
351            "--min-instances",
352            &min,
353            "--max-instances",
354            &max,
355            "--concurrency",
356            &concurrency,
357            "--port",
358            &port,
359            "--allow-unauthenticated",
360            "--quiet",
361            "--format",
362            "value(status.url)",
363        ];
364
365        if !secrets_flag.is_empty() {
366            cmd.push("--update-secrets");
367            cmd.push(&secrets_flag);
368        }
369
370        let cmd_owned: Vec<String> = cmd.iter().map(|s| (*s).to_owned()).collect();
371
372        let output = self
373            .executor
374            .exec(&cmd_owned)
375            .await
376            .map_err(|e| DeployError::Deploy { source: e })?;
377
378        Ok(output.trim().to_owned())
379    }
380
381    pub async fn describe_service(
382        &self,
383        service_name: &str,
384        project_id: &str,
385        region: &str,
386    ) -> Result<String, DeployError> {
387        self.executor
388            .exec(&args([
389                "run",
390                "services",
391                "describe",
392                service_name,
393                "--project",
394                project_id,
395                "--region",
396                region,
397                "--format",
398                "yaml(status)",
399            ]))
400            .await
401            .map_err(|e| DeployError::Deploy { source: e })
402    }
403
404    pub async fn delete_service(
405        &self,
406        service_name: &str,
407        project_id: &str,
408        region: &str,
409    ) -> Result<(), DeployError> {
410        self.executor
411            .exec(&args([
412                "run",
413                "services",
414                "delete",
415                service_name,
416                "--project",
417                project_id,
418                "--region",
419                region,
420                "--quiet",
421            ]))
422            .await
423            .map_err(|e| DeployError::Deploy { source: e })?;
424
425        Ok(())
426    }
427
428    pub async fn read_logs(
429        &self,
430        service_name: &str,
431        project_id: &str,
432        region: &str,
433        limit: u32,
434    ) -> Result<(), DeployError> {
435        let limit_str = limit.to_string();
436        self.executor
437            .exec_streaming(&args([
438                "run",
439                "services",
440                "logs",
441                "read",
442                service_name,
443                "--project",
444                project_id,
445                "--region",
446                region,
447                "--limit",
448                &limit_str,
449            ]))
450            .await
451            .map_err(|e| DeployError::Logs { source: e })
452    }
453
454    pub async fn tail_logs(
455        &self,
456        service_name: &str,
457        project_id: &str,
458        region: &str,
459    ) -> Result<(), DeployError> {
460        self.executor
461            .exec_streaming(&args([
462                "run",
463                "services",
464                "logs",
465                "tail",
466                service_name,
467                "--project",
468                project_id,
469                "--region",
470                region,
471            ]))
472            .await
473            .map_err(|e| DeployError::Logs { source: e })
474    }
475
476    // ── Secret Manager ──
477
478    pub async fn set_secret(
479        &self,
480        project_id: &str,
481        secret_name: &str,
482        secret_value: &str,
483    ) -> Result<(), SecretError> {
484        let secret_exists = self
485            .executor
486            .exec(&args([
487                "secrets",
488                "describe",
489                secret_name,
490                "--project",
491                project_id,
492            ]))
493            .await
494            .is_ok();
495
496        if !secret_exists {
497            self.executor
498                .exec(&args([
499                    "secrets",
500                    "create",
501                    secret_name,
502                    "--project",
503                    project_id,
504                    "--replication-policy",
505                    "automatic",
506                ]))
507                .await
508                .map_err(|e| SecretError::Create { source: e })?;
509        }
510
511        self.executor
512            .exec_with_stdin(
513                &args([
514                    "secrets",
515                    "versions",
516                    "add",
517                    secret_name,
518                    "--project",
519                    project_id,
520                    "--data-file",
521                    "-",
522                ]),
523                secret_value.as_bytes(),
524            )
525            .await
526            .map_err(|e| SecretError::AddVersion { source: e })?;
527
528        Ok(())
529    }
530
531    pub async fn get_project_number(&self, project_id: &str) -> Result<String, DeployError> {
532        let output = self
533            .executor
534            .exec(&args([
535                "projects",
536                "describe",
537                project_id,
538                "--format",
539                "value(projectNumber)",
540            ]))
541            .await
542            .map_err(|e| DeployError::Deploy { source: e })?;
543
544        Ok(output.trim().to_owned())
545    }
546
547    pub async fn grant_secret_access(
548        &self,
549        project_id: &str,
550        secret_name: &str,
551        service_account: &str,
552    ) -> Result<(), SecretError> {
553        let member = format!("serviceAccount:{service_account}");
554        self.executor
555            .exec(&args([
556                "secrets",
557                "add-iam-policy-binding",
558                secret_name,
559                "--project",
560                project_id,
561                "--member",
562                &member,
563                "--role",
564                "roles/secretmanager.secretAccessor",
565            ]))
566            .await
567            .map_err(|e| SecretError::GrantAccess { source: e })?;
568
569        Ok(())
570    }
571
572    pub async fn revoke_secret_access(
573        &self,
574        project_id: &str,
575        secret_name: &str,
576        service_account: &str,
577    ) -> Result<(), SecretError> {
578        let member = format!("serviceAccount:{service_account}");
579        self.executor
580            .exec(&args([
581                "secrets",
582                "remove-iam-policy-binding",
583                secret_name,
584                "--project",
585                project_id,
586                "--member",
587                &member,
588                "--role",
589                "roles/secretmanager.secretAccessor",
590            ]))
591            .await
592            .map_err(|e| SecretError::RevokeAccess { source: e })?;
593
594        Ok(())
595    }
596
597    pub async fn list_secrets(&self, project_id: &str) -> Result<Vec<String>, SecretError> {
598        let output = self
599            .executor
600            .exec(&args([
601                "secrets",
602                "list",
603                "--project",
604                project_id,
605                "--format",
606                "value(name)",
607            ]))
608            .await
609            .map_err(|e| SecretError::List { source: e })?;
610
611        Ok(output.lines().map(|s| s.to_owned()).collect())
612    }
613
614    pub async fn delete_secret(
615        &self,
616        project_id: &str,
617        secret_name: &str,
618    ) -> Result<(), SecretError> {
619        self.executor
620            .exec(&args([
621                "secrets",
622                "delete",
623                secret_name,
624                "--project",
625                project_id,
626                "--quiet",
627            ]))
628            .await
629            .map_err(|e| SecretError::Delete { source: e })?;
630
631        Ok(())
632    }
633
634    // ── Workload Identity Federation ──
635
636    /// Create a Workload Identity Pool (idempotent).
637    /// Returns `true` if created, `false` if already existed.
638    pub async fn ensure_wif_pool(&self, project_id: &str, pool_id: &str) -> Result<bool, WifError> {
639        match self
640            .executor
641            .exec(&args([
642                "iam",
643                "workload-identity-pools",
644                "create",
645                pool_id,
646                "--project",
647                project_id,
648                "--location",
649                "global",
650                "--display-name",
651                "Propel GitHub Actions",
652            ]))
653            .await
654        {
655            Ok(_) => Ok(true),
656            Err(ref e) if is_already_exists(e) => Ok(false),
657            Err(e) => Err(WifError::CreatePool { source: e }),
658        }
659    }
660
661    /// Create an OIDC provider in a WIF pool (idempotent).
662    /// Returns `true` if created, `false` if already existed.
663    pub async fn ensure_oidc_provider(
664        &self,
665        project_id: &str,
666        pool_id: &str,
667        provider_id: &str,
668        github_repo: &str,
669    ) -> Result<bool, WifError> {
670        let attribute_condition = format!("assertion.repository == '{github_repo}'");
671
672        let cmd: Vec<String> = [
673            "iam",
674            "workload-identity-pools",
675            "providers",
676            "create-oidc",
677            provider_id,
678            "--project",
679            project_id,
680            "--location",
681            "global",
682            "--workload-identity-pool",
683            pool_id,
684            "--display-name",
685            "GitHub",
686            "--attribute-mapping",
687            "google.subject=assertion.sub,attribute.repository=assertion.repository",
688            "--attribute-condition",
689            &attribute_condition,
690            "--issuer-uri",
691            "https://token.actions.githubusercontent.com",
692        ]
693        .iter()
694        .map(|s| (*s).to_owned())
695        .collect();
696
697        match self.executor.exec(&cmd).await {
698            Ok(_) => Ok(true),
699            Err(ref e) if is_already_exists(e) => Ok(false),
700            Err(e) => Err(WifError::CreateProvider { source: e }),
701        }
702    }
703
704    /// Create a service account (idempotent).
705    /// Returns `true` if created, `false` if already existed.
706    pub async fn ensure_service_account(
707        &self,
708        project_id: &str,
709        sa_id: &str,
710        display_name: &str,
711    ) -> Result<bool, WifError> {
712        match self
713            .executor
714            .exec(&args([
715                "iam",
716                "service-accounts",
717                "create",
718                sa_id,
719                "--project",
720                project_id,
721                "--display-name",
722                display_name,
723            ]))
724            .await
725        {
726            Ok(_) => Ok(true),
727            Err(ref e) if is_already_exists(e) => Ok(false),
728            Err(e) => Err(WifError::CreateServiceAccount { source: e }),
729        }
730    }
731
732    /// Bind IAM roles to a service account.
733    pub async fn bind_iam_roles(
734        &self,
735        project_id: &str,
736        sa_email: &str,
737        roles: &[&str],
738    ) -> Result<(), WifError> {
739        let member = format!("serviceAccount:{sa_email}");
740        for role in roles {
741            self.executor
742                .exec(&args([
743                    "projects",
744                    "add-iam-policy-binding",
745                    project_id,
746                    "--member",
747                    &member,
748                    "--role",
749                    role,
750                    "--quiet",
751                ]))
752                .await
753                .map_err(|e| WifError::BindRole {
754                    role: (*role).to_owned(),
755                    source: e,
756                })?;
757        }
758
759        Ok(())
760    }
761
762    /// Bind a WIF pool to a service account, scoped to a GitHub repository.
763    pub async fn bind_wif_to_sa(
764        &self,
765        project_id: &str,
766        project_number: &str,
767        pool_id: &str,
768        sa_email: &str,
769        github_repo: &str,
770    ) -> Result<(), WifError> {
771        let member = format!(
772            "principalSet://iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/attribute.repository/{github_repo}"
773        );
774
775        self.executor
776            .exec(&args([
777                "iam",
778                "service-accounts",
779                "add-iam-policy-binding",
780                sa_email,
781                "--project",
782                project_id,
783                "--role",
784                "roles/iam.workloadIdentityUser",
785                "--member",
786                &member,
787            ]))
788            .await
789            .map_err(|e| WifError::BindWif { source: e })?;
790
791        Ok(())
792    }
793
794    /// Delete a Workload Identity Pool (and its providers).
795    pub async fn delete_wif_pool(&self, project_id: &str, pool_id: &str) -> Result<(), WifError> {
796        self.executor
797            .exec(&args([
798                "iam",
799                "workload-identity-pools",
800                "delete",
801                pool_id,
802                "--project",
803                project_id,
804                "--location",
805                "global",
806                "--quiet",
807            ]))
808            .await
809            .map_err(|e| WifError::DeletePool { source: e })?;
810
811        Ok(())
812    }
813
814    /// Delete a service account.
815    pub async fn delete_service_account(
816        &self,
817        project_id: &str,
818        sa_email: &str,
819    ) -> Result<(), WifError> {
820        self.executor
821            .exec(&args([
822                "iam",
823                "service-accounts",
824                "delete",
825                sa_email,
826                "--project",
827                project_id,
828                "--quiet",
829            ]))
830            .await
831            .map_err(|e| WifError::DeleteServiceAccount { source: e })?;
832
833        Ok(())
834    }
835}
836
837// ── Helper ──
838
839fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
840    a.iter().map(|s| (*s).to_owned()).collect()
841}
842
843/// Check whether a gcloud error indicates the resource already exists.
844fn is_already_exists(e: &GcloudError) -> bool {
845    match e {
846        GcloudError::CommandFailed { stderr, .. } => {
847            stderr.contains("ALREADY_EXISTS") || stderr.contains("already exists")
848        }
849        _ => false,
850    }
851}
852
853// ── Error types ──
854
855#[derive(Debug, Default)]
856pub struct PreflightReport {
857    pub gcloud_version: Option<String>,
858    pub authenticated: bool,
859    pub project_name: Option<String>,
860    pub disabled_apis: Vec<String>,
861}
862
863impl PreflightReport {
864    pub fn has_warnings(&self) -> bool {
865        !self.disabled_apis.is_empty()
866    }
867}
868
869#[derive(Debug, thiserror::Error)]
870pub enum PreflightError {
871    #[error("gcloud CLI not installed — https://cloud.google.com/sdk/docs/install")]
872    GcloudNotInstalled,
873
874    #[error("not authenticated — run: gcloud auth login")]
875    NotAuthenticated,
876
877    #[error("GCP project '{0}' is not accessible — check project ID and permissions")]
878    ProjectNotAccessible(String),
879}
880
881// ── Doctor types ──
882
883#[derive(Debug, Default)]
884pub struct DoctorReport {
885    pub gcloud: CheckResult,
886    pub account: CheckResult,
887    pub project: CheckResult,
888    pub billing: CheckResult,
889    pub apis: Vec<ApiCheck>,
890    pub config_file: CheckResult,
891}
892
893impl DoctorReport {
894    pub fn all_passed(&self) -> bool {
895        self.gcloud.passed
896            && self.account.passed
897            && self.project.passed
898            && self.billing.passed
899            && self.config_file.passed
900            && self.apis.iter().all(|a| a.result.passed)
901    }
902}
903
904#[derive(Debug, Default, Clone)]
905pub struct CheckResult {
906    pub passed: bool,
907    pub detail: String,
908}
909
910impl CheckResult {
911    pub fn ok(detail: &str) -> Self {
912        Self {
913            passed: true,
914            detail: detail.to_owned(),
915        }
916    }
917
918    pub fn fail(detail: &str) -> Self {
919        Self {
920            passed: false,
921            detail: detail.to_owned(),
922        }
923    }
924
925    pub fn icon(&self) -> &'static str {
926        if self.passed { "OK" } else { "NG" }
927    }
928}
929
930#[derive(Debug, Clone)]
931pub struct ApiCheck {
932    pub name: String,
933    pub result: CheckResult,
934}
935
936#[derive(Debug, thiserror::Error)]
937pub enum CloudBuildError {
938    #[error("bundle path is not valid UTF-8: {0}")]
939    InvalidPath(std::path::PathBuf),
940
941    #[error("cloud build submission failed")]
942    Submit { source: GcloudError },
943}
944
945#[derive(Debug, thiserror::Error)]
946pub enum DeployError {
947    #[error("cloud run deployment failed")]
948    Deploy { source: GcloudError },
949
950    #[error("failed to read logs")]
951    Logs { source: GcloudError },
952}
953
954#[derive(Debug, thiserror::Error)]
955pub enum SecretError {
956    #[error("failed to create secret")]
957    Create { source: GcloudError },
958
959    #[error("failed to add secret version")]
960    AddVersion { source: GcloudError },
961
962    #[error("failed to list secrets")]
963    List { source: GcloudError },
964
965    #[error("failed to grant secret access")]
966    GrantAccess { source: GcloudError },
967
968    #[error("failed to revoke secret access")]
969    RevokeAccess { source: GcloudError },
970
971    #[error("failed to delete secret")]
972    Delete { source: GcloudError },
973}
974
975#[derive(Debug, thiserror::Error)]
976pub enum WifError {
977    #[error("failed to create workload identity pool")]
978    CreatePool { source: GcloudError },
979
980    #[error("failed to create OIDC provider")]
981    CreateProvider { source: GcloudError },
982
983    #[error("failed to create service account")]
984    CreateServiceAccount { source: GcloudError },
985
986    #[error("failed to bind IAM role: {role}")]
987    BindRole { role: String, source: GcloudError },
988
989    #[error("failed to bind WIF to service account")]
990    BindWif { source: GcloudError },
991
992    #[error("failed to delete workload identity pool")]
993    DeletePool { source: GcloudError },
994
995    #[error("failed to delete service account")]
996    DeleteServiceAccount { source: GcloudError },
997}