1use std::io::Write;
2use std::path::PathBuf;
3use std::process::{Command as ProcessCommand, Stdio};
4
5use crate::config::{DeployerConfig, DeployerRequest, OutputFormat, Provider};
6use crate::contract::DeployerCapability;
7use crate::error::{DeployerError, Result};
8use crate::multi_target;
9use crate::plan::PlanContext;
10use crate::runtime_secrets::{
11 PromoteRuntimeSecretsReport, ResolvedRuntimeSecret, default_cloud_secret_prefix,
12 flat_cloud_secret_name, resolve_for_cloud_apply,
13};
14
15#[derive(Debug, Clone)]
17pub struct GcpRequest {
18 pub capability: DeployerCapability,
19 pub tenant: String,
20 pub pack_path: Option<PathBuf>,
21 pub bundle_root: Option<PathBuf>,
22 pub bundle_source: Option<String>,
23 pub bundle_digest: Option<String>,
24 pub repo_registry_base: Option<String>,
25 pub store_registry_base: Option<String>,
26 pub provider_pack: Option<PathBuf>,
27 pub deploy_pack_id_override: Option<String>,
28 pub deploy_flow_id_override: Option<String>,
29 pub environment: Option<String>,
30 pub pack_id: Option<String>,
31 pub pack_version: Option<String>,
32 pub pack_digest: Option<String>,
33 pub distributor_url: Option<String>,
34 pub distributor_token: Option<String>,
35 pub preview: bool,
36 pub dry_run: bool,
37 pub execute_local: bool,
38 pub output: OutputFormat,
39 pub config_path: Option<PathBuf>,
40 pub allow_remote_in_offline: bool,
41 pub providers_dir: PathBuf,
42 pub packs_dir: PathBuf,
43}
44
45impl GcpRequest {
46 pub fn new(
47 capability: DeployerCapability,
48 tenant: impl Into<String>,
49 pack_path: Option<PathBuf>,
50 ) -> Self {
51 Self {
52 capability,
53 tenant: tenant.into(),
54 pack_path,
55 bundle_root: None,
56 bundle_source: None,
57 bundle_digest: None,
58 repo_registry_base: None,
59 store_registry_base: None,
60 provider_pack: None,
61 deploy_pack_id_override: None,
62 deploy_flow_id_override: None,
63 environment: None,
64 pack_id: None,
65 pack_version: None,
66 pack_digest: None,
67 distributor_url: None,
68 distributor_token: None,
69 preview: false,
70 dry_run: false,
71 execute_local: false,
72 output: OutputFormat::Text,
73 config_path: None,
74 allow_remote_in_offline: false,
75 providers_dir: PathBuf::from("providers/deployer"),
76 packs_dir: PathBuf::from("packs"),
77 }
78 }
79
80 pub fn into_deployer_request(self) -> DeployerRequest {
81 DeployerRequest {
82 capability: self.capability,
83 provider: Provider::Gcp,
84 strategy: "iac-only".to_string(),
85 tenant: self.tenant,
86 environment: self.environment,
87 pack_path: self.pack_path,
88 bundle_root: self.bundle_root,
89 bundle_source: self.bundle_source,
90 bundle_digest: self.bundle_digest,
91 repo_registry_base: self.repo_registry_base,
92 store_registry_base: self.store_registry_base,
93 providers_dir: self.providers_dir,
94 packs_dir: self.packs_dir,
95 provider_pack: self.provider_pack,
96 pack_id: self.pack_id,
97 pack_version: self.pack_version,
98 pack_digest: self.pack_digest,
99 distributor_url: self.distributor_url,
100 distributor_token: self.distributor_token,
101 preview: self.preview,
102 dry_run: self.dry_run,
103 execute_local: self.execute_local,
104 output: self.output,
105 config_path: self.config_path,
106 allow_remote_in_offline: self.allow_remote_in_offline,
107 deploy_pack_id_override: self.deploy_pack_id_override,
108 deploy_flow_id_override: self.deploy_flow_id_override,
109 }
110 }
111}
112
113#[derive(Debug, Clone, serde::Deserialize)]
118#[serde(rename_all = "camelCase")]
119pub struct GcpCloudRunExtConfig {
120 pub project_id: String,
121 pub region: String,
122 pub environment: String,
123 pub operator_image_digest: String,
124 pub bundle_source: String,
125 pub bundle_digest: String,
126 pub remote_state_backend: String,
127 pub dns_name: Option<String>,
128 pub public_base_url: Option<String>,
129 pub repo_registry_base: Option<String>,
130 pub store_registry_base: Option<String>,
131 pub admin_allowed_clients: Option<String>,
132 #[serde(default = "default_ext_tenant")]
133 pub tenant: String,
134}
135
136fn default_ext_tenant() -> String {
137 "default".to_string()
138}
139
140pub fn resolve_config(request: GcpRequest) -> Result<DeployerConfig> {
141 DeployerConfig::resolve(request.into_deployer_request())
142}
143
144pub fn ensure_gcp_config(config: &DeployerConfig) -> Result<()> {
145 if config.provider != Provider::Gcp || config.strategy != "iac-only" {
146 return Err(DeployerError::Config(format!(
147 "gcp adapter requires provider=gcp strategy=iac-only, got provider={} strategy={}",
148 config.provider.as_str(),
149 config.strategy
150 )));
151 }
152 Ok(())
153}
154
155fn build_gcp_request_from_ext(
159 capability: DeployerCapability,
160 cfg: &GcpCloudRunExtConfig,
161 pack_path: Option<&std::path::Path>,
162) -> GcpRequest {
163 GcpRequest {
164 capability,
165 tenant: cfg.tenant.clone(),
166 pack_path: pack_path.map(std::path::Path::to_path_buf),
167 bundle_root: None,
168 bundle_source: Some(cfg.bundle_source.clone()),
169 bundle_digest: Some(cfg.bundle_digest.clone()),
170 repo_registry_base: cfg.repo_registry_base.clone(),
171 store_registry_base: cfg.store_registry_base.clone(),
172 provider_pack: None,
173 deploy_pack_id_override: None,
174 deploy_flow_id_override: None,
175 environment: Some(cfg.environment.clone()),
176 pack_id: None,
177 pack_version: None,
178 pack_digest: None,
179 distributor_url: None,
180 distributor_token: None,
181 preview: false,
182 dry_run: false,
183 execute_local: true,
184 output: crate::config::OutputFormat::Text,
185 config_path: None,
186 allow_remote_in_offline: false,
187 providers_dir: std::path::PathBuf::from("providers/deployer"),
188 packs_dir: std::path::PathBuf::from("packs"),
189 }
190}
191
192pub fn apply_from_ext(
200 config_json: &str,
201 _creds_json: &str,
202 pack_path: Option<&std::path::Path>,
203) -> anyhow::Result<()> {
204 use anyhow::Context;
205 let cfg: GcpCloudRunExtConfig =
206 serde_json::from_str(config_json).context("parse gcp cloud-run config JSON")?;
207 let request = build_gcp_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
208 let config = resolve_config(request).context("resolve GCP deployer config")?;
209 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for GCP deploy")?;
210 let _outcome = rt
211 .block_on(crate::apply::run(config))
212 .context("run GCP deployment pipeline")?;
213 Ok(())
214}
215
216pub fn destroy_from_ext(
219 config_json: &str,
220 _creds_json: &str,
221 pack_path: Option<&std::path::Path>,
222) -> anyhow::Result<()> {
223 use anyhow::Context;
224 let cfg: GcpCloudRunExtConfig =
225 serde_json::from_str(config_json).context("parse gcp cloud-run config JSON")?;
226 let request = build_gcp_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
227 let config = resolve_config(request).context("resolve GCP deployer config")?;
228 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for GCP destroy")?;
229 let _outcome = rt
230 .block_on(crate::apply::run(config))
231 .context("run GCP destroy pipeline")?;
232 Ok(())
233}
234
235pub async fn run(request: GcpRequest) -> Result<multi_target::OperationResult> {
236 let config = resolve_config(request)?;
237 run_config(config).await
238}
239
240pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
241 ensure_gcp_config(&config)?;
242 promote_runtime_secrets_for_apply(&config).await?;
243 multi_target::run(config).await
244}
245
246pub async fn run_with_plan(
247 request: GcpRequest,
248 plan: PlanContext,
249) -> Result<multi_target::OperationResult> {
250 let config = resolve_config(request)?;
251 run_config_with_plan(config, plan).await
252}
253
254pub async fn run_config_with_plan(
255 config: DeployerConfig,
256 plan: PlanContext,
257) -> Result<multi_target::OperationResult> {
258 ensure_gcp_config(&config)?;
259 promote_runtime_secrets_for_apply(&config).await?;
260 multi_target::run_with_plan(config, plan).await
261}
262
263async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
264 let Some(resolution) = resolve_for_cloud_apply(config).await? else {
265 return Ok(());
266 };
267 let project_id = gcp_project_id()?;
268 let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
269 promote_to_gcp_secret_manager(&resolution.resolved, &project_id, &prefix).await?;
270 Ok(())
271}
272
273async fn promote_to_gcp_secret_manager(
274 resolved: &[ResolvedRuntimeSecret],
275 project_id: &str,
276 prefix: &str,
277) -> Result<PromoteRuntimeSecretsReport> {
278 let mut report = PromoteRuntimeSecretsReport::default();
279 for secret in resolved {
280 let remote_name = flat_cloud_secret_name(
281 prefix,
282 &secret.requirement.provider_id,
283 &secret.requirement.key,
284 255,
285 );
286 ensure_gcp_secret(project_id, &remote_name)?;
287 add_gcp_secret_version(project_id, &remote_name, secret.value.expose())?;
288 report
289 .promoted
290 .push(crate::runtime_secrets::PromotedRuntimeSecret {
291 uri: secret.requirement.uri.clone(),
292 remote_name,
293 });
294 }
295 Ok(report)
296}
297
298fn gcp_project_id() -> Result<String> {
299 std::env::var("GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID")
300 .or_else(|_| std::env::var("GOOGLE_CLOUD_PROJECT"))
301 .or_else(|_| std::env::var("GCLOUD_PROJECT"))
302 .map(|value| value.trim().to_string())
303 .ok()
304 .filter(|value| !value.is_empty())
305 .ok_or_else(|| {
306 DeployerError::Config(
307 "GCP runtime secret promotion requires GREENTIC_DEPLOY_TERRAFORM_VAR_GCP_PROJECT_ID, GOOGLE_CLOUD_PROJECT, or GCLOUD_PROJECT"
308 .to_string(),
309 )
310 })
311}
312
313fn ensure_gcp_secret(project_id: &str, secret_name: &str) -> Result<()> {
314 let status = ProcessCommand::new("gcloud")
315 .args([
316 "secrets",
317 "create",
318 secret_name,
319 "--project",
320 project_id,
321 "--replication-policy",
322 "automatic",
323 "--quiet",
324 ])
325 .stdout(Stdio::null())
326 .stderr(Stdio::piped())
327 .status()
328 .map_err(|err| DeployerError::Other(format!("run gcloud secrets create: {err}")))?;
329 if status.success() {
330 return Ok(());
331 }
332
333 let describe = ProcessCommand::new("gcloud")
334 .args([
335 "secrets",
336 "describe",
337 secret_name,
338 "--project",
339 project_id,
340 "--quiet",
341 ])
342 .stdout(Stdio::null())
343 .stderr(Stdio::null())
344 .status()
345 .map_err(|err| DeployerError::Other(format!("run gcloud secrets describe: {err}")))?;
346 if describe.success() {
347 Ok(())
348 } else {
349 Err(DeployerError::Other(format!(
350 "create GCP Secret Manager secret {secret_name} failed"
351 )))
352 }
353}
354
355fn add_gcp_secret_version(project_id: &str, secret_name: &str, value: &str) -> Result<()> {
356 let mut child = ProcessCommand::new("gcloud")
357 .args([
358 "secrets",
359 "versions",
360 "add",
361 secret_name,
362 "--project",
363 project_id,
364 "--data-file",
365 "-",
366 "--quiet",
367 ])
368 .stdin(Stdio::piped())
369 .stdout(Stdio::null())
370 .stderr(Stdio::piped())
371 .spawn()
372 .map_err(|err| DeployerError::Other(format!("run gcloud secrets versions add: {err}")))?;
373 if let Some(stdin) = child.stdin.as_mut() {
374 stdin.write_all(value.as_bytes())?;
375 }
376 let output = child.wait_with_output().map_err(|err| {
377 DeployerError::Other(format!("wait for gcloud secrets versions add: {err}"))
378 })?;
379 if output.status.success() {
380 Ok(())
381 } else {
382 Err(DeployerError::Other(format!(
383 "add GCP Secret Manager version for {secret_name} failed"
384 )))
385 }
386}
387
388#[cfg(test)]
389mod tests {
390 use super::*;
391
392 #[test]
393 fn gcp_request_defaults_to_gcp_iac_target() {
394 let request = GcpRequest::new(
395 DeployerCapability::Plan,
396 "acme",
397 Some(PathBuf::from("pack-dir")),
398 )
399 .into_deployer_request();
400
401 assert_eq!(request.provider, Provider::Gcp);
402 assert_eq!(request.strategy, "iac-only");
403 assert_eq!(request.tenant, "acme");
404 }
405
406 #[test]
407 fn gcp_request_preserves_all_passthrough_fields() {
408 let mut request = GcpRequest::new(
409 DeployerCapability::Apply,
410 "acme",
411 Some(PathBuf::from("pack-dir")),
412 );
413 request.bundle_root = Some(PathBuf::from("bundle-root"));
414 request.bundle_source = Some("gs://bucket/bundle.gtbundle".into());
415 request.bundle_digest = Some("sha256:abc".into());
416 request.repo_registry_base = Some("https://repo.example".into());
417 request.store_registry_base = Some("https://store.example".into());
418 request.provider_pack = Some(PathBuf::from("providers/deployer/gcp.gtpack"));
419 request.deploy_pack_id_override = Some("greentic.deploy.gcp".into());
420 request.deploy_flow_id_override = Some("apply_terraform".into());
421 request.environment = Some("prod".into());
422 request.pack_id = Some("pack-id".into());
423 request.pack_version = Some("1.2.3".into());
424 request.pack_digest = Some("sha256:def".into());
425 request.distributor_url = Some("https://dist.example".into());
426 request.distributor_token = Some("token".into());
427 request.preview = true;
428 request.dry_run = true;
429 request.execute_local = true;
430 request.output = OutputFormat::Yaml;
431 request.config_path = Some(PathBuf::from("greentic.toml"));
432 request.allow_remote_in_offline = true;
433 request.providers_dir = PathBuf::from("providers");
434 request.packs_dir = PathBuf::from("packs-dir");
435
436 let deployer = request.into_deployer_request();
437
438 assert_eq!(deployer.capability, DeployerCapability::Apply);
439 assert_eq!(deployer.provider, Provider::Gcp);
440 assert_eq!(
441 deployer.bundle_root.as_deref(),
442 Some(std::path::Path::new("bundle-root"))
443 );
444 assert_eq!(
445 deployer.bundle_source.as_deref(),
446 Some("gs://bucket/bundle.gtbundle")
447 );
448 assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
449 assert_eq!(
450 deployer.repo_registry_base.as_deref(),
451 Some("https://repo.example")
452 );
453 assert_eq!(
454 deployer.store_registry_base.as_deref(),
455 Some("https://store.example")
456 );
457 assert_eq!(
458 deployer.provider_pack.as_deref(),
459 Some(std::path::Path::new("providers/deployer/gcp.gtpack"))
460 );
461 assert_eq!(
462 deployer.deploy_pack_id_override.as_deref(),
463 Some("greentic.deploy.gcp")
464 );
465 assert_eq!(
466 deployer.deploy_flow_id_override.as_deref(),
467 Some("apply_terraform")
468 );
469 assert_eq!(deployer.environment.as_deref(), Some("prod"));
470 assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
471 assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
472 assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
473 assert_eq!(
474 deployer.distributor_url.as_deref(),
475 Some("https://dist.example")
476 );
477 assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
478 assert!(deployer.preview);
479 assert!(deployer.dry_run);
480 assert!(deployer.execute_local);
481 assert_eq!(deployer.output, OutputFormat::Yaml);
482 assert_eq!(
483 deployer.config_path.as_deref(),
484 Some(std::path::Path::new("greentic.toml"))
485 );
486 assert!(deployer.allow_remote_in_offline);
487 assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
488 assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
489 }
490
491 #[test]
492 fn ensure_gcp_config_rejects_non_gcp_provider() {
493 let tmp = tempfile::tempdir().expect("tempdir");
494 let mut request = GcpRequest::new(
495 DeployerCapability::Plan,
496 "acme",
497 Some(tmp.path().to_path_buf()),
498 )
499 .into_deployer_request();
500 request.provider = Provider::Aws;
501 let config = DeployerConfig::resolve(request).expect("resolve config");
502
503 let err = ensure_gcp_config(&config).expect_err("non-gcp config should fail");
504 assert!(
505 err.to_string().contains("provider=aws strategy=iac-only"),
506 "got: {err}"
507 );
508 }
509
510 #[test]
511 fn ensure_gcp_config_accepts_gcp_iac_config() {
512 let tmp = tempfile::tempdir().expect("tempdir");
513 let request = GcpRequest::new(
514 DeployerCapability::Plan,
515 "acme",
516 Some(tmp.path().to_path_buf()),
517 )
518 .into_deployer_request();
519 let config = DeployerConfig::resolve(request).expect("resolve config");
520
521 ensure_gcp_config(&config).expect("gcp config");
522 }
523
524 #[test]
525 fn gcp_secret_names_are_flat_and_bounded() {
526 let name = flat_cloud_secret_name(
527 "greentic/dev/demo/_",
528 "messaging-telegram",
529 "TELEGRAM_BOT_TOKEN",
530 255,
531 );
532 assert_eq!(
533 name,
534 "greentic-dev-demo-messaging-telegram-telegram-bot-token"
535 );
536 }
537
538 #[test]
539 fn ext_config_parses_minimum_fields() {
540 let json = r#"{
541 "projectId": "my-gcp-project-12345",
542 "region": "us-central1",
543 "environment": "staging",
544 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
545 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
546 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
547 "remoteStateBackend": "gs://my-tf-state-bucket/greentic/staging"
548 }"#;
549 let cfg: GcpCloudRunExtConfig = serde_json::from_str(json).unwrap();
550 assert_eq!(cfg.project_id, "my-gcp-project-12345");
551 assert_eq!(cfg.region, "us-central1");
552 assert_eq!(cfg.environment, "staging");
553 assert_eq!(cfg.tenant, "default");
554 assert!(cfg.dns_name.is_none());
555 assert!(cfg.public_base_url.is_none());
556 }
557
558 #[test]
559 fn ext_config_accepts_all_optionals() {
560 let json = r#"{
561 "projectId": "my-gcp-project-12345",
562 "region": "us-central1",
563 "environment": "prod",
564 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
565 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
566 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
567 "remoteStateBackend": "gs://my-tf-state-bucket/greentic/prod",
568 "dnsName": "api.example.com",
569 "publicBaseUrl": "https://api.example.com",
570 "repoRegistryBase": "https://repo.example.com",
571 "storeRegistryBase": "https://store.example.com",
572 "adminAllowedClients": "CN=admin",
573 "tenant": "acme"
574 }"#;
575 let cfg: GcpCloudRunExtConfig = serde_json::from_str(json).unwrap();
576 assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
577 assert_eq!(
578 cfg.public_base_url.as_deref(),
579 Some("https://api.example.com")
580 );
581 assert_eq!(cfg.tenant, "acme");
582 }
583
584 #[test]
585 fn build_gcp_request_from_ext_maps_cloud_bundle_fields() {
586 let cfg = GcpCloudRunExtConfig {
587 project_id: "project-123".to_string(),
588 region: "europe-west1".to_string(),
589 environment: "prod".to_string(),
590 operator_image_digest: "sha256:0000".to_string(),
591 bundle_source: "oci://registry.example/acme/prod".to_string(),
592 bundle_digest: "sha256:1111".to_string(),
593 remote_state_backend: "gs://state/greentic/prod".to_string(),
594 dns_name: Some("api.example.com".to_string()),
595 public_base_url: Some("https://api.example.com".to_string()),
596 repo_registry_base: Some("https://repo.example.com".to_string()),
597 store_registry_base: Some("https://store.example.com".to_string()),
598 admin_allowed_clients: Some("CN=admin".to_string()),
599 tenant: "acme".to_string(),
600 };
601
602 let request = build_gcp_request_from_ext(
603 DeployerCapability::Destroy,
604 &cfg,
605 Some(std::path::Path::new("pack")),
606 );
607
608 assert_eq!(request.capability, DeployerCapability::Destroy);
609 assert_eq!(request.tenant, "acme");
610 assert_eq!(request.pack_path, Some(PathBuf::from("pack")));
611 assert_eq!(
612 request.bundle_source.as_deref(),
613 Some("oci://registry.example/acme/prod")
614 );
615 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
616 assert_eq!(
617 request.repo_registry_base.as_deref(),
618 Some("https://repo.example.com")
619 );
620 assert_eq!(
621 request.store_registry_base.as_deref(),
622 Some("https://store.example.com")
623 );
624 assert_eq!(request.environment.as_deref(), Some("prod"));
625 assert!(request.execute_local);
626 }
627
628 #[test]
629 fn ext_config_rejects_missing_project_id() {
630 let json = r#"{
631 "region": "us-central1",
632 "environment": "staging",
633 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
634 "bundleSource": "oci://...",
635 "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
636 "remoteStateBackend": "gs://..."
637 }"#;
638 let err = serde_json::from_str::<GcpCloudRunExtConfig>(json).unwrap_err();
639 let msg = format!("{err}");
640 assert!(
641 msg.contains("projectId") || msg.contains("project_id"),
642 "got: {msg}"
643 );
644 }
645
646 #[test]
647 fn apply_from_ext_rejects_invalid_json() {
648 let err = apply_from_ext("not json", "{}", None).unwrap_err();
649 assert!(format!("{err}").contains("parse"), "got: {err}");
650 }
651
652 #[test]
653 fn apply_from_ext_rejects_missing_required_field() {
654 let json = r#"{"projectId":"my-project"}"#;
655 let err = apply_from_ext(json, "{}", None).unwrap_err();
656 let msg = format!("{err:#}");
657 assert!(
658 msg.contains("missing field")
659 || msg.contains("bundleSource")
660 || msg.contains("bundle_source"),
661 "got: {msg}"
662 );
663 }
664
665 #[test]
666 fn destroy_from_ext_rejects_invalid_json() {
667 let err = destroy_from_ext("not json", "{}", None).unwrap_err();
668 assert!(format!("{err}").contains("parse"), "got: {err}");
669 }
670}