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: Option<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: Option<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.map(std::path::Path::to_path_buf),
182 bundle_root: None,
183 bundle_source: Some(cfg.bundle_source.clone()),
184 bundle_digest: Some(cfg.bundle_digest.clone()),
185 repo_registry_base: cfg.repo_registry_base.clone(),
186 store_registry_base: cfg.store_registry_base.clone(),
187 provider_pack: None,
188 deploy_pack_id_override: None,
189 deploy_flow_id_override: None,
190 environment: Some(cfg.environment.clone()),
191 pack_id: None,
192 pack_version: None,
193 pack_digest: None,
194 distributor_url: None,
195 distributor_token: None,
196 preview: false,
197 dry_run: false,
198 execute_local: true,
199 output: crate::config::OutputFormat::Text,
200 config_path: None,
201 allow_remote_in_offline: false,
202 providers_dir: std::path::PathBuf::from("providers/deployer"),
203 packs_dir: std::path::PathBuf::from("packs"),
204 }
205}
206
207pub fn apply_from_ext(
213 config_json: &str,
214 _creds_json: &str,
215 pack_path: Option<&std::path::Path>,
216) -> anyhow::Result<()> {
217 use anyhow::Context;
218 let cfg: AwsEcsFargateExtConfig =
219 serde_json::from_str(config_json).context("parse aws ecs-fargate config JSON")?;
220 let request = build_aws_request_from_ext(DeployerCapability::Apply, &cfg, pack_path);
221 let config = resolve_config(request).context("resolve AWS deployer config")?;
222 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for AWS deploy")?;
223 let _outcome = rt
224 .block_on(crate::apply::run(config))
225 .context("run AWS deployment pipeline")?;
226 Ok(())
227}
228
229pub fn destroy_from_ext(
232 config_json: &str,
233 _creds_json: &str,
234 pack_path: Option<&std::path::Path>,
235) -> anyhow::Result<()> {
236 use anyhow::Context;
237 let cfg: AwsEcsFargateExtConfig =
238 serde_json::from_str(config_json).context("parse aws ecs-fargate config JSON")?;
239 let request = build_aws_request_from_ext(DeployerCapability::Destroy, &cfg, pack_path);
240 let config = resolve_config(request).context("resolve AWS deployer config")?;
241 let rt = tokio::runtime::Runtime::new().context("create tokio runtime for AWS destroy")?;
242 let _outcome = rt
243 .block_on(crate::apply::run(config))
244 .context("run AWS destroy pipeline")?;
245 Ok(())
246}
247
248pub async fn run(request: AwsRequest) -> Result<multi_target::OperationResult> {
249 let config = resolve_config(request)?;
250 run_config(config).await
251}
252
253pub async fn run_config(config: DeployerConfig) -> Result<multi_target::OperationResult> {
254 ensure_aws_config(&config)?;
255 promote_runtime_secrets_for_apply(&config).await?;
256 multi_target::run(config).await
257}
258
259pub async fn run_with_plan(
260 request: AwsRequest,
261 plan: PlanContext,
262) -> Result<multi_target::OperationResult> {
263 let config = resolve_config(request)?;
264 run_config_with_plan(config, plan).await
265}
266
267pub async fn run_config_with_plan(
268 config: DeployerConfig,
269 plan: PlanContext,
270) -> Result<multi_target::OperationResult> {
271 ensure_aws_config(&config)?;
272 promote_runtime_secrets_for_apply(&config).await?;
273 multi_target::run_with_plan(config, plan).await
274}
275
276async fn promote_runtime_secrets_for_apply(config: &DeployerConfig) -> Result<()> {
277 let Some(resolution) = resolve_for_cloud_apply(config).await? else {
278 return Ok(());
279 };
280 let prefix = default_cloud_secret_prefix(&config.environment, &config.tenant, None);
281 promote_to_aws_secrets_manager(
282 &resolution.resolved,
283 &prefix,
284 config.bundle_digest.as_deref(),
285 &config.environment,
286 &config.tenant,
287 None,
288 )
289 .await?;
290 Ok(())
291}
292
293#[cfg(feature = "runtime-secrets-aws")]
294async fn promote_to_aws_secrets_manager(
295 resolved: &[ResolvedRuntimeSecret],
296 prefix: &str,
297 bundle_digest: Option<&str>,
298 environment: &str,
299 tenant: &str,
300 team: Option<&str>,
301) -> Result<PromoteRuntimeSecretsReport> {
302 let sink = AwsCliSecretSink {
303 region: aws_runtime_secrets_region(),
304 };
305 crate::runtime_secret_sink::promote_runtime_secrets(
306 &sink,
307 resolved,
308 prefix,
309 bundle_digest,
310 environment,
311 tenant,
312 team.unwrap_or("_"),
313 "aws",
314 "aws-secrets-manager",
315 )
316}
317
318#[cfg(feature = "runtime-secrets-aws")]
322struct AwsCliSecretSink {
323 region: String,
324}
325
326#[cfg(feature = "runtime-secrets-aws")]
327impl crate::runtime_secret_sink::RuntimeSecretSink for AwsCliSecretSink {
328 fn upsert(
329 &self,
330 name: &str,
331 value: &str,
332 tags: &[(String, String)],
333 ) -> Result<crate::runtime_secret_sink::UpsertOutcome> {
334 use crate::runtime_secret_sink::UpsertOutcome;
335
336 let mut temp = tempfile::NamedTempFile::new()
337 .map_err(|err| DeployerError::Other(format!("create temporary secret file: {err}")))?;
338 temp.write_all(value.as_bytes())?;
339 temp.flush()?;
340 let secret_file = format!(
341 "file://{}",
342 temp.path().to_str().ok_or_else(|| {
343 DeployerError::Other("temporary secret path is not UTF-8".to_string())
344 })?
345 );
346
347 let mut create = ProcessCommand::new("aws");
348 create.args([
349 "secretsmanager",
350 "create-secret",
351 "--region",
352 &self.region,
353 "--name",
354 name,
355 "--secret-string",
356 &secret_file,
357 ]);
358 if !tags.is_empty() {
361 create.arg("--tags");
362 for (key, value) in tags {
363 create.arg(format!("Key={key},Value={value}"));
364 }
365 }
366
367 let create = create
368 .stdout(Stdio::null())
369 .stderr(Stdio::piped())
370 .output()
371 .map_err(|err| {
372 DeployerError::Other(format!("run aws secretsmanager create-secret: {err}"))
373 })?;
374 if create.status.success() {
375 return Ok(UpsertOutcome::Created);
376 }
377
378 let create_stderr = String::from_utf8_lossy(&create.stderr);
379 if !create_stderr.contains("ResourceExistsException") {
380 return Err(DeployerError::Other(format!(
381 "create AWS Secrets Manager secret {name}: {}",
382 create_stderr.trim()
383 )));
384 }
385
386 let update = ProcessCommand::new("aws")
387 .args([
388 "secretsmanager",
389 "put-secret-value",
390 "--region",
391 &self.region,
392 "--secret-id",
393 name,
394 "--secret-string",
395 &secret_file,
396 ])
397 .stdout(Stdio::null())
398 .stderr(Stdio::piped())
399 .output()
400 .map_err(|err| {
401 DeployerError::Other(format!("run aws secretsmanager put-secret-value: {err}"))
402 })?;
403 if update.status.success() {
404 if !tags.is_empty() {
409 let mut tag = ProcessCommand::new("aws");
410 tag.args([
411 "secretsmanager",
412 "tag-resource",
413 "--region",
414 &self.region,
415 "--secret-id",
416 name,
417 "--tags",
418 ]);
419 for (key, value) in tags {
420 tag.arg(format!("Key={key},Value={value}"));
421 }
422 match tag.stdout(Stdio::null()).stderr(Stdio::piped()).output() {
423 Ok(out) if out.status.success() => {}
424 Ok(out) => tracing::warn!(
425 secret = %name,
426 stderr = %String::from_utf8_lossy(&out.stderr).trim(),
427 "failed to tag existing secret on update"
428 ),
429 Err(err) => tracing::warn!(
430 secret = %name,
431 %err,
432 "failed to invoke aws secretsmanager tag-resource"
433 ),
434 }
435 }
436 Ok(UpsertOutcome::Updated)
437 } else {
438 Err(DeployerError::Other(format!(
439 "update AWS Secrets Manager secret {name}: {}",
440 String::from_utf8_lossy(&update.stderr).trim()
441 )))
442 }
443 }
444}
445
446#[cfg(feature = "runtime-secrets-aws")]
447fn aws_runtime_secrets_region() -> String {
448 std::env::var("AWS_REGION")
449 .ok()
450 .filter(|region| !region.trim().is_empty())
451 .or_else(|| {
452 std::env::var("AWS_DEFAULT_REGION")
453 .ok()
454 .filter(|region| !region.trim().is_empty())
455 })
456 .or_else(aws_cli_config_region)
457 .unwrap_or_else(|| "eu-north-1".to_string())
458}
459
460#[cfg(feature = "runtime-secrets-aws")]
461fn aws_cli_config_region() -> Option<String> {
462 let output = ProcessCommand::new("aws")
463 .args(["configure", "get", "region"])
464 .output()
465 .ok()?;
466 if !output.status.success() {
467 return None;
468 }
469 let region = String::from_utf8_lossy(&output.stdout).trim().to_string();
470 (!region.is_empty()).then_some(region)
471}
472
473#[cfg(not(feature = "runtime-secrets-aws"))]
474async fn promote_to_aws_secrets_manager(
475 _resolved: &[ResolvedRuntimeSecret],
476 _prefix: &str,
477 _bundle_digest: Option<&str>,
478 _environment: &str,
479 _tenant: &str,
480 _team: Option<&str>,
481) -> Result<PromoteRuntimeSecretsReport> {
482 Err(DeployerError::Config(
483 "AWS runtime secret promotion is not enabled".to_string(),
484 ))
485}
486
487pub fn run_admin_tunnel(args: AwsAdminTunnelRequest) -> Result<()> {
488 let deploy_dir = resolve_latest_deploy_dir(&args.bundle_dir, "aws")?;
489 let outputs_path = deploy_dir.join("terraform-outputs.json");
490 let outputs = load_terraform_outputs(&outputs_path)?;
491 let Some(admin_ca_secret_ref) = terraform_output_string(&outputs, "admin_ca_secret_ref") else {
492 return Err(DeployerError::Other(format!(
493 "missing admin_ca_secret_ref in {}; deploy the bundle first",
494 outputs_path.display()
495 )));
496 };
497
498 let Some(region) = aws_region_from_secret_arn(&admin_ca_secret_ref) else {
499 return Err(DeployerError::Other(
500 "failed to derive AWS region from admin secret ref".to_string(),
501 ));
502 };
503 let Some(name_prefix) = deploy_name_prefix_from_secret_arn(&admin_ca_secret_ref) else {
504 return Err(DeployerError::Other(
505 "failed to derive deploy name prefix from admin secret ref".to_string(),
506 ));
507 };
508
509 let cluster = format!("{name_prefix}-cluster");
510 let service = format!("{name_prefix}-service");
511
512 let task_arn = aws_cli_capture(
513 &[
514 "ecs",
515 "list-tasks",
516 "--region",
517 ®ion,
518 "--cluster",
519 &cluster,
520 "--service-name",
521 &service,
522 "--query",
523 "taskArns[0]",
524 "--output",
525 "text",
526 ],
527 "aws ecs list-tasks",
528 )?;
529 if task_arn.is_empty() || task_arn == "None" {
530 return Err(DeployerError::Other(format!(
531 "no running ECS task found for service {service}"
532 )));
533 }
534
535 let runtime_query = format!(
536 "tasks[0].containers[?name=='{}'].runtimeId | [0]",
537 args.container
538 );
539 let runtime_id = aws_cli_capture(
540 &[
541 "ecs",
542 "describe-tasks",
543 "--region",
544 ®ion,
545 "--cluster",
546 &cluster,
547 "--tasks",
548 &task_arn,
549 "--query",
550 &runtime_query,
551 "--output",
552 "text",
553 ],
554 "aws ecs describe-tasks",
555 )?;
556 if runtime_id.is_empty() || runtime_id == "None" {
557 return Err(DeployerError::Other(format!(
558 "no runtimeId found for container {}",
559 args.container
560 )));
561 }
562
563 let Some(task_id) = task_id_from_arn(&task_arn) else {
564 return Err(DeployerError::Other(
565 "failed to derive task id from task ARN".to_string(),
566 ));
567 };
568
569 maybe_write_tunnel_admin_certs(&args.bundle_dir, &outputs, ®ion, &name_prefix)?;
570
571 let target = format!("ecs:{cluster}_{task_id}_{runtime_id}");
572 let parameters = format!(
573 "{{\"host\":[\"127.0.0.1\"],\"portNumber\":[\"8433\"],\"localPortNumber\":[\"{}\"]}}",
574 args.local_port
575 );
576
577 println!(
578 "Opening admin tunnel on https://127.0.0.1:{}",
579 args.local_port
580 );
581 let cert_dir = tunnel_admin_cert_dir(&args.bundle_dir, &name_prefix);
582 if cert_dir.is_dir() {
583 println!("admin certs: {}", cert_dir.display());
584 println!(
585 "example: curl --cacert {0}/ca.crt --cert {0}/client.crt --key {0}/client.key https://127.0.0.1:{1}/admin/v1/health",
586 cert_dir.display(),
587 args.local_port
588 );
589 }
590 if let Some(value) = terraform_output_string(&outputs, "admin_client_cert_secret_ref") {
591 println!("admin_client_cert_secret_ref: {value}");
592 } else {
593 println!("note: this deployment does not publish admin client cert refs yet");
594 }
595 if let Some(value) = terraform_output_string(&outputs, "admin_client_key_secret_ref") {
596 println!("admin_client_key_secret_ref: {value}");
597 }
598 println!("Press Ctrl+C to stop.");
599
600 let status = ProcessCommand::new("aws")
601 .args([
602 "ssm",
603 "start-session",
604 "--region",
605 ®ion,
606 "--target",
607 &target,
608 "--document-name",
609 "AWS-StartPortForwardingSessionToRemoteHost",
610 "--parameters",
611 ¶meters,
612 ])
613 .stdin(Stdio::inherit())
614 .stdout(Stdio::inherit())
615 .stderr(Stdio::inherit())
616 .status()?;
617
618 if status.success() {
619 Ok(())
620 } else {
621 Err(DeployerError::Other(format!(
622 "admin tunnel exited with status {status}"
623 )))
624 }
625}
626
627fn aws_region_from_secret_arn(secret_arn: &str) -> Option<String> {
628 secret_arn.split(':').nth(3).map(|value| value.to_string())
629}
630
631fn maybe_write_tunnel_admin_certs(
632 bundle_dir: &Path,
633 outputs: &Value,
634 region: &str,
635 deploy_name_prefix: &str,
636) -> Result<()> {
637 let Some(client_cert_ref) = terraform_output_string(outputs, "admin_client_cert_secret_ref")
638 else {
639 return Ok(());
640 };
641 let Some(client_key_ref) = terraform_output_string(outputs, "admin_client_key_secret_ref")
642 else {
643 return Ok(());
644 };
645 let Some(ca_ref) = terraform_output_string(outputs, "admin_ca_secret_ref") else {
646 return Ok(());
647 };
648
649 let cert_dir = tunnel_admin_cert_dir(bundle_dir, deploy_name_prefix);
650 fs::create_dir_all(&cert_dir)?;
651 fs::write(
652 cert_dir.join("ca.crt"),
653 aws_cli_capture(
654 &[
655 "secretsmanager",
656 "get-secret-value",
657 "--region",
658 region,
659 "--secret-id",
660 &ca_ref,
661 "--query",
662 "SecretString",
663 "--output",
664 "text",
665 ],
666 "aws secretsmanager get-secret-value (admin ca)",
667 )?,
668 )?;
669 fs::write(
670 cert_dir.join("client.crt"),
671 aws_cli_capture(
672 &[
673 "secretsmanager",
674 "get-secret-value",
675 "--region",
676 region,
677 "--secret-id",
678 &client_cert_ref,
679 "--query",
680 "SecretString",
681 "--output",
682 "text",
683 ],
684 "aws secretsmanager get-secret-value (admin client cert)",
685 )?,
686 )?;
687 fs::write(
688 cert_dir.join("client.key"),
689 aws_cli_capture(
690 &[
691 "secretsmanager",
692 "get-secret-value",
693 "--region",
694 region,
695 "--secret-id",
696 &client_key_ref,
697 "--query",
698 "SecretString",
699 "--output",
700 "text",
701 ],
702 "aws secretsmanager get-secret-value (admin client key)",
703 )?,
704 )?;
705 Ok(())
706}
707
708fn deploy_name_prefix_from_secret_arn(secret_arn: &str) -> Option<String> {
709 let marker = ":secret:greentic/admin/";
710 let start = secret_arn.find(marker)? + marker.len();
711 let rest = &secret_arn[start..];
712 let prefix = rest.split('/').next()?;
713 if prefix.is_empty() {
714 None
715 } else {
716 Some(prefix.to_string())
717 }
718}
719
720fn task_id_from_arn(task_arn: &str) -> Option<String> {
721 task_arn.rsplit('/').next().map(|value| value.to_string())
722}
723
724fn aws_cli_capture(args: &[&str], label: &str) -> Result<String> {
725 let output = ProcessCommand::new("aws").args(args).output()?;
726 if !output.status.success() {
727 let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
728 if stderr.is_empty() {
729 return Err(DeployerError::Other(format!(
730 "{label} failed with status {}",
731 output.status
732 )));
733 }
734 return Err(DeployerError::Other(format!("{label} failed: {stderr}")));
735 }
736 Ok(String::from_utf8_lossy(&output.stdout).trim().to_string())
737}
738
739#[cfg(test)]
740mod tests {
741 use super::*;
742
743 use tempfile::tempdir;
744
745 #[test]
746 fn aws_request_defaults_to_aws_iac_target() {
747 let request = AwsRequest::new(
748 DeployerCapability::Plan,
749 "acme",
750 Some(PathBuf::from("pack-dir")),
751 )
752 .into_deployer_request();
753
754 assert_eq!(request.provider, Provider::Aws);
755 assert_eq!(request.strategy, "iac-only");
756 assert_eq!(request.tenant, "acme");
757 }
758
759 #[test]
760 fn aws_request_preserves_all_passthrough_fields() {
761 let mut request = AwsRequest::new(
762 DeployerCapability::Apply,
763 "acme",
764 Some(PathBuf::from("pack-dir")),
765 );
766 request.bundle_root = Some(PathBuf::from("bundle-root"));
767 request.bundle_source = Some("s3://bucket/bundle.gtbundle".into());
768 request.bundle_digest = Some("sha256:abc".into());
769 request.repo_registry_base = Some("https://repo.example".into());
770 request.store_registry_base = Some("https://store.example".into());
771 request.provider_pack = Some(PathBuf::from("providers/deployer/aws.gtpack"));
772 request.deploy_pack_id_override = Some("greentic.deploy.aws".into());
773 request.deploy_flow_id_override = Some("apply_terraform".into());
774 request.environment = Some("prod".into());
775 request.pack_id = Some("pack-id".into());
776 request.pack_version = Some("1.2.3".into());
777 request.pack_digest = Some("sha256:def".into());
778 request.distributor_url = Some("https://dist.example".into());
779 request.distributor_token = Some("token".into());
780 request.preview = true;
781 request.dry_run = true;
782 request.execute_local = true;
783 request.output = OutputFormat::Json;
784 request.config_path = Some(PathBuf::from("greentic.toml"));
785 request.allow_remote_in_offline = true;
786 request.providers_dir = PathBuf::from("providers");
787 request.packs_dir = PathBuf::from("packs-dir");
788
789 let deployer = request.into_deployer_request();
790
791 assert_eq!(deployer.capability, DeployerCapability::Apply);
792 assert_eq!(deployer.provider, Provider::Aws);
793 assert_eq!(
794 deployer.bundle_root.as_deref(),
795 Some(Path::new("bundle-root"))
796 );
797 assert_eq!(
798 deployer.bundle_source.as_deref(),
799 Some("s3://bucket/bundle.gtbundle")
800 );
801 assert_eq!(deployer.bundle_digest.as_deref(), Some("sha256:abc"));
802 assert_eq!(
803 deployer.repo_registry_base.as_deref(),
804 Some("https://repo.example")
805 );
806 assert_eq!(
807 deployer.store_registry_base.as_deref(),
808 Some("https://store.example")
809 );
810 assert_eq!(
811 deployer.provider_pack.as_deref(),
812 Some(Path::new("providers/deployer/aws.gtpack"))
813 );
814 assert_eq!(
815 deployer.deploy_pack_id_override.as_deref(),
816 Some("greentic.deploy.aws")
817 );
818 assert_eq!(
819 deployer.deploy_flow_id_override.as_deref(),
820 Some("apply_terraform")
821 );
822 assert_eq!(deployer.environment.as_deref(), Some("prod"));
823 assert_eq!(deployer.pack_id.as_deref(), Some("pack-id"));
824 assert_eq!(deployer.pack_version.as_deref(), Some("1.2.3"));
825 assert_eq!(deployer.pack_digest.as_deref(), Some("sha256:def"));
826 assert_eq!(
827 deployer.distributor_url.as_deref(),
828 Some("https://dist.example")
829 );
830 assert_eq!(deployer.distributor_token.as_deref(), Some("token"));
831 assert!(deployer.preview);
832 assert!(deployer.dry_run);
833 assert!(deployer.execute_local);
834 assert_eq!(deployer.output, OutputFormat::Json);
835 assert_eq!(
836 deployer.config_path.as_deref(),
837 Some(Path::new("greentic.toml"))
838 );
839 assert!(deployer.allow_remote_in_offline);
840 assert_eq!(deployer.providers_dir, PathBuf::from("providers"));
841 assert_eq!(deployer.packs_dir, PathBuf::from("packs-dir"));
842 }
843
844 #[test]
845 fn ensure_aws_config_rejects_non_aws_provider() {
846 let tmp = tempfile::tempdir().expect("tempdir");
847 let mut request = AwsRequest::new(
848 DeployerCapability::Plan,
849 "acme",
850 Some(tmp.path().to_path_buf()),
851 )
852 .into_deployer_request();
853 request.provider = Provider::Gcp;
854 let config = DeployerConfig::resolve(request).expect("resolve config");
855
856 let err = ensure_aws_config(&config).expect_err("non-aws config should fail");
857 assert!(
858 err.to_string().contains("provider=gcp strategy=iac-only"),
859 "got: {err}"
860 );
861 }
862
863 #[test]
864 fn ensure_aws_config_accepts_aws_iac_config() {
865 let tmp = tempfile::tempdir().expect("tempdir");
866 let request = AwsRequest::new(
867 DeployerCapability::Plan,
868 "acme",
869 Some(tmp.path().to_path_buf()),
870 )
871 .into_deployer_request();
872 let config = DeployerConfig::resolve(request).expect("resolve config");
873
874 ensure_aws_config(&config).expect("aws config");
875 }
876
877 #[test]
878 fn build_aws_request_from_ext_maps_cloud_bundle_fields() {
879 let cfg = AwsEcsFargateExtConfig {
880 region: "eu-north-1".to_string(),
881 environment: "prod".to_string(),
882 operator_image_digest: "sha256:0000".to_string(),
883 bundle_source: "oci://registry.example/acme/prod".to_string(),
884 bundle_digest: "sha256:1111".to_string(),
885 remote_state_backend: "s3://state/greentic/prod".to_string(),
886 redis_url: Some("redis://cache.example:6379/0".to_string()),
887 dns_name: Some("admin.example.com".to_string()),
888 public_base_url: Some("https://admin.example.com".to_string()),
889 repo_registry_base: Some("https://repo.example.com".to_string()),
890 store_registry_base: Some("https://store.example.com".to_string()),
891 admin_allowed_clients: Some("CN=admin".to_string()),
892 tenant: "acme".to_string(),
893 };
894
895 let request =
896 build_aws_request_from_ext(DeployerCapability::Destroy, &cfg, Some(Path::new("pack")));
897
898 assert_eq!(request.capability, DeployerCapability::Destroy);
899 assert_eq!(request.tenant, "acme");
900 assert_eq!(request.pack_path, Some(PathBuf::from("pack")));
901 assert_eq!(
902 request.bundle_source.as_deref(),
903 Some("oci://registry.example/acme/prod")
904 );
905 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
906 assert_eq!(
907 request.repo_registry_base.as_deref(),
908 Some("https://repo.example.com")
909 );
910 assert_eq!(
911 request.store_registry_base.as_deref(),
912 Some("https://store.example.com")
913 );
914 assert_eq!(request.environment.as_deref(), Some("prod"));
915 assert!(request.execute_local);
916 assert_eq!(request.providers_dir, PathBuf::from("providers/deployer"));
917 assert_eq!(request.packs_dir, PathBuf::from("packs"));
918 }
919
920 #[test]
921 fn build_aws_request_from_ext_uses_empty_pack_when_missing() {
922 let cfg = AwsEcsFargateExtConfig {
923 region: "eu-north-1".to_string(),
924 environment: "dev".to_string(),
925 operator_image_digest: "sha256:0000".to_string(),
926 bundle_source: "s3://bucket/bundle.gtbundle".to_string(),
927 bundle_digest: "sha256:1111".to_string(),
928 remote_state_backend: "s3://state".to_string(),
929 redis_url: None,
930 dns_name: None,
931 public_base_url: None,
932 repo_registry_base: None,
933 store_registry_base: None,
934 admin_allowed_clients: None,
935 tenant: default_ext_tenant(),
936 };
937
938 let request = build_aws_request_from_ext(DeployerCapability::Apply, &cfg, None);
939
940 assert_eq!(request.capability, DeployerCapability::Apply);
941 assert_eq!(request.tenant, "default");
942 assert_eq!(request.pack_path, None);
943 assert_eq!(
944 request.bundle_source.as_deref(),
945 Some("s3://bucket/bundle.gtbundle")
946 );
947 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:1111"));
948 assert!(request.repo_registry_base.is_none());
949 assert!(request.store_registry_base.is_none());
950 assert_eq!(request.output, OutputFormat::Text);
951 assert!(request.execute_local);
952 assert!(!request.preview);
953 assert!(!request.dry_run);
954 }
955
956 #[test]
957 fn run_admin_tunnel_reports_missing_admin_ca_before_aws_cli() {
958 let tmp = tempfile::tempdir().expect("tempdir");
959 let bundle_dir = tmp.path().join("bundle");
960 let deploy_dir = bundle_dir
961 .join(".greentic")
962 .join("deploy")
963 .join("aws")
964 .join("acme")
965 .join("staging");
966 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
967 fs::write(
968 deploy_dir.join("terraform-outputs.json"),
969 serde_json::to_vec_pretty(&serde_json::json!({})).expect("serialize outputs"),
970 )
971 .expect("write outputs");
972
973 let err = run_admin_tunnel(AwsAdminTunnelRequest {
974 bundle_dir: bundle_dir.clone(),
975 local_port: "9443".to_string(),
976 container: "operator".to_string(),
977 })
978 .expect_err("missing ca ref should fail");
979
980 assert!(
981 err.to_string().contains("missing admin_ca_secret_ref"),
982 "got: {err}"
983 );
984 }
985
986 #[test]
987 fn run_admin_tunnel_rejects_malformed_admin_ca_ref_before_aws_cli() {
988 let tmp = tempfile::tempdir().expect("tempdir");
989 let bundle_dir = tmp.path().join("bundle");
990 let deploy_dir = bundle_dir
991 .join(".greentic")
992 .join("deploy")
993 .join("aws")
994 .join("acme")
995 .join("staging");
996 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
997 fs::write(
998 deploy_dir.join("terraform-outputs.json"),
999 serde_json::to_vec_pretty(&serde_json::json!({
1000 "admin_ca_secret_ref": { "value": "not-an-arn" }
1001 }))
1002 .expect("serialize outputs"),
1003 )
1004 .expect("write outputs");
1005
1006 let err = run_admin_tunnel(AwsAdminTunnelRequest {
1007 bundle_dir,
1008 local_port: "9443".to_string(),
1009 container: "operator".to_string(),
1010 })
1011 .expect_err("malformed ca ref should fail");
1012
1013 assert!(
1014 err.to_string().contains("failed to derive AWS region"),
1015 "got: {err}"
1016 );
1017 }
1018
1019 #[test]
1020 fn aws_admin_tunnel_helpers_parse_secret_and_task_refs() {
1021 let secret_arn =
1022 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca";
1023 assert_eq!(
1024 aws_region_from_secret_arn(secret_arn).as_deref(),
1025 Some("eu-north-1")
1026 );
1027 assert_eq!(
1028 deploy_name_prefix_from_secret_arn(secret_arn).as_deref(),
1029 Some("acme-prod")
1030 );
1031 assert_eq!(
1032 deploy_name_prefix_from_secret_arn(
1033 "arn:aws:secretsmanager:eu-north-1:123:secret:other/path"
1034 ),
1035 None
1036 );
1037 assert_eq!(
1038 deploy_name_prefix_from_secret_arn(
1039 "arn:aws:secretsmanager:eu-north-1:123:secret:greentic/admin//ca"
1040 ),
1041 None
1042 );
1043 assert_eq!(aws_region_from_secret_arn("not-an-arn"), None);
1044 assert_eq!(
1045 aws_region_from_secret_arn("arn:aws:secretsmanager::123:secret:name").as_deref(),
1046 Some("")
1047 );
1048 assert_eq!(
1049 task_id_from_arn("arn:aws:ecs:eu-north-1:123456789012:task/acme-prod-cluster/abc123")
1050 .as_deref(),
1051 Some("abc123")
1052 );
1053 assert_eq!(task_id_from_arn("abc123").as_deref(), Some("abc123"));
1054 assert_eq!(task_id_from_arn("cluster/").as_deref(), Some(""));
1055 }
1056
1057 #[test]
1058 fn maybe_write_tunnel_admin_certs_skips_when_refs_are_missing() {
1059 let tmp = tempfile::tempdir().expect("tempdir");
1060 let outputs = serde_json::json!({
1061 "admin_ca_secret_ref": {
1062 "value": "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca"
1063 }
1064 });
1065
1066 maybe_write_tunnel_admin_certs(tmp.path(), &outputs, "eu-north-1", "acme-prod")
1067 .expect("missing optional refs should skip");
1068
1069 assert!(!tunnel_admin_cert_dir(tmp.path(), "acme-prod").exists());
1070 }
1071
1072 #[test]
1073 fn maybe_write_tunnel_admin_certs_skips_for_each_missing_ref() {
1074 let tmp = tempfile::tempdir().expect("tempdir");
1075 let ca_ref =
1076 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/ca";
1077 let cert_ref = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/client-cert";
1078 let key_ref = "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/acme-prod/client-key";
1079
1080 for outputs in [
1081 serde_json::json!({
1082 "admin_client_cert_secret_ref": { "value": cert_ref },
1083 "admin_client_key_secret_ref": { "value": key_ref }
1084 }),
1085 serde_json::json!({
1086 "admin_client_cert_secret_ref": { "value": cert_ref },
1087 "admin_ca_secret_ref": { "value": ca_ref }
1088 }),
1089 serde_json::json!({
1090 "admin_client_key_secret_ref": { "value": key_ref },
1091 "admin_ca_secret_ref": { "value": ca_ref }
1092 }),
1093 ] {
1094 maybe_write_tunnel_admin_certs(tmp.path(), &outputs, "eu-north-1", "acme-prod")
1095 .expect("incomplete cert refs should skip without shelling out");
1096 }
1097
1098 assert!(!tunnel_admin_cert_dir(tmp.path(), "acme-prod").exists());
1099 }
1100
1101 #[test]
1102 fn ext_config_parses_minimum_fields() {
1103 let json = r#"{
1104 "region": "us-east-1",
1105 "environment": "staging",
1106 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1107 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
1108 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1109 "remoteStateBackend": "s3://my-tf-state-bucket/greentic/staging"
1110 }"#;
1111 let cfg: AwsEcsFargateExtConfig = serde_json::from_str(json).unwrap();
1112 assert_eq!(cfg.region, "us-east-1");
1113 assert_eq!(cfg.environment, "staging");
1114 assert_eq!(cfg.tenant, "default");
1115 assert!(cfg.redis_url.is_none());
1116 assert!(cfg.dns_name.is_none());
1117 assert!(cfg.public_base_url.is_none());
1118 }
1119
1120 #[test]
1121 fn ext_config_accepts_all_optionals() {
1122 let json = r#"{
1123 "region": "us-east-1",
1124 "environment": "prod",
1125 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1126 "bundleSource": "oci://registry.example/acme/prod-bundle@sha256:1111111111111111111111111111111111111111111111111111111111111111",
1127 "bundleDigest": "sha256:2222222222222222222222222222222222222222222222222222222222222222",
1128 "remoteStateBackend": "s3://my-tf-state-bucket/greentic/prod",
1129 "redisUrl": "redis://shared.example.com:6379/0",
1130 "dnsName": "api.example.com",
1131 "publicBaseUrl": "https://api.example.com",
1132 "repoRegistryBase": "https://repo.example.com",
1133 "storeRegistryBase": "https://store.example.com",
1134 "adminAllowedClients": "CN=admin",
1135 "tenant": "acme"
1136 }"#;
1137 let cfg: AwsEcsFargateExtConfig = serde_json::from_str(json).unwrap();
1138 assert_eq!(
1139 cfg.redis_url.as_deref(),
1140 Some("redis://shared.example.com:6379/0")
1141 );
1142 assert_eq!(cfg.dns_name.as_deref(), Some("api.example.com"));
1143 assert_eq!(
1144 cfg.public_base_url.as_deref(),
1145 Some("https://api.example.com")
1146 );
1147 assert_eq!(cfg.tenant, "acme");
1148 }
1149
1150 #[test]
1151 fn ext_config_rejects_missing_region() {
1152 let json = r#"{
1153 "environment": "staging",
1154 "operatorImageDigest": "sha256:0000000000000000000000000000000000000000000000000000000000000000",
1155 "bundleSource": "oci://...",
1156 "bundleDigest": "sha256:1111111111111111111111111111111111111111111111111111111111111111",
1157 "remoteStateBackend": "s3://..."
1158 }"#;
1159 let err = serde_json::from_str::<AwsEcsFargateExtConfig>(json).unwrap_err();
1160 assert!(format!("{err}").contains("region"), "got: {err}");
1161 }
1162
1163 #[test]
1164 fn apply_from_ext_rejects_invalid_json() {
1165 let err = apply_from_ext("not json", "{}", None).unwrap_err();
1166 assert!(format!("{err}").contains("parse"), "got: {err}");
1167 }
1168
1169 #[test]
1170 fn apply_from_ext_rejects_missing_required_field() {
1171 let json = r#"{"region":"us-east-1"}"#;
1172 let err = apply_from_ext(json, "{}", None).unwrap_err();
1173 let msg = format!("{err:#}");
1175 assert!(
1177 msg.contains("missing field")
1178 || msg.contains("bundleSource")
1179 || msg.contains("bundle_source"),
1180 "got: {msg}"
1181 );
1182 }
1183
1184 #[test]
1185 fn destroy_from_ext_rejects_invalid_json() {
1186 let err = destroy_from_ext("not json", "{}", None).unwrap_err();
1187 assert!(format!("{err}").contains("parse"), "got: {err}");
1188 }
1189
1190 #[test]
1191 fn destroy_from_ext_rejects_missing_required_field() {
1192 let json = r#"{"region":"eu-north-1"}"#;
1193 let err = destroy_from_ext(json, "{}", None).unwrap_err();
1194 let msg = format!("{err:#}");
1195 assert!(
1196 msg.contains("missing field")
1197 || msg.contains("bundleSource")
1198 || msg.contains("bundle_source"),
1199 "got: {msg}"
1200 );
1201 }
1202
1203 #[test]
1204 fn build_aws_request_from_ext_populates_extension_fields() {
1205 let cfg = AwsEcsFargateExtConfig {
1206 region: "eu-north-1".to_string(),
1207 environment: "prod".to_string(),
1208 operator_image_digest: "sha256:deadbeef".to_string(),
1209 bundle_source: "oci://registry.example/app@sha256:1111".to_string(),
1210 bundle_digest: "sha256:2222".to_string(),
1211 remote_state_backend: "s3://demo/state".to_string(),
1212 redis_url: None,
1213 dns_name: Some("api.example.com".to_string()),
1214 public_base_url: Some("https://api.example.com".to_string()),
1215 repo_registry_base: Some("https://repo.example.com".to_string()),
1216 store_registry_base: Some("https://store.example.com".to_string()),
1217 admin_allowed_clients: Some("CN=admin".to_string()),
1218 tenant: "acme".to_string(),
1219 };
1220
1221 let request = build_aws_request_from_ext(
1222 DeployerCapability::Destroy,
1223 &cfg,
1224 Some(Path::new("/tmp/demo-pack")),
1225 );
1226
1227 assert_eq!(request.capability, DeployerCapability::Destroy);
1228 assert_eq!(request.tenant, "acme");
1229 assert_eq!(request.pack_path, Some(PathBuf::from("/tmp/demo-pack")));
1230 assert_eq!(
1231 request.bundle_source.as_deref(),
1232 Some("oci://registry.example/app@sha256:1111")
1233 );
1234 assert_eq!(request.bundle_digest.as_deref(), Some("sha256:2222"));
1235 assert_eq!(request.environment.as_deref(), Some("prod"));
1236 assert_eq!(
1237 request.repo_registry_base.as_deref(),
1238 Some("https://repo.example.com")
1239 );
1240 assert_eq!(
1241 request.store_registry_base.as_deref(),
1242 Some("https://store.example.com")
1243 );
1244 assert!(request.execute_local);
1245 assert_eq!(request.output, OutputFormat::Text);
1246 }
1247
1248 #[test]
1249 fn helper_parsers_extract_expected_aws_values() {
1250 assert_eq!(
1251 aws_region_from_secret_arn(
1252 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/demo/ca"
1253 )
1254 .as_deref(),
1255 Some("eu-north-1")
1256 );
1257 assert_eq!(aws_region_from_secret_arn("invalid"), None);
1258
1259 assert_eq!(
1260 deploy_name_prefix_from_secret_arn(
1261 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/demo/ca"
1262 )
1263 .as_deref(),
1264 Some("demo")
1265 );
1266 assert_eq!(
1267 deploy_name_prefix_from_secret_arn(
1268 "arn:aws:secretsmanager:eu-north-1:123456789012:secret:other/demo/ca"
1269 ),
1270 None
1271 );
1272
1273 assert_eq!(
1274 task_id_from_arn("arn:aws:ecs:eu-north-1:123456789012:task/demo-cluster/task-123")
1275 .as_deref(),
1276 Some("task-123")
1277 );
1278 }
1279
1280 #[test]
1281 fn maybe_write_tunnel_admin_certs_skips_when_secret_refs_are_missing() {
1282 let temp = tempdir().expect("tempdir");
1283 let outputs = serde_json::json!({
1284 "admin_ca_secret_ref": {
1285 "value": "arn:aws:secretsmanager:eu-north-1:123456789012:secret:greentic/admin/demo/ca"
1286 }
1287 });
1288
1289 maybe_write_tunnel_admin_certs(temp.path(), &outputs, "eu-north-1", "demo")
1290 .expect("skip missing refs");
1291
1292 assert!(!tunnel_admin_cert_dir(temp.path(), "demo").exists());
1293 }
1294
1295 #[test]
1296 fn run_admin_tunnel_reports_missing_admin_ca_secret_ref() {
1297 let temp = tempdir().expect("tempdir");
1298 let bundle_dir = temp.path().join("bundle");
1299 let deploy_dir = bundle_dir
1300 .join(".greentic")
1301 .join("deploy")
1302 .join("aws")
1303 .join("demo")
1304 .join("state");
1305 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1306 fs::write(deploy_dir.join("terraform-outputs.json"), b"{}").expect("write outputs");
1307
1308 let err = run_admin_tunnel(AwsAdminTunnelRequest {
1309 bundle_dir,
1310 local_port: "8443".to_string(),
1311 container: "app".to_string(),
1312 })
1313 .unwrap_err();
1314
1315 assert!(format!("{err}").contains("missing admin_ca_secret_ref"));
1316 }
1317
1318 #[test]
1319 fn run_admin_tunnel_rejects_secret_refs_without_deploy_prefix() {
1320 let temp = tempdir().expect("tempdir");
1321 let bundle_dir = temp.path().join("bundle");
1322 let deploy_dir = bundle_dir
1323 .join(".greentic")
1324 .join("deploy")
1325 .join("aws")
1326 .join("demo")
1327 .join("state");
1328 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1329 fs::write(
1330 deploy_dir.join("terraform-outputs.json"),
1331 serde_json::to_vec_pretty(&serde_json::json!({
1332 "admin_ca_secret_ref": {
1333 "value": "arn:aws:secretsmanager:eu-north-1:123456789012:secret:other/demo/ca"
1334 }
1335 }))
1336 .expect("serialize outputs"),
1337 )
1338 .expect("write outputs");
1339
1340 let err = run_admin_tunnel(AwsAdminTunnelRequest {
1341 bundle_dir,
1342 local_port: "8443".to_string(),
1343 container: "app".to_string(),
1344 })
1345 .unwrap_err();
1346
1347 assert!(format!("{err}").contains("failed to derive deploy name prefix"));
1348 }
1349
1350 #[test]
1351 fn run_admin_tunnel_rejects_secret_refs_without_region() {
1352 let temp = tempdir().expect("tempdir");
1353 let bundle_dir = temp.path().join("bundle");
1354 let deploy_dir = bundle_dir
1355 .join(".greentic")
1356 .join("deploy")
1357 .join("aws")
1358 .join("demo")
1359 .join("state");
1360 fs::create_dir_all(&deploy_dir).expect("create deploy dir");
1361 fs::write(
1362 deploy_dir.join("terraform-outputs.json"),
1363 serde_json::to_vec_pretty(&serde_json::json!({
1364 "admin_ca_secret_ref": {
1365 "value": "invalid-secret-ref"
1366 }
1367 }))
1368 .expect("serialize outputs"),
1369 )
1370 .expect("write outputs");
1371
1372 let err = run_admin_tunnel(AwsAdminTunnelRequest {
1373 bundle_dir,
1374 local_port: "8443".to_string(),
1375 container: "app".to_string(),
1376 })
1377 .unwrap_err();
1378
1379 assert!(format!("{err}").contains("failed to derive AWS region"));
1380 }
1381}