Skip to main content

propel_cloud/
client.rs

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