1use crate::executor::{GcloudExecutor, RealExecutor};
2use crate::gcloud::GcloudError;
3use propel_core::CloudRunConfig;
4use std::path::Path;
5
6pub 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 pub async fn check_prerequisites(
33 &self,
34 project_id: &str,
35 ) -> Result<PreflightReport, PreflightError> {
36 let mut report = PreflightReport::default();
37
38 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 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 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 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 pub async fn doctor(&self, project_id: Option<&str>) -> DoctorReport {
109 let mut report = DoctorReport::default();
110
111 match self.executor.exec(&args(["version"])).await {
113 Ok(v) => {
114 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 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 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 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 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 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 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 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 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 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 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
586fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
589 a.iter().map(|s| (*s).to_owned()).collect()
590}
591
592#[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#[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}