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-access-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 limit: u32,
434 ) -> Result<(), DeployError> {
435 let limit_str = limit.to_string();
436 self.executor
437 .exec_streaming(&args([
438 "run",
439 "services",
440 "logs",
441 "read",
442 service_name,
443 "--project",
444 project_id,
445 "--region",
446 region,
447 "--limit",
448 &limit_str,
449 ]))
450 .await
451 .map_err(|e| DeployError::Logs { source: e })
452 }
453
454 pub async fn tail_logs(
455 &self,
456 service_name: &str,
457 project_id: &str,
458 region: &str,
459 ) -> Result<(), DeployError> {
460 self.executor
461 .exec_streaming(&args([
462 "run",
463 "services",
464 "logs",
465 "tail",
466 service_name,
467 "--project",
468 project_id,
469 "--region",
470 region,
471 ]))
472 .await
473 .map_err(|e| DeployError::Logs { source: e })
474 }
475
476 pub async fn set_secret(
479 &self,
480 project_id: &str,
481 secret_name: &str,
482 secret_value: &str,
483 ) -> Result<(), SecretError> {
484 let secret_exists = self
485 .executor
486 .exec(&args([
487 "secrets",
488 "describe",
489 secret_name,
490 "--project",
491 project_id,
492 ]))
493 .await
494 .is_ok();
495
496 if !secret_exists {
497 self.executor
498 .exec(&args([
499 "secrets",
500 "create",
501 secret_name,
502 "--project",
503 project_id,
504 "--replication-policy",
505 "automatic",
506 ]))
507 .await
508 .map_err(|e| SecretError::Create { source: e })?;
509 }
510
511 self.executor
512 .exec_with_stdin(
513 &args([
514 "secrets",
515 "versions",
516 "add",
517 secret_name,
518 "--project",
519 project_id,
520 "--data-file",
521 "-",
522 ]),
523 secret_value.as_bytes(),
524 )
525 .await
526 .map_err(|e| SecretError::AddVersion { source: e })?;
527
528 Ok(())
529 }
530
531 pub async fn get_project_number(&self, project_id: &str) -> Result<String, DeployError> {
532 let output = self
533 .executor
534 .exec(&args([
535 "projects",
536 "describe",
537 project_id,
538 "--format",
539 "value(projectNumber)",
540 ]))
541 .await
542 .map_err(|e| DeployError::Deploy { source: e })?;
543
544 Ok(output.trim().to_owned())
545 }
546
547 pub async fn grant_secret_access(
548 &self,
549 project_id: &str,
550 secret_name: &str,
551 service_account: &str,
552 ) -> Result<(), SecretError> {
553 let member = format!("serviceAccount:{service_account}");
554 self.executor
555 .exec(&args([
556 "secrets",
557 "add-iam-policy-binding",
558 secret_name,
559 "--project",
560 project_id,
561 "--member",
562 &member,
563 "--role",
564 "roles/secretmanager.secretAccessor",
565 ]))
566 .await
567 .map_err(|e| SecretError::GrantAccess { source: e })?;
568
569 Ok(())
570 }
571
572 pub async fn revoke_secret_access(
573 &self,
574 project_id: &str,
575 secret_name: &str,
576 service_account: &str,
577 ) -> Result<(), SecretError> {
578 let member = format!("serviceAccount:{service_account}");
579 self.executor
580 .exec(&args([
581 "secrets",
582 "remove-iam-policy-binding",
583 secret_name,
584 "--project",
585 project_id,
586 "--member",
587 &member,
588 "--role",
589 "roles/secretmanager.secretAccessor",
590 ]))
591 .await
592 .map_err(|e| SecretError::RevokeAccess { source: e })?;
593
594 Ok(())
595 }
596
597 pub async fn list_secrets(&self, project_id: &str) -> Result<Vec<String>, SecretError> {
598 let output = self
599 .executor
600 .exec(&args([
601 "secrets",
602 "list",
603 "--project",
604 project_id,
605 "--format",
606 "value(name)",
607 ]))
608 .await
609 .map_err(|e| SecretError::List { source: e })?;
610
611 Ok(output.lines().map(|s| s.to_owned()).collect())
612 }
613
614 pub async fn delete_secret(
615 &self,
616 project_id: &str,
617 secret_name: &str,
618 ) -> Result<(), SecretError> {
619 self.executor
620 .exec(&args([
621 "secrets",
622 "delete",
623 secret_name,
624 "--project",
625 project_id,
626 "--quiet",
627 ]))
628 .await
629 .map_err(|e| SecretError::Delete { source: e })?;
630
631 Ok(())
632 }
633
634 pub async fn ensure_wif_pool(&self, project_id: &str, pool_id: &str) -> Result<bool, WifError> {
639 match self
640 .executor
641 .exec(&args([
642 "iam",
643 "workload-identity-pools",
644 "create",
645 pool_id,
646 "--project",
647 project_id,
648 "--location",
649 "global",
650 "--display-name",
651 "Propel GitHub Actions",
652 ]))
653 .await
654 {
655 Ok(_) => Ok(true),
656 Err(ref e) if is_already_exists(e) => Ok(false),
657 Err(e) => Err(WifError::CreatePool { source: e }),
658 }
659 }
660
661 pub async fn ensure_oidc_provider(
664 &self,
665 project_id: &str,
666 pool_id: &str,
667 provider_id: &str,
668 github_repo: &str,
669 ) -> Result<bool, WifError> {
670 let attribute_condition = format!("assertion.repository == '{github_repo}'");
671
672 let cmd: Vec<String> = [
673 "iam",
674 "workload-identity-pools",
675 "providers",
676 "create-oidc",
677 provider_id,
678 "--project",
679 project_id,
680 "--location",
681 "global",
682 "--workload-identity-pool",
683 pool_id,
684 "--display-name",
685 "GitHub",
686 "--attribute-mapping",
687 "google.subject=assertion.sub,attribute.repository=assertion.repository",
688 "--attribute-condition",
689 &attribute_condition,
690 "--issuer-uri",
691 "https://token.actions.githubusercontent.com",
692 ]
693 .iter()
694 .map(|s| (*s).to_owned())
695 .collect();
696
697 match self.executor.exec(&cmd).await {
698 Ok(_) => Ok(true),
699 Err(ref e) if is_already_exists(e) => Ok(false),
700 Err(e) => Err(WifError::CreateProvider { source: e }),
701 }
702 }
703
704 pub async fn ensure_service_account(
707 &self,
708 project_id: &str,
709 sa_id: &str,
710 display_name: &str,
711 ) -> Result<bool, WifError> {
712 match self
713 .executor
714 .exec(&args([
715 "iam",
716 "service-accounts",
717 "create",
718 sa_id,
719 "--project",
720 project_id,
721 "--display-name",
722 display_name,
723 ]))
724 .await
725 {
726 Ok(_) => Ok(true),
727 Err(ref e) if is_already_exists(e) => Ok(false),
728 Err(e) => Err(WifError::CreateServiceAccount { source: e }),
729 }
730 }
731
732 pub async fn bind_iam_roles(
734 &self,
735 project_id: &str,
736 sa_email: &str,
737 roles: &[&str],
738 ) -> Result<(), WifError> {
739 let member = format!("serviceAccount:{sa_email}");
740 for role in roles {
741 self.executor
742 .exec(&args([
743 "projects",
744 "add-iam-policy-binding",
745 project_id,
746 "--member",
747 &member,
748 "--role",
749 role,
750 "--quiet",
751 ]))
752 .await
753 .map_err(|e| WifError::BindRole {
754 role: (*role).to_owned(),
755 source: e,
756 })?;
757 }
758
759 Ok(())
760 }
761
762 pub async fn bind_wif_to_sa(
764 &self,
765 project_id: &str,
766 project_number: &str,
767 pool_id: &str,
768 sa_email: &str,
769 github_repo: &str,
770 ) -> Result<(), WifError> {
771 let member = format!(
772 "principalSet://iam.googleapis.com/projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/attribute.repository/{github_repo}"
773 );
774
775 self.executor
776 .exec(&args([
777 "iam",
778 "service-accounts",
779 "add-iam-policy-binding",
780 sa_email,
781 "--project",
782 project_id,
783 "--role",
784 "roles/iam.workloadIdentityUser",
785 "--member",
786 &member,
787 ]))
788 .await
789 .map_err(|e| WifError::BindWif { source: e })?;
790
791 Ok(())
792 }
793
794 pub async fn delete_wif_pool(&self, project_id: &str, pool_id: &str) -> Result<(), WifError> {
796 self.executor
797 .exec(&args([
798 "iam",
799 "workload-identity-pools",
800 "delete",
801 pool_id,
802 "--project",
803 project_id,
804 "--location",
805 "global",
806 "--quiet",
807 ]))
808 .await
809 .map_err(|e| WifError::DeletePool { source: e })?;
810
811 Ok(())
812 }
813
814 pub async fn delete_service_account(
816 &self,
817 project_id: &str,
818 sa_email: &str,
819 ) -> Result<(), WifError> {
820 self.executor
821 .exec(&args([
822 "iam",
823 "service-accounts",
824 "delete",
825 sa_email,
826 "--project",
827 project_id,
828 "--quiet",
829 ]))
830 .await
831 .map_err(|e| WifError::DeleteServiceAccount { source: e })?;
832
833 Ok(())
834 }
835}
836
837fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
840 a.iter().map(|s| (*s).to_owned()).collect()
841}
842
843fn is_already_exists(e: &GcloudError) -> bool {
845 match e {
846 GcloudError::CommandFailed { stderr, .. } => {
847 stderr.contains("ALREADY_EXISTS") || stderr.contains("already exists")
848 }
849 _ => false,
850 }
851}
852
853#[derive(Debug, Default)]
856pub struct PreflightReport {
857 pub gcloud_version: Option<String>,
858 pub authenticated: bool,
859 pub project_name: Option<String>,
860 pub disabled_apis: Vec<String>,
861}
862
863impl PreflightReport {
864 pub fn has_warnings(&self) -> bool {
865 !self.disabled_apis.is_empty()
866 }
867}
868
869#[derive(Debug, thiserror::Error)]
870pub enum PreflightError {
871 #[error("gcloud CLI not installed — https://cloud.google.com/sdk/docs/install")]
872 GcloudNotInstalled,
873
874 #[error("not authenticated — run: gcloud auth login")]
875 NotAuthenticated,
876
877 #[error("GCP project '{0}' is not accessible — check project ID and permissions")]
878 ProjectNotAccessible(String),
879}
880
881#[derive(Debug, Default)]
884pub struct DoctorReport {
885 pub gcloud: CheckResult,
886 pub account: CheckResult,
887 pub project: CheckResult,
888 pub billing: CheckResult,
889 pub apis: Vec<ApiCheck>,
890 pub config_file: CheckResult,
891}
892
893impl DoctorReport {
894 pub fn all_passed(&self) -> bool {
895 self.gcloud.passed
896 && self.account.passed
897 && self.project.passed
898 && self.billing.passed
899 && self.config_file.passed
900 && self.apis.iter().all(|a| a.result.passed)
901 }
902}
903
904#[derive(Debug, Default, Clone)]
905pub struct CheckResult {
906 pub passed: bool,
907 pub detail: String,
908}
909
910impl CheckResult {
911 pub fn ok(detail: &str) -> Self {
912 Self {
913 passed: true,
914 detail: detail.to_owned(),
915 }
916 }
917
918 pub fn fail(detail: &str) -> Self {
919 Self {
920 passed: false,
921 detail: detail.to_owned(),
922 }
923 }
924
925 pub fn icon(&self) -> &'static str {
926 if self.passed { "OK" } else { "NG" }
927 }
928}
929
930#[derive(Debug, Clone)]
931pub struct ApiCheck {
932 pub name: String,
933 pub result: CheckResult,
934}
935
936#[derive(Debug, thiserror::Error)]
937pub enum CloudBuildError {
938 #[error("bundle path is not valid UTF-8: {0}")]
939 InvalidPath(std::path::PathBuf),
940
941 #[error("cloud build submission failed")]
942 Submit { source: GcloudError },
943}
944
945#[derive(Debug, thiserror::Error)]
946pub enum DeployError {
947 #[error("cloud run deployment failed")]
948 Deploy { source: GcloudError },
949
950 #[error("failed to read logs")]
951 Logs { source: GcloudError },
952}
953
954#[derive(Debug, thiserror::Error)]
955pub enum SecretError {
956 #[error("failed to create secret")]
957 Create { source: GcloudError },
958
959 #[error("failed to add secret version")]
960 AddVersion { source: GcloudError },
961
962 #[error("failed to list secrets")]
963 List { source: GcloudError },
964
965 #[error("failed to grant secret access")]
966 GrantAccess { source: GcloudError },
967
968 #[error("failed to revoke secret access")]
969 RevokeAccess { source: GcloudError },
970
971 #[error("failed to delete secret")]
972 Delete { source: GcloudError },
973}
974
975#[derive(Debug, thiserror::Error)]
976pub enum WifError {
977 #[error("failed to create workload identity pool")]
978 CreatePool { source: GcloudError },
979
980 #[error("failed to create OIDC provider")]
981 CreateProvider { source: GcloudError },
982
983 #[error("failed to create service account")]
984 CreateServiceAccount { source: GcloudError },
985
986 #[error("failed to bind IAM role: {role}")]
987 BindRole { role: String, source: GcloudError },
988
989 #[error("failed to bind WIF to service account")]
990 BindWif { source: GcloudError },
991
992 #[error("failed to delete workload identity pool")]
993 DeletePool { source: GcloudError },
994
995 #[error("failed to delete service account")]
996 DeleteServiceAccount { source: GcloudError },
997}