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    ) -> Result<String, DeployError> {
321        let cpu = config.cpu.to_string();
322        let min = config.min_instances.to_string();
323        let max = config.max_instances.to_string();
324        let concurrency = config.concurrency.to_string();
325        let port = config.port.to_string();
326
327        let output = self
328            .executor
329            .exec(&args([
330                "run",
331                "deploy",
332                service_name,
333                "--image",
334                image_tag,
335                "--project",
336                project_id,
337                "--region",
338                region,
339                "--platform",
340                "managed",
341                "--memory",
342                &config.memory,
343                "--cpu",
344                &cpu,
345                "--min-instances",
346                &min,
347                "--max-instances",
348                &max,
349                "--concurrency",
350                &concurrency,
351                "--port",
352                &port,
353                "--allow-unauthenticated",
354                "--quiet",
355                "--format",
356                "value(status.url)",
357            ]))
358            .await
359            .map_err(|e| DeployError::Deploy { source: e })?;
360
361        Ok(output.trim().to_owned())
362    }
363
364    pub async fn describe_service(
365        &self,
366        service_name: &str,
367        project_id: &str,
368        region: &str,
369    ) -> Result<String, DeployError> {
370        self.executor
371            .exec(&args([
372                "run",
373                "services",
374                "describe",
375                service_name,
376                "--project",
377                project_id,
378                "--region",
379                region,
380                "--format",
381                "yaml(status)",
382            ]))
383            .await
384            .map_err(|e| DeployError::Deploy { source: e })
385    }
386
387    pub async fn delete_service(
388        &self,
389        service_name: &str,
390        project_id: &str,
391        region: &str,
392    ) -> Result<(), DeployError> {
393        self.executor
394            .exec(&args([
395                "run",
396                "services",
397                "delete",
398                service_name,
399                "--project",
400                project_id,
401                "--region",
402                region,
403                "--quiet",
404            ]))
405            .await
406            .map_err(|e| DeployError::Deploy { source: e })?;
407
408        Ok(())
409    }
410
411    pub async fn read_logs(
412        &self,
413        service_name: &str,
414        project_id: &str,
415        region: &str,
416    ) -> Result<(), DeployError> {
417        self.executor
418            .exec_streaming(&args([
419                "run",
420                "services",
421                "logs",
422                "read",
423                service_name,
424                "--project",
425                project_id,
426                "--region",
427                region,
428                "--limit",
429                "100",
430            ]))
431            .await
432            .map_err(|e| DeployError::Deploy { source: e })
433    }
434
435    // ── Secret Manager ──
436
437    pub async fn set_secret(
438        &self,
439        project_id: &str,
440        secret_name: &str,
441        secret_value: &str,
442    ) -> Result<(), SecretError> {
443        let secret_exists = self
444            .executor
445            .exec(&args([
446                "secrets",
447                "describe",
448                secret_name,
449                "--project",
450                project_id,
451            ]))
452            .await
453            .is_ok();
454
455        if !secret_exists {
456            self.executor
457                .exec(&args([
458                    "secrets",
459                    "create",
460                    secret_name,
461                    "--project",
462                    project_id,
463                    "--replication-policy",
464                    "automatic",
465                ]))
466                .await
467                .map_err(|e| SecretError::Create { source: e })?;
468        }
469
470        self.executor
471            .exec_with_stdin(
472                &args([
473                    "secrets",
474                    "versions",
475                    "add",
476                    secret_name,
477                    "--project",
478                    project_id,
479                    "--data-file",
480                    "-",
481                ]),
482                secret_value.as_bytes(),
483            )
484            .await
485            .map_err(|e| SecretError::AddVersion { source: e })?;
486
487        Ok(())
488    }
489
490    pub async fn list_secrets(&self, project_id: &str) -> Result<Vec<String>, SecretError> {
491        let output = self
492            .executor
493            .exec(&args([
494                "secrets",
495                "list",
496                "--project",
497                project_id,
498                "--format",
499                "value(name)",
500            ]))
501            .await
502            .map_err(|e| SecretError::List { source: e })?;
503
504        Ok(output.lines().map(|s| s.to_owned()).collect())
505    }
506}
507
508// ── Helper ──
509
510fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
511    a.iter().map(|s| (*s).to_owned()).collect()
512}
513
514// ── Error types ──
515
516#[derive(Debug, Default)]
517pub struct PreflightReport {
518    pub gcloud_version: Option<String>,
519    pub authenticated: bool,
520    pub project_name: Option<String>,
521    pub disabled_apis: Vec<String>,
522}
523
524impl PreflightReport {
525    pub fn has_warnings(&self) -> bool {
526        !self.disabled_apis.is_empty()
527    }
528}
529
530#[derive(Debug, thiserror::Error)]
531pub enum PreflightError {
532    #[error("gcloud CLI not installed — https://cloud.google.com/sdk/docs/install")]
533    GcloudNotInstalled,
534
535    #[error("not authenticated — run: gcloud auth login")]
536    NotAuthenticated,
537
538    #[error("GCP project '{0}' is not accessible — check project ID and permissions")]
539    ProjectNotAccessible(String),
540}
541
542// ── Doctor types ──
543
544#[derive(Debug, Default)]
545pub struct DoctorReport {
546    pub gcloud: CheckResult,
547    pub account: CheckResult,
548    pub project: CheckResult,
549    pub billing: CheckResult,
550    pub apis: Vec<ApiCheck>,
551    pub config_file: CheckResult,
552}
553
554impl DoctorReport {
555    pub fn all_passed(&self) -> bool {
556        self.gcloud.passed
557            && self.account.passed
558            && self.project.passed
559            && self.billing.passed
560            && self.config_file.passed
561            && self.apis.iter().all(|a| a.result.passed)
562    }
563}
564
565#[derive(Debug, Default, Clone)]
566pub struct CheckResult {
567    pub passed: bool,
568    pub detail: String,
569}
570
571impl CheckResult {
572    pub fn ok(detail: &str) -> Self {
573        Self {
574            passed: true,
575            detail: detail.to_owned(),
576        }
577    }
578
579    pub fn fail(detail: &str) -> Self {
580        Self {
581            passed: false,
582            detail: detail.to_owned(),
583        }
584    }
585
586    pub fn icon(&self) -> &'static str {
587        if self.passed { "OK" } else { "NG" }
588    }
589}
590
591#[derive(Debug, Clone)]
592pub struct ApiCheck {
593    pub name: String,
594    pub result: CheckResult,
595}
596
597#[derive(Debug, thiserror::Error)]
598pub enum CloudBuildError {
599    #[error("bundle path is not valid UTF-8: {0}")]
600    InvalidPath(std::path::PathBuf),
601
602    #[error("cloud build submission failed")]
603    Submit { source: GcloudError },
604}
605
606#[derive(Debug, thiserror::Error)]
607pub enum DeployError {
608    #[error("cloud run deployment failed")]
609    Deploy { source: GcloudError },
610}
611
612#[derive(Debug, thiserror::Error)]
613pub enum SecretError {
614    #[error("failed to create secret")]
615    Create { source: GcloudError },
616
617    #[error("failed to add secret version")]
618    AddVersion { source: GcloudError },
619
620    #[error("failed to list secrets")]
621    List { source: GcloudError },
622}