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-identity-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    ) -> Result<(), DeployError> {
434        self.executor
435            .exec_streaming(&args([
436                "run",
437                "services",
438                "logs",
439                "read",
440                service_name,
441                "--project",
442                project_id,
443                "--region",
444                region,
445                "--limit",
446                "100",
447            ]))
448            .await
449            .map_err(|e| DeployError::Deploy { source: e })
450    }
451
452    // ── Secret Manager ──
453
454    pub async fn set_secret(
455        &self,
456        project_id: &str,
457        secret_name: &str,
458        secret_value: &str,
459    ) -> Result<(), SecretError> {
460        let secret_exists = self
461            .executor
462            .exec(&args([
463                "secrets",
464                "describe",
465                secret_name,
466                "--project",
467                project_id,
468            ]))
469            .await
470            .is_ok();
471
472        if !secret_exists {
473            self.executor
474                .exec(&args([
475                    "secrets",
476                    "create",
477                    secret_name,
478                    "--project",
479                    project_id,
480                    "--replication-policy",
481                    "automatic",
482                ]))
483                .await
484                .map_err(|e| SecretError::Create { source: e })?;
485        }
486
487        self.executor
488            .exec_with_stdin(
489                &args([
490                    "secrets",
491                    "versions",
492                    "add",
493                    secret_name,
494                    "--project",
495                    project_id,
496                    "--data-file",
497                    "-",
498                ]),
499                secret_value.as_bytes(),
500            )
501            .await
502            .map_err(|e| SecretError::AddVersion { source: e })?;
503
504        Ok(())
505    }
506
507    pub async fn get_project_number(&self, project_id: &str) -> Result<String, DeployError> {
508        let output = self
509            .executor
510            .exec(&args([
511                "projects",
512                "describe",
513                project_id,
514                "--format",
515                "value(projectNumber)",
516            ]))
517            .await
518            .map_err(|e| DeployError::Deploy { source: e })?;
519
520        Ok(output.trim().to_owned())
521    }
522
523    pub async fn grant_secret_access(
524        &self,
525        project_id: &str,
526        secret_name: &str,
527        service_account: &str,
528    ) -> Result<(), SecretError> {
529        let member = format!("serviceAccount:{service_account}");
530        self.executor
531            .exec(&args([
532                "secrets",
533                "add-iam-policy-binding",
534                secret_name,
535                "--project",
536                project_id,
537                "--member",
538                &member,
539                "--role",
540                "roles/secretmanager.secretAccessor",
541            ]))
542            .await
543            .map_err(|e| SecretError::GrantAccess { source: e })?;
544
545        Ok(())
546    }
547
548    pub async fn list_secrets(&self, project_id: &str) -> Result<Vec<String>, SecretError> {
549        let output = self
550            .executor
551            .exec(&args([
552                "secrets",
553                "list",
554                "--project",
555                project_id,
556                "--format",
557                "value(name)",
558            ]))
559            .await
560            .map_err(|e| SecretError::List { source: e })?;
561
562        Ok(output.lines().map(|s| s.to_owned()).collect())
563    }
564
565    pub async fn delete_secret(
566        &self,
567        project_id: &str,
568        secret_name: &str,
569    ) -> Result<(), SecretError> {
570        self.executor
571            .exec(&args([
572                "secrets",
573                "delete",
574                secret_name,
575                "--project",
576                project_id,
577                "--quiet",
578            ]))
579            .await
580            .map_err(|e| SecretError::Delete { source: e })?;
581
582        Ok(())
583    }
584}
585
586// ── Helper ──
587
588fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
589    a.iter().map(|s| (*s).to_owned()).collect()
590}
591
592// ── Error types ──
593
594#[derive(Debug, Default)]
595pub struct PreflightReport {
596    pub gcloud_version: Option<String>,
597    pub authenticated: bool,
598    pub project_name: Option<String>,
599    pub disabled_apis: Vec<String>,
600}
601
602impl PreflightReport {
603    pub fn has_warnings(&self) -> bool {
604        !self.disabled_apis.is_empty()
605    }
606}
607
608#[derive(Debug, thiserror::Error)]
609pub enum PreflightError {
610    #[error("gcloud CLI not installed — https://cloud.google.com/sdk/docs/install")]
611    GcloudNotInstalled,
612
613    #[error("not authenticated — run: gcloud auth login")]
614    NotAuthenticated,
615
616    #[error("GCP project '{0}' is not accessible — check project ID and permissions")]
617    ProjectNotAccessible(String),
618}
619
620// ── Doctor types ──
621
622#[derive(Debug, Default)]
623pub struct DoctorReport {
624    pub gcloud: CheckResult,
625    pub account: CheckResult,
626    pub project: CheckResult,
627    pub billing: CheckResult,
628    pub apis: Vec<ApiCheck>,
629    pub config_file: CheckResult,
630}
631
632impl DoctorReport {
633    pub fn all_passed(&self) -> bool {
634        self.gcloud.passed
635            && self.account.passed
636            && self.project.passed
637            && self.billing.passed
638            && self.config_file.passed
639            && self.apis.iter().all(|a| a.result.passed)
640    }
641}
642
643#[derive(Debug, Default, Clone)]
644pub struct CheckResult {
645    pub passed: bool,
646    pub detail: String,
647}
648
649impl CheckResult {
650    pub fn ok(detail: &str) -> Self {
651        Self {
652            passed: true,
653            detail: detail.to_owned(),
654        }
655    }
656
657    pub fn fail(detail: &str) -> Self {
658        Self {
659            passed: false,
660            detail: detail.to_owned(),
661        }
662    }
663
664    pub fn icon(&self) -> &'static str {
665        if self.passed { "OK" } else { "NG" }
666    }
667}
668
669#[derive(Debug, Clone)]
670pub struct ApiCheck {
671    pub name: String,
672    pub result: CheckResult,
673}
674
675#[derive(Debug, thiserror::Error)]
676pub enum CloudBuildError {
677    #[error("bundle path is not valid UTF-8: {0}")]
678    InvalidPath(std::path::PathBuf),
679
680    #[error("cloud build submission failed")]
681    Submit { source: GcloudError },
682}
683
684#[derive(Debug, thiserror::Error)]
685pub enum DeployError {
686    #[error("cloud run deployment failed")]
687    Deploy { source: GcloudError },
688}
689
690#[derive(Debug, thiserror::Error)]
691pub enum SecretError {
692    #[error("failed to create secret")]
693    Create { source: GcloudError },
694
695    #[error("failed to add secret version")]
696    AddVersion { source: GcloudError },
697
698    #[error("failed to list secrets")]
699    List { source: GcloudError },
700
701    #[error("failed to grant secret access")]
702    GrantAccess { source: GcloudError },
703
704    #[error("failed to delete secret")]
705    Delete { source: GcloudError },
706}