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 ) -> 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 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
508fn args<const N: usize>(a: [&str; N]) -> Vec<String> {
511 a.iter().map(|s| (*s).to_owned()).collect()
512}
513
514#[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#[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}