1use std::fs;
2#[cfg(feature = "runtime-secrets-aws")]
3use std::io::Write;
4use std::path::{Path, PathBuf};
5use std::process::{Command as ProcessCommand, Stdio};
6
7use serde_json::Value;
8
9use crate::admin_access::{
10 load_terraform_outputs, resolve_latest_deploy_dir, terraform_output_string,
11 tunnel_admin_cert_dir,
12};
13use crate::config::{DeployerConfig, DeployerRequest, OutputFormat, Provider};
14use crate::contract::DeployerCapability;
15use crate::error::{DeployerError, Result};
16use crate::multi_target;
17use crate::plan::PlanContext;
18use crate::runtime_secrets::{
19 PromoteRuntimeSecretsReport, ResolvedRuntimeSecret, default_cloud_secret_prefix,
20 resolve_for_cloud_apply,
21};
22
23#[derive(Debug, Clone)]
25pub struct AwsRequest {
26 pub capability: DeployerCapability,
27 pub tenant: String,
28 pub pack_path: PathBuf,
29 pub bundle_root: Option<PathBuf>,
30 pub bundle_source: Option<String>,
31 pub bundle_digest: Option<String>,
32 pub repo_registry_base: Option<String>,
33 pub store_registry_base: Option<String>,
34 pub provider_pack: Option<PathBuf>,
35 pub deploy_pack_id_override: Option<String>,
36 pub deploy_flow_id_override: Option<String>,
37 pub environment: Option<String>,
38 pub pack_id: Option<String>,
39 pub pack_version: Option<String>,
40 pub pack_digest: Option<String>,
41 pub distributor_url: Option<String>,
42 pub distributor_token: Option<String>,
43 pub preview: bool,
44 pub dry_run: bool,
45 pub execute_local: bool,
46 pub output: OutputFormat,
47 pub config_path: Option<PathBuf>,
48 pub allow_remote_in_offline: bool,
49 pub providers_dir: PathBuf,
50 pub packs_dir: PathBuf,
51}
52
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AwsAdminTunnelRequest {
55 pub bundle_dir: PathBuf,
56 pub local_port: String,
57 pub container: String,
58}
59
60#[derive(Debug, Clone, serde::Deserialize)]
65#[serde(rename_all = "camelCase")]
66pub struct AwsEcsFargateExtConfig {
67 pub region: String,
68 pub environment: String,
69 pub operator_image_digest: String,
70 pub bundle_source: String,
71 pub bundle_digest: String,
72 pub remote_state_backend: String,
73 pub redis_url: Option<String>,
74 pub dns_name: Option<String>,
75 pub public_base_url: Option<String>,
76 pub repo_registry_base: Option<String>,
77 pub store_registry_base: Option<String>,
78 pub admin_allowed_clients: Option<String>,
79 #[serde(default = "default_ext_tenant")]
80 pub tenant: String,
81}
82
83fn default_ext_tenant() -> String {
84 "default".to_string()
85}
86
87impl AwsRequest {
88 pub fn new(
89 capability: DeployerCapability,
90 tenant: impl Into<String>,
91 pack_path: PathBuf,
92 ) -> Self {
93 Self {
94 capability,
95 tenant: tenant.into(),
96 pack_path,
97 bundle_root: None,
98 bundle_source: None,
99 bundle_digest: None,
100 repo_registry_base: None,
101 store_registry_base: None,
102 provider_pack: None,
103 deploy_pack_id_override: None,
104 deploy_flow_id_override: None,
105 environment: None,
106 pack_id: None,
107 pack_version: None,
108 pack_digest: None,
109 distributor_url: None,
110 distributor_token: None,
111 preview: false,
112 dry_run: false,
113 execute_local: false,
114 output: OutputFormat::Text,
115 config_path: None,
116 allow_remote_in_offline: false,
117 providers_dir: PathBuf::from("providers/deployer"),
118 packs_dir: PathBuf::from("packs"),
119 }
120 }
121
122 pub fn into_deployer_request(self) -> DeployerRequest {
123 DeployerRequest {
124 capability: self.capability,
125 provider: Provider::Aws,
126 strategy: "iac-only".to_string(),
127 tenant: self.tenant,
128 environment: self.environment,
129 pack_path: self.pack_path,
130 bundle_root: self.bundle_root,
131 bundle_source: self.bundle_source,
132 bundle_digest: self.bundle_digest,
133 repo_registry_base: self.repo_registry_base,
134 store_registry_base: self.store_registry_base,
135 providers_dir: self.providers_dir,
136 packs_dir: self.packs_dir,
137 provider_pack: self.provider_pack,
138 pack_id: self.pack_id,
139 pack_version: self.pack_version,
140 pack_digest: self.pack_digest,
141 distributor_url: self.distributor_url,
142 distributor_token: self.distributor_token,
143 preview: self.preview,
144 dry_run: self.dry_run,
145 execute_local: self.execute_local,
146 output: self.output,
147 config_path: self.config_path,
148 allow_remote_in_offline: self.allow_remote_in_offline,
149 deploy_pack_id_override: self.deploy_pack_id_override,
150 deploy_flow_id_override: self.deploy_flow_id_override,
151 }
152 }
153}
154
155pub fn resolve_config(request: AwsRequest) -> Result<DeployerConfig> {
156 DeployerConfig::resolve(request.into_deployer_request())
157}
158
159pub fn ensure_aws_config(config: &DeployerConfig) -> Result<()> {
160 if config.provider != Provider::Aws || config.strategy != "iac-only" {
161 return Err(DeployerError::Config(format!(
162 "aws adapter requires provider=aws strategy=iac-only, got provider={} strategy={}",
163 config.provider.as_str(),
164 config.strategy
165 )));
166 }
167 Ok(())
168}
169
170fn build_aws_request_from_ext(
174 capability: DeployerCapability,
175 cfg: &AwsEcsFargateExtConfig,
176 pack_path: Option<&std::path::Path>,
177) -> AwsRequest {
178 AwsRequest {
179 capability,
180 tenant: cfg.tenant.clone(),
181 pack_path: pack_path
182 .map(std::path::Path::to_path_buf)
183 .unwrap_or_default(),
184 bundle_root: None,
185 bundle_source: Some(cfg.bundle_source.clone()),
186 bundle_digest: Some(cfg.bundle_digest.clone()),
187 repo_registry_base: cfg.repo_registry_base.clone(),
188 store_registry_base: cfg.store_registry_base.clone(),
189 provider_pack: None,
190 deploy_pack_id_override: None,
191 deploy_flow_id_override: None,
192 environment: Some(cfg.environment.clone()),
193 pack_id: None,
194 pack_version: None,
195 pack_digest: None,
196 distributor_url: None,
197 distributor_token: None,
198 preview: false,
199 dry_run: false,
200 execute_local: true,
201 output: crate::config::OutputFormat::Text,
202 config_path: None,
203 allow_remote_in_offline: false,
204 providers_dir: std::path::PathBuf::from("providers/deployer"),
205 packs_dir: std::path::PathBuf::from("packs"),
206 }
207}
208
209pub fn apply_from_ext(
215 config_json: &str,
216 _creds_json: &str,
217 pack_path: Option<&std::path::Path>,
218) -> anyhow::Result<()> {
219 use anyhow::Context;
220 let cfg: AwsEcsFargateExtConfig =
221 serde_json::from_str(config_json).context("parse aws ecs-fargate config JSON")?;
222 let request = build_aws_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
223 let config = resolve_config(request).context("resolve AWS deployer config")?;
224 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for AWS deploy")?;
225 let _outcome = rt
226 .block_on(crate::apply::run(config))
227 .context("run AWS deployment pipeline")?;
228 Ok(())
229}
230
231pub fn destroy_from_ext(
234 config_json: &str,
235 _creds_json: &str,
236 pack_path: Option<&std::path::Path>,
237) -> anyhow::Result<()> {
238 use anyhow::Context;
239 let cfg: AwsEcsFargateExtConfig =
240 serde_json::from_str(config_json).context("parse aws ecs-fargate config JSON")?;
241 let request = build_aws_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
242 let config = resolve_config(request).context("resolve AWS deployer config")?;
243 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for AWS destroy")?;
244 let _outcome = rt
245 .block_on(crate::apply::run(config))
246 .context("run AWS destroy pipeline")?;
247 Ok(())
248}
249
250pub async fn run(request: AwsRequest) -> Result<multi_target::OperationResult> {
251 let config = resolve_config(request)?;
252 run_config(config).await
253}
254
255pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
256 ensure_aws_config(&config)?;
257 promote_runtime_secrets_for_apply(&config).await?;
258 multi_target::run(config).await
259}
260
261pub async fn run_with_plan(
262 request: AwsRequest,
263 plan: PlanContext,
264) -> Result<multi_target::OperationResult> {
265 let config = resolve_config(request)?;
266 run_config_with_plan(config, plan).await
267}
268
269pub async fn run_config_with_plan(
270 config: DeployerConfig,
271 plan: PlanContext,
272) -> Result<multi_target::OperationResult> {
273 ensure_aws_config(&config)?;
274 promote_runtime_secrets_for_apply(&config).await?;
275 multi_target::run_with_plan(config, plan).await
276}
277
278async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
279 let Some(resolution) = resolve_for_cloud_apply(config).await? else {
280 return Ok(());
281 };
282 let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
283 promote_to_aws_secrets_manager(
284 &resolution.resolved,
285 &prefix,
286 config.bundle_digest.as_deref(),
287 &config.environment,
288 &config.tenant,
289 None,
290 )
291 .await?;
292 Ok(())
293}
294
295#[cfg(feature = "runtime-secrets-aws")]
296async fn promote_to_aws_secrets_manager(
297 resolved: &[ResolvedRuntimeSecret],
298 prefix: &str,
299 bundle_digest: Option<&str>,
300 environment: &str,
301 tenant: &str,
302 team: Option<&str>,
303) -> Result<PromoteRuntimeSecretsReport> {
304 let sink = AwsCliSecretSink {
305 region: aws_runtime_secrets_region(),
306 };
307 crate::runtime_secret_sink::promote_runtime_secrets(
308 &sink,
309 resolved,
310 prefix,
311 bundle_digest,
312 environment,
313 tenant,
314 team.unwrap_or("_"),
315 "aws",
316 "aws-secrets-manager",
317 )
318}
319
320#[cfg(feature = "runtime-secrets-aws")]
324struct AwsCliSecretSink {
325 region: String,
326}
327
328#[cfg(feature = "runtime-secrets-aws")]
329impl crate::runtime_secret_sink::RuntimeSecretSink for AwsCliSecretSink {
330 fn upsert(
331 &self,
332 name: &str,
333 value: &str,
334 tags: &[(String, String)],
335 ) -> Result<crate::runtime_secret_sink::UpsertOutcome> {
336 use crate::runtime_secret_sink::UpsertOutcome;
337
338 let mut temp = tempfile::NamedTempFile::new()
339 .map_err(|err| DeployerError::Other(format!("create temporary secret file: {err}")))?;
340 temp.write_all(value.as_bytes())?;
341 temp.flush()?;
342 let secret_file = format!(
343 "file://{}",
344 temp.path().to_str().ok_or_else(|| {
345 DeployerError::Other("temporary secret path is not UTF-8".to_string())
346 })?
347 );
348
349 let mut create = ProcessCommand::new("aws");
350 create.args([
351 "secretsmanager",
352 "create-secret",
353 "--region",
354 &self.region,
355 "--name",
356 name,
357 "--secret-string",
358 &secret_file,
359 ]);
360 if !tags.is_empty() {
363 create.arg("--tags");
364 for (key, value) in tags {
365 create.arg(format!("Key={key},Value={value}"));
366 }
367 }
368
369 let create = create
370 .stdout(Stdio::null())
371 .stderr(Stdio::piped())
372 .output()
373 .map_err(|err| {
374 DeployerError::Other(format!("run aws secretsmanager create-secret: {err}"))
375 })?;
376 if create.status.success() {
377 return Ok(UpsertOutcome::Created);
378 }
379
380 let create_stderr = String::from_utf8_lossy(&create.stderr);
381 if !create_stderr.contains("ResourceExistsException") {
382 return Err(DeployerError::Other(format!(
383 "create AWS Secrets Manager secret {name}: {}",
384 create_stderr.trim()
385 )));
386 }
387
388 let update = ProcessCommand::new("aws")
389 .args([
390 "secretsmanager",
391 "put-secret-value",
392 "--region",
393 &self.region,
394 "--secret-id",
395 name,
396 "--secret-string",
397 &secret_file,
398 ])
399 .stdout(Stdio::null())
400 .stderr(Stdio::piped())
401 .output()
402 .map_err(|err| {
403 DeployerError::Other(format!("run aws secretsmanager put-secret-value: {err}"))
404 })?;
405 if update.status.success() {
406 if !tags.is_empty() {
411 let mut tag = ProcessCommand::new("aws");
412 tag.args([
413 "secretsmanager",
414 "tag-resource",
415 "--region",
416 &self.region,
417 "--secret-id",
418 name,
419 "--tags",
420 ]);
421 for (key, value) in tags {
422 tag.arg(format!("Key={key},Value={value}"));
423 }
424 match tag.stdout(Stdio::null()).stderr(Stdio::piped()).output() {
425 Ok(out) if out.status.success() => {}
426 Ok(out) => tracing::warn!(
427 secret = %name,
428 stderr = %String::from_utf8_lossy(&out.stderr).trim(),
429 "failed to tag existing secret on update"
430 ),
431 Err(err) => tracing::warn!(
432 secret = %name,
433 %err,
434 "failed to invoke aws secretsmanager tag-resource"
435 ),
436 }
437 }
438 Ok(UpsertOutcome::Updated)
439 } else {
440 Err(DeployerError::Other(format!(
441 "update AWS Secrets Manager secret {name}: {}",
442 String::from_utf8_lossy(&update.stderr).trim()
443 )))
444 }
445 }
446}
447
448#[cfg(feature = "runtime-secrets-aws")]
449fn aws_runtime_secrets_region() -> String {
450 std::env::var("AWS_REGION")
451 .ok()
452 .filter(|region| !region.trim().is_empty())
453 .or_else(|| {
454 std::env::var("AWS_DEFAULT_REGION")
455 .ok()
456 .filter(|region| !region.trim().is_empty())
457 })
458 .or_else(aws_cli_config_region)
459 .unwrap_or_else(|| "eu-north-1".to_string())
460}
461
462#[cfg(feature = "runtime-secrets-aws")]
463fn aws_cli_config_region() -> Option<String> {
464 let output = ProcessCommand::new("aws")
465 .args(["configure", "get", "region"])
466 .output()
467 .ok()?;
468 if !output.status.success() {
469 return None;
470 }
471 let region = String::from_utf8_lossy(&output.stdout).trim().to_string();
472 (!region.is_empty()).then_some(region)
473}
474
475#[cfg(not(feature = "runtime-secrets-aws"))]
476async fn promote_to_aws_secrets_manager(
477 _resolved: &[ResolvedRuntimeSecret],
478 _prefix: &str,
479 _bundle_digest: Option<&str>,
480 _environment: &str,
481 _tenant: &str,
482 _team: Option<&str>,
483) -> Result<PromoteRuntimeSecretsReport> {
484 Err(DeployerError::Config(
485 "AWS runtime secret promotion is not enabled".to_string(),
486 ))
487}
488
489pub fn run_admin_tunnel(args: AwsAdminTunnelRequest) -> Result<()> {
490 let deploy_dir = resolve_latest_deploy_dir(&args.bundle_dir, "aws")?;
491 let outputs_path = deploy_dir.join("terraform-outputs.json");
492 let outputs = load_terraform_outputs(&outputs_path)?;
493 let Some(admin_ca_secret_ref) = terraform_output_string(&outputs, "admin_ca_secret_ref") else {
494 return Err(DeployerError::Other(format!(
495 "missing admin_ca_secret_ref in {}; deploy the bundle first",
496 outputs_path.display()
497 )));
498 };
499
500 let Some(region) = aws_region_from_secret_arn(&admin_ca_secret_ref) else {
501 return Err(DeployerError::Other(
502 "failed to derive AWS region from admin secret ref".to_string(),
503 ));
504 };
505 let Some(name_prefix) = deploy_name_prefix_from_secret_arn(&admin_ca_secret_ref) else {
506 return Err(DeployerError::Other(
507 "failed to derive deploy name prefix from admin secret ref".to_string(),
508 ));
509 };
510
511 let cluster = format!("{name_prefix}-cluster");
512 let service = format!("{name_prefix}-service");
513
514 let task_arn = aws_cli_capture(
515 &[
516 "ecs",
517 "list-tasks",
518 "--region",
519 ®ion,
520 "--cluster",
521 &cluster,
522 "--service-name",
523 &service,
524 "--query",
525 "taskArns[0]",
526 "--output",
527 "text",
528 ],
529 "aws ecs list-tasks",
530 )?;
531 if task_arn.is_empty() || task_arn == "None" {
532 return Err(DeployerError::Other(format!(
533 "no running ECS task found for service {service}"
534 )));
535 }
536
537 let runtime_query = format!(
538 "tasks[0].containers[?name=='{}'].runtimeId | [0]",
539 args.container
540 );
541 let runtime_id = aws_cli_capture(
542 &[
543 "ecs",
544 "describe-tasks",
545 "--region",
546 ®ion,
547 "--cluster",
548 &cluster,
549 "--tasks",
550 &task_arn,
551 "--query",
552 &runtime_query,
553 "--output",
554 "text",
555 ],
556 "aws ecs describe-tasks",
557 )?;
558 if runtime_id.is_empty() || runtime_id == "None" {
559 return Err(DeployerError::Other(format!(
560 "no runtimeId found for container {}",
561 args.container
562 )));
563 }
564
565 let Some(task_id) = task_id_from_arn(&task_arn) else {
566 return Err(DeployerError::Other(
567 "failed to derive task id from task ARN".to_string(),
568 ));
569 };
570
571 maybe_write_tunnel_admin_certs(&args.bundle_dir, &outputs, ®ion, &name_prefix)?;
572
573 let target = format!("ecs:{cluster}_{task_id}_{runtime_id}");
574 let parameters = format!(
575 "{{\"host\":[\"127.0.0.1\"],\"portNumber\":[\"8433\"],\"localPortNumber\":[\"{}\"]}}",
576 args.local_port
577 );
578
579 println!(
580 "Opening admin tunnel on https://127.0.0.1:{}",
581 args.local_port
582 );
583 let cert_dir = tunnel_admin_cert_dir(&args.bundle_dir, &name_prefix);
584 if cert_dir.is_dir() {
585 println!("admin certs: {}", cert_dir.display());
586 println!(
587 "example: curl --cacert {0}/ca.crt --cert {0}/client.crt --key {0}/client.key https://127.0.0.1:{1}/admin/v1/health",
588 cert_dir.display(),
589 args.local_port
590 );
591 }
592 if let Some(value) = terraform_output_string(&outputs, "admin_client_cert_secret_ref") {
593 println!("admin_client_cert_secret_ref: {value}");
594 } else {
595 println!("note: this deployment does not publish admin client cert refs yet");
596 }
597 if let Some(value) = terraform_output_string(&outputs, "admin_client_key_secret_ref") {
598 println!("admin_client_key_secret_ref: {value}");
599 }
600 println!("Press Ctrl+C to stop.");
601
602 let status = ProcessCommand::new("aws")
603 .args([
604 "ssm",
605 "start-session",
606 "--region",
607 ®ion,
608 "--target",
609 &target,
610 "--document-name",
611 "AWS-StartPortForwardingSessionToRemoteHost",
612 "--parameters",
613 ¶meters,
614 ])
615 .stdin(Stdio::inherit())
616 .stdout(Stdio::inherit())
617 .stderr(Stdio::inherit())
618 .status()?;
619
620 if status.success() {
621 Ok(())
622 } else {
623 Err(DeployerError::Other(format!(
624 "admin tunnel exited with status {status}"
625 )))
626 }
627}
628
629fn aws_region_from_secret_arn(secret_arn: &str) -> Option<String> {
630 secret_arn.split(':').nth(3).map(|value| value.to_string())
631}
632
633fn maybe_write_tunnel_admin_certs(
634 bundle_dir: &Path,
635 outputs: &Value,
636 region: &str,
637 deploy_name_prefix: &str,
638) -> Result<()> {
639 let Some(client_cert_ref) = terraform_output_string(outputs, "admin_client_cert_secret_ref")
640 else {
641 return Ok(());
642 };
643 let Some(client_key_ref) = terraform_output_string(outputs, "admin_client_key_secret_ref")
644 else {
645 return Ok(());
646 };
647 let Some(ca_ref) = terraform_output_string(outputs, "admin_ca_secret_ref") else {
648 return Ok(());
649 };
650
651 let cert_dir = tunnel_admin_cert_dir(bundle_dir, deploy_name_prefix);
652 fs::create_dir_all(&cert_dir)?;
653 fs::write(
654 cert_dir.join("ca.crt"),
655 aws_cli_capture(
656 &[
657 "secretsmanager",
658 "get-secret-value",
659 "--region",
660 region,
661 "--secret-id",
662 &ca_ref,
663 "--query",
664 "SecretString",
665 "--output",
666 "text",
667 ],
668 "aws secretsmanager get-secret-value (admin ca)",
669 )?,
670 )?;
671 fs::write(
672 cert_dir.join("client.crt"),
673 aws_cli_capture(
674 &[
675 "secretsmanager",
676 "get-secret-value",
677 "--region",
678 region,
679 "--secret-id",
680 &client_cert_ref,
681 "--query",
682 "SecretString",
683 "--output",
684 "text",
685 ],
686 "aws secretsmanager get-secret-value (admin client cert)",
687 )?,
688 )?;
689 fs::write(
690 cert_dir.join("client.key"),
691 aws_cli_capture(
692 &[
693 "secretsmanager",
694 "get-secret-value",
695 "--region",
696 region,
697 "--secret-id",
698 &client_key_ref,
699 "--query",
700 "SecretString",
701 "--output",
702 "text",
703 ],
704 "aws secretsmanager get-secret-value (admin client key)",
705 )?,
706 )?;
707 Ok(())
708}
709
710fn deploy_name_prefix_from_secret_arn(secret_arn: &str) -> Option<String> {
711 let marker = ":secret:greentic/admin/";
712 let start = secret_arn.find(marker)? + marker.len();
713 let rest = &secret_arn[start..];
714 let prefix = rest.split('/').next()?;
715 if prefix.is_empty() {
716 None
717 } else {
718 Some(prefix.to_string())
719 }
720}
721
722fn task_id_from_arn(task_arn: &str) -> Option<String> {
723 task_arn.rsplit('/').next().map(|value| value.to_string())
724}
725
726fn aws_cli_capture(args: &[&str], label: &str) -> Result<String> {
727 let output = ProcessCommand::new("aws").args(args).output()?;
728 if !output.status.success() {
729 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
730 if stderr.is_empty() {
731 return Err(DeployerError::Other(format!(
732 "{label} failed with status {}",
733 output.status
734 )));
735 }
736 return Err(DeployerError::Other(format!("{label} failed: {stderr}")));
737 }
738 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
739}
740
741#[cfg(test)]
742mod tests {
743 use super::*;
744
745 #[test]
746 fn aws_request_defaults_to_aws_iac_target() {
747 let request = AwsRequest::new(DeployerCapability::Plan, "acme", PathBuf::from("pack-dir"))
748 .into_deployer_request();
749
750 assert_eq!(request.provider, Provider::Aws);
751 assert_eq!(request.strategy, "iac-only");
752 assert_eq!(request.tenant, "acme");
753 }
754
755 #[test]
756 fn aws_request_preserves_all_passthrough_fields() {
757 let mut request =
758 AwsRequest::new(DeployerCapability::Apply, "acme", PathBuf::from("pack-dir"));
759 request.bundle_root = Some(PathBuf::from("bundle-root"));
760 request.bundle_source = Some("s3://bucket/bundle.gtbundle".into());
761 request.bundle_digest = Some("sha256:abc".into());
762 request.repo_registry_base = Some("https://repo.example".into());
763 request.store_registry_base = Some("https://store.example".into());
764 request.provider_pack = Some(PathBuf::from("providers/deployer/aws.gtpack"));
765 request.deploy_pack_id_override = Some("greentic.deploy.aws".into());
766 request.deploy_flow_id_override = Some("apply_terraform".into());
767 request.environment = Some("prod".into());
768 request.pack_id = Some("pack-id".into());
769 request.pack_version = Some("1.2.3".into());
770 request.pack_digest = Some("sha256:def".into());
771 request.distributor_url = Some("https://dist.example".into());
772 request.distributor_token = Some("token".into());
773 request.preview = true;
774 request.dry_run = true;
775 request.execute_local = true;
776 request.output = OutputFormat::Json;
777 request.config_path = Some(PathBuf::from("greentic.toml"));
778 request.allow_remote_in_offline = true;
779 request.providers_dir = PathBuf::from("providers");
780 request.packs_dir = PathBuf::from("packs-dir");
781
782 let deployer = request.into_deployer_request();
783
784 assert_eq!(deployer.capability, DeployerCapability::Apply);
785 assert_eq!(deployer.provider, Provider::Aws);
786 assert_eq!(
787 deployer.bundle_root.as_deref(),
788 Some(Path::new("bundle-root"))
789 );
790 assert_eq!(
791 deployer.bundle_source.as_deref(),
792 Some("s3://bucket/bundle.gtbundle")
793 );
794 assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
795 assert_eq!(
796 deployer.repo_registry_base.as_deref(),
797 Some("https://repo.example")
798 );
799 assert_eq!(
800 deployer.store_registry_base.as_deref(),
801 Some("https://store.example")
802 );
803 assert_eq!(
804 deployer.provider_pack.as_deref(),
805 Some(Path::new("providers/deployer/aws.gtpack"))
806 );
807 assert_eq!(
808 deployer.deploy_pack_id_override.as_deref(),
809 Some("greentic.deploy.aws")
810 );
811 assert_eq!(
812 deployer.deploy_flow_id_override.as_deref(),
813 Some("apply_terraform")
814 );
815 assert_eq!(deployer.environment.as_deref(), Some("prod"));
816 assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
817 assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
818 assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
819 assert_eq!(
820 deployer.distributor_url.as_deref(),
821 Some("https://dist.example")
822 );
823 assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
824 assert!(deployer.preview);
825 assert!(deployer.dry_run);
826 assert!(deployer.execute_local);
827 assert_eq!(deployer.output, OutputFormat::Json);
828 assert_eq!(
829 deployer.config_path.as_deref(),
830 Some(Path::new("greentic.toml"))
831 );
832 assert!(deployer.allow_remote_in_offline);
833 assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
834 assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
835 }
836
837 #[test]
838 fn ensure_aws_config_rejects_non_aws_provider() {
839 let tmp = tempfile::tempdir().expect("tempdir");
840 let mut request = AwsRequest::new(DeployerCapability::Plan, "acme", tmp.path().into())
841 .into_deployer_request();
842 request.provider = Provider::Gcp;
843 let config = DeployerConfig::resolve(request).expect("resolve config");
844
845 let err = ensure_aws_config(&config).expect_err("non-aws config should fail");
846 assert!(
847 err.to_string().contains("provider=gcp strategy=iac-only"),
848 "got: {err}"
849 );
850 }
851
852 #[test]
853 fn ensure_aws_config_accepts_aws_iac_config() {
854 let tmp = tempfile::tempdir().expect("tempdir");
855 let request = AwsRequest::new(DeployerCapability::Plan, "acme", tmp.path().into())
856 .into_deployer_request();
857 let config = DeployerConfig::resolve(request).expect("resolve config");
858
859 ensure_aws_config(&config).expect("aws config");
860 }
861
862 #[test]
863 fn build_aws_request_from_ext_maps_cloud_bundle_fields() {
864 let cfg = AwsEcsFargateExtConfig {
865 region: "eu-north-1".to_string(),
866 environment: "prod".to_string(),
867 operator_image_digest: "sha256:0000".to_string(),
868 bundle_source: "oci://registry.example/acme/prod".to_string(),
869 bundle_digest: "sha256:1111".to_string(),
870 remote_state_backend: "s3://state/greentic/prod".to_string(),
871 redis_url: Some("redis://cache.example:6379/0".to_string()),
872 dns_name: Some("admin.example.com".to_string()),
873 public_base_url: Some("https://admin.example.com".to_string()),
874 repo_registry_base: Some("https://repo.example.com".to_string()),
875 store_registry_base: Some("https://store.example.com".to_string()),
876 admin_allowed_clients: Some("CN=admin".to_string()),
877 tenant: "acme".to_string(),
878 };
879
880 let request =
881 build_aws_request_from_ext(DeployerCapability::Destroy, &cfg, Some(Path::new("pack")));
882
883 assert_eq!(request.capability, DeployerCapability::Destroy);
884 assert_eq!(request.tenant, "acme");
885 assert_eq!(request.pack_path, PathBuf::from("pack"));
886 assert_eq!(
887 request.bundle_source.as_deref(),
888 Some("oci://registry.example/acme/prod")
889 );
890 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
891 assert_eq!(
892 request.repo_registry_base.as_deref(),
893 Some("https://repo.example.com")
894 );
895 assert_eq!(
896 request.store_registry_base.as_deref(),
897 Some("https://store.example.com")
898 );
899 assert_eq!(request.environment.as_deref(), Some("prod"));
900 assert!(request.execute_local);
901 assert_eq!(request.providers_dir, PathBuf::from("providers/deployer"));
902 assert_eq!(request.packs_dir, PathBuf::from("packs"));
903 }
904
905 #[test]
906 fn build_aws_request_from_ext_uses_empty_pack_when_missing() {
907 let cfg = AwsEcsFargateExtConfig {
908 region: "eu-north-1".to_string(),
909 environment: "dev".to_string(),
910 operator_image_digest: "sha256:0000".to_string(),
911 bundle_source: "s3://bucket/bundle.gtbundle".to_string(),
912 bundle_digest: "sha256:1111".to_string(),
913 remote_state_backend: "s3://state".to_string(),
914 redis_url: None,
915 dns_name: None,
916 public_base_url: None,
917 repo_registry_base: None,
918 store_registry_base: None,
919 admin_allowed_clients: None,
920 tenant: default_ext_tenant(),
921 };
922
923 let request = build_aws_request_from_ext(DeployerCapability::Apply, &cfg, None);
924
925 assert_eq!(request.capability, DeployerCapability::Apply);
926 assert_eq!(request.tenant, "default");
927 assert_eq!(request.pack_path, PathBuf::new());
928 assert_eq!(
929 request.bundle_source.as_deref(),
930 Some("s3://bucket/bundle.gtbundle")
931 );
932 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
933 assert!(request.repo_registry_base.is_none());
934 assert!(request.store_registry_base.is_none());
935 assert_eq!(request.output, OutputFormat::Text);
936 assert!(request.execute_local);
937 assert!(!request.preview);
938 assert!(!request.dry_run);
939 }
940
941 #[test]
942 fn run_admin_tunnel_reports_missing_admin_ca_before_aws_cli() {
943 let tmp = tempfile::tempdir().expect("tempdir");
944 let bundle_dir = tmp.path().join("bundle");
945 let deploy_dir = bundle_dir
946 .join(".greentic")
947 .join("deploy")
948 .join("aws")
949 .join("acme")
950 .join("staging");
951 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
952 fs::write(
953 deploy_dir.join("terraform-outputs.json"),
954 serde_json::to_vec_pretty(&serde_json::json!({})).expect("serialize outputs"),
955 )
956 .expect("write outputs");
957
958 let err = run_admin_tunnel(AwsAdminTunnelRequest {
959 bundle_dir: bundle_dir.clone(),
960 local_port: "9443".to_string(),
961 container: "operator".to_string(),
962 })
963 .expect_err("missing ca ref should fail");
964
965 assert!(
966 err.to_string().contains("missing admin_ca_secret_ref"),
967 "got: {err}"
968 );
969 }
970
971 #[test]
972 fn run_admin_tunnel_rejects_malformed_admin_ca_ref_before_aws_cli() {
973 let tmp = tempfile::tempdir().expect("tempdir");
974 let bundle_dir = tmp.path().join("bundle");
975 let deploy_dir = bundle_dir
976 .join(".greentic")
977 .join("deploy")
978 .join("aws")
979 .join("acme")
980 .join("staging");
981 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
982 fs::write(
983 deploy_dir.join("terraform-outputs.json"),
984 serde_json::to_vec_pretty(&serde_json::json!({
985 "admin_ca_secret_ref": { "value": "not-an-arn" }
986 }))
987 .expect("serialize outputs"),
988 )
989 .expect("write outputs");
990
991 let err = run_admin_tunnel(AwsAdminTunnelRequest {
992 bundle_dir,
993 local_port: "9443".to_string(),
994 container: "operator".to_string(),
995 })
996 .expect_err("malformed ca ref should fail");
997
998 assert!(
999 err.to_string().contains("failed to derive AWS region"),
1000 "got: {err}"
1001 );
1002 }
1003
1004 #[test]
1005 fn aws_admin_tunnel_helpers_parse_secret_and_task_refs() {
1006 let secret_arn =
1007 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca";
1008 assert_eq!(
1009 aws_region_from_secret_arn(secret_arn).as_deref(),
1010 Some("eu-north-1")
1011 );
1012 assert_eq!(
1013 deploy_name_prefix_from_secret_arn(secret_arn).as_deref(),
1014 Some("acme-prod")
1015 );
1016 assert_eq!(
1017 deploy_name_prefix_from_secret_arn(
1018 "arn:aws:secretsmanager:eu-north-1:123:secret:other/path"
1019 ),
1020 None
1021 );
1022 assert_eq!(
1023 deploy_name_prefix_from_secret_arn(
1024 "arn:aws:secretsmanager:eu-north-1:123:secret:greentic/admin//ca"
1025 ),
1026 None
1027 );
1028 assert_eq!(aws_region_from_secret_arn("not-an-arn"), None);
1029 assert_eq!(
1030 aws_region_from_secret_arn("arn:aws:secretsmanager::123:secret:name").as_deref(),
1031 Some("")
1032 );
1033 assert_eq!(
1034 task_id_from_arn("arn:aws:ecs:eu-north-1:123456789012:task/acme-prod-cluster/abc123")
1035 .as_deref(),
1036 Some("abc123")
1037 );
1038 assert_eq!(task_id_from_arn("abc123").as_deref(), Some("abc123"));
1039 assert_eq!(task_id_from_arn("cluster/").as_deref(), Some(""));
1040 }
1041
1042 #[test]
1043 fn maybe_write_tunnel_admin_certs_skips_when_refs_are_missing() {
1044 let tmp = tempfile::tempdir().expect("tempdir");
1045 let outputs = serde_json::json!({
1046 "admin_ca_secret_ref": {
1047 "value": "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca"
1048 }
1049 });
1050
1051 maybe_write_tunnel_admin_certs(tmp.path(), &outputs, "eu-north-1", "acme-prod")
1052 .expect("missing optional refs should skip");
1053
1054 assert!(!tunnel_admin_cert_dir(tmp.path(), "acme-prod").exists());
1055 }
1056
1057 #[test]
1058 fn maybe_write_tunnel_admin_certs_skips_for_each_missing_ref() {
1059 let tmp = tempfile::tempdir().expect("tempdir");
1060 let ca_ref =
1061 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca";
1062 let cert_ref = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/client-cert";
1063 let key_ref = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/client-key";
1064
1065 for outputs in [
1066 serde_json::json!({
1067 "admin_client_cert_secret_ref": { "value": cert_ref },
1068 "admin_client_key_secret_ref": { "value": key_ref }
1069 }),
1070 serde_json::json!({
1071 "admin_client_cert_secret_ref": { "value": cert_ref },
1072 "admin_ca_secret_ref": { "value": ca_ref }
1073 }),
1074 serde_json::json!({
1075 "admin_client_key_secret_ref": { "value": key_ref },
1076 "admin_ca_secret_ref": { "value": ca_ref }
1077 }),
1078 ] {
1079 maybe_write_tunnel_admin_certs(tmp.path(), &outputs, "eu-north-1", "acme-prod")
1080 .expect("incomplete cert refs should skip without shelling out");
1081 }
1082
1083 assert!(!tunnel_admin_cert_dir(tmp.path(), "acme-prod").exists());
1084 }
1085
1086 #[test]
1087 fn ext_config_parses_minimum_fields() {
1088 let json = r#"{
1089 "region": "us-east-1",
1090 "environment": "staging",
1091 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1092 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
1093 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1094 "remoteStateBackend": "s3://my-tf-state-bucket/greentic/staging"
1095 }"#;
1096 let cfg: AwsEcsFargateExtConfig = serde_json::from_str(json).unwrap();
1097 assert_eq!(cfg.region, "us-east-1");
1098 assert_eq!(cfg.environment, "staging");
1099 assert_eq!(cfg.tenant, "default");
1100 assert!(cfg.redis_url.is_none());
1101 assert!(cfg.dns_name.is_none());
1102 assert!(cfg.public_base_url.is_none());
1103 }
1104
1105 #[test]
1106 fn ext_config_accepts_all_optionals() {
1107 let json = r#"{
1108 "region": "us-east-1",
1109 "environment": "prod",
1110 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1111 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
1112 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1113 "remoteStateBackend": "s3://my-tf-state-bucket/greentic/prod",
1114 "redisUrl": "redis://shared.example.com:6379/0",
1115 "dnsName": "api.example.com",
1116 "publicBaseUrl": "https://api.example.com",
1117 "repoRegistryBase": "https://repo.example.com",
1118 "storeRegistryBase": "https://store.example.com",
1119 "adminAllowedClients": "CN=admin",
1120 "tenant": "acme"
1121 }"#;
1122 let cfg: AwsEcsFargateExtConfig = serde_json::from_str(json).unwrap();
1123 assert_eq!(
1124 cfg.redis_url.as_deref(),
1125 Some("redis://shared.example.com:6379/0")
1126 );
1127 assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
1128 assert_eq!(
1129 cfg.public_base_url.as_deref(),
1130 Some("https://api.example.com")
1131 );
1132 assert_eq!(cfg.tenant, "acme");
1133 }
1134
1135 #[test]
1136 fn ext_config_rejects_missing_region() {
1137 let json = r#"{
1138 "environment": "staging",
1139 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1140 "bundleSource": "oci://...",
1141 "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1142 "remoteStateBackend": "s3://..."
1143 }"#;
1144 let err = serde_json::from_str::<AwsEcsFargateExtConfig>(json).unwrap_err();
1145 assert!(format!("{err}").contains("region"), "got: {err}");
1146 }
1147
1148 #[test]
1149 fn apply_from_ext_rejects_invalid_json() {
1150 let err = apply_from_ext("not json", "{}", None).unwrap_err();
1151 assert!(format!("{err}").contains("parse"), "got: {err}");
1152 }
1153
1154 #[test]
1155 fn apply_from_ext_rejects_missing_required_field() {
1156 let json = r#"{"region":"us-east-1"}"#;
1157 let err = apply_from_ext(json, "{}", None).unwrap_err();
1158 let msg = format!("{err:#}");
1160 assert!(
1162 msg.contains("missing field")
1163 || msg.contains("bundleSource")
1164 || msg.contains("bundle_source"),
1165 "got: {msg}"
1166 );
1167 }
1168
1169 #[test]
1170 fn destroy_from_ext_rejects_invalid_json() {
1171 let err = destroy_from_ext("not json", "{}", None).unwrap_err();
1172 assert!(format!("{err}").contains("parse"), "got: {err}");
1173 }
1174
1175 #[test]
1176 fn destroy_from_ext_rejects_missing_required_field() {
1177 let json = r#"{"region":"eu-north-1"}"#;
1178 let err = destroy_from_ext(json, "{}", None).unwrap_err();
1179 let msg = format!("{err:#}");
1180 assert!(
1181 msg.contains("missing field")
1182 || msg.contains("bundleSource")
1183 || msg.contains("bundle_source"),
1184 "got: {msg}"
1185 );
1186 }
1187}