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