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