Skip to main content

greentic_deployer/
aws.rs

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/// Library-facing request for the explicit AWS adapter surface.
24#[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/// Configuration shape consumed by `ext apply --target aws-ecs-fargate-local`.
61///
62/// Mirrors the JSON schema declared by the `deploy-aws` reference extension.
63/// Keys use camelCase on the wire; Rust field names use snake_case with serde rename.
64#[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
170/// Build an `AwsRequest` from the extension-provided config. Used by
171/// `apply_from_ext` / `destroy_from_ext`. Fields unused by the extension
172/// path default to `None` / `false` / sensible defaults.
173fn 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
207/// Extension-driven apply entry point: parse JSON config, build request,
208/// delegate to existing `resolve_config` + `apply::run` pipeline.
209///
210/// `_creds_json` is reserved for future secret URI resolution (Phase B #2);
211/// today, AWS credentials come from the ambient provider chain.
212pub 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
229/// Extension-driven destroy entry point: same shape as `apply_from_ext`
230/// with `capability: Destroy`.
231pub 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/// [`RuntimeSecretSink`](crate::runtime_secret_sink::RuntimeSecretSink) backed by
319/// the `aws secretsmanager` CLI. Kept thin so the promotion orchestration is
320/// exercised against an in-memory mock instead of this.
321#[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        // A single `--tags` with all tags: repeating the flag makes the AWS CLI
359        // keep only the last one, which silently dropped every tag but one.
360        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            // put-secret-value updates the value but not tags, so a pre-existing
405            // secret stays untagged (this is why `list-secrets --filters
406            // tag-key=greentic:managed-by` came back empty). Apply tags
407            // explicitly; this is best-effort metadata, so don't fail the deploy.
408            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            &region,
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            &region,
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, &region, &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            &region,
606            "--target",
607            &target,
608            "--document-name",
609            "AWS-StartPortForwardingSessionToRemoteHost",
610            "--parameters",
611            &parameters,
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        // Use alternate display to include the full error chain (context + serde cause)
1174        let msg = format!("{err:#}");
1175        // serde error mentions missing field by name — either the Rust field or the JSON key
1176        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}