Skip to main content

tracel_xtask/utils/aws/
cli.rs

1use std::{
2    collections::HashMap,
3    io::Write,
4    path::Path,
5    process::{Command, Stdio},
6};
7
8use anyhow::Context;
9
10use crate::{prelude::run_process, utils::process::run_process_capture_stdout};
11
12/// Run a process, discarding stdout and inheriting stderr.
13/// Fail on non-zero exit.
14fn run_process_quiet(cmd: &mut Command, error_msg: &str) -> anyhow::Result<()> {
15    // Discard stdout to avoid noise in our CLI output.
16    cmd.stdout(Stdio::null());
17
18    let status = cmd.status().with_context(|| {
19        format!(
20            "{error_msg} (failed to spawn '{}')",
21            cmd.get_program().to_string_lossy()
22        )
23    })?;
24
25    if !status.success() {
26        anyhow::bail!("{error_msg} (exit status {status})");
27    }
28
29    Ok(())
30}
31
32/// Run `aws` cli with passed arguments.
33/// Uses the generic `run_process`, but injects AWS env vars to disable pager/auto-prompt.
34pub fn aws_cli(
35    args: Vec<String>,
36    envs: Option<HashMap<&str, &str>>,
37    path: Option<&Path>,
38    error_msg: &str,
39) -> anyhow::Result<()> {
40    let mut merged_envs: HashMap<&str, &str> = envs.unwrap_or_default();
41    merged_envs.insert("AWS_PAGER", "");
42    merged_envs.insert("AWS_CLI_AUTO_PROMPT", "off");
43
44    let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
45    run_process("aws", &arg_refs, Some(merged_envs), path, error_msg)
46}
47
48/// Run `aws` cli with passed arguments, discarding stdout but keeping stderr.
49/// Useful for commands where you only care about success/failure, not output.
50pub fn aws_cli_quiet(
51    args: Vec<String>,
52    envs: Option<HashMap<&str, &str>>,
53    path: Option<&Path>,
54    error_msg: &str,
55) -> anyhow::Result<()> {
56    let mut cmd = Command::new("aws");
57
58    if let Some(p) = path {
59        cmd.current_dir(p);
60    }
61    if let Some(e) = envs {
62        cmd.envs(e);
63    }
64
65    // Always disable AWS pager and auto-prompt for our wrappers.
66    cmd.env("AWS_PAGER", "");
67    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
68
69    for a in &args {
70        cmd.arg(a);
71    }
72
73    run_process_quiet(&mut cmd, error_msg)
74}
75
76/// Run `aws` cli and capture stdout.
77/// Fail on non-zero exit.
78pub fn aws_cli_capture_stdout(
79    args: Vec<String>,
80    label: &str,
81    envs: Option<HashMap<&str, &str>>,
82    path: Option<&Path>,
83) -> anyhow::Result<String> {
84    let mut cmd = Command::new("aws");
85
86    if let Some(p) = path {
87        cmd.current_dir(p);
88    }
89    if let Some(e) = envs {
90        cmd.envs(e);
91    }
92
93    // Always disable AWS pager and auto-prompt for our wrappers.
94    cmd.env("AWS_PAGER", "");
95    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
96
97    for a in &args {
98        cmd.arg(a);
99    }
100
101    run_process_capture_stdout(&mut cmd, label)
102}
103
104/// Run `aws` cli and capture stdout.
105/// Return Ok(None) on non-zero exit.
106/// Useful for commands where “not found” is a non-zero exit you want to treat as absence.
107pub fn aws_cli_try_capture_stdout(
108    args: Vec<String>,
109    label: &str,
110) -> anyhow::Result<Option<String>> {
111    let mut cmd = Command::new("aws");
112
113    // Always disable AWS pager and auto-prompt for our wrappers.
114    cmd.env("AWS_PAGER", "");
115    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
116
117    cmd.args(&args);
118
119    let out = cmd.output().with_context(|| label.to_string())?;
120    if !out.status.success() {
121        return Ok(None);
122    }
123    let s = String::from_utf8(out.stdout).context("utf8 stdout")?;
124    Ok(Some(s))
125}
126
127/// Return the setup account Id of the local aws cli.
128pub fn aws_account_id() -> anyhow::Result<String> {
129    aws_cli_capture_stdout(
130        vec![
131            "sts".into(),
132            "get-caller-identity".into(),
133            "--query".into(),
134            "Account".into(),
135            "--output".into(),
136            "text".into(),
137        ],
138        "aws sts get-caller-identity",
139        None,
140        None,
141    )
142    .map(|s| s.trim().to_string())
143}
144
145// EC2 -----------------------------------------------------------------------
146
147pub fn ec2_describe_instances_json(
148    region: &str,
149    instance_ids: &[String],
150) -> anyhow::Result<String> {
151    anyhow::ensure!(
152        !instance_ids.is_empty(),
153        "ec2 describe-instances should be called with at least one instance id"
154    );
155
156    let mut args: Vec<String> = vec![
157        "ec2".into(),
158        "describe-instances".into(),
159        "--region".into(),
160        region.into(),
161        "--output".into(),
162        "json".into(),
163        "--instance-ids".into(),
164    ];
165    args.extend(instance_ids.iter().cloned());
166
167    aws_cli_capture_stdout(args, "aws ec2 describe-instances", None, None)
168        .map(|s| s.trim_end().to_string())
169}
170
171pub fn ec2_instance_get_console_output_json(
172    region: &str,
173    instance_id: &str,
174) -> anyhow::Result<String> {
175    aws_cli_capture_stdout(
176        vec![
177            "ec2".into(),
178            "get-console-output".into(),
179            "--instance-id".into(),
180            instance_id.into(),
181            "--region".into(),
182            region.into(),
183            "--latest".into(),
184            "--output".into(),
185            "json".into(),
186        ],
187        "aws ec2 get-console-output should succeed",
188        None,
189        None,
190    )
191    .map(|s| s.trim_end().to_string())
192}
193
194/// Start an Auto Scaling Group instance refresh and return its refresh ID.
195/// If you pass `None` for `preferences_json`, AWS will use the ASG defaults.
196/// Example preferences (as JSON string):
197/// r#"{"Strategy":"Rolling","InstanceWarmup":120,"MinHealthyPercentage":90}"#
198///
199/// Note: this only *starts* the refresh; it does not wait for completion.
200pub fn ec2_autoscaling_start_instance_refresh(
201    asg_name: &str,
202    region: &str,
203    strategy: &str,
204    preferences_json: Option<&str>,
205) -> anyhow::Result<String> {
206    let mut args = vec![
207        "autoscaling".into(),
208        "start-instance-refresh".into(),
209        "--auto-scaling-group-name".into(),
210        asg_name.into(),
211        "--strategy".into(),
212        strategy.into(),
213        "--region".into(),
214        region.into(),
215        "--query".into(),
216        "InstanceRefreshId".into(),
217        "--output".into(),
218        "text".into(),
219    ];
220
221    if let Some(prefs) = preferences_json {
222        // AWS CLI expects a JSON payload as a single argument
223        args.push("--preferences".into());
224        args.push(prefs.into());
225    }
226
227    aws_cli_capture_stdout(args, "aws autoscaling start-instance-refresh", None, None)
228        .map(|s| s.trim().to_string())
229}
230
231/// Get the latest instance refresh status for an ASG (if any).
232/// Possible values include: Pending, InProgress, Successful, Failed, Cancelling, Cancelled.
233pub fn ec2_autoscaling_latest_instance_refresh_status(
234    asg_name: &str,
235    region: &str,
236) -> anyhow::Result<Option<String>> {
237    let out = aws_cli_try_capture_stdout(
238        vec![
239            "autoscaling".into(),
240            "describe-instance-refreshes".into(),
241            "--auto-scaling-group-name".into(),
242            asg_name.into(),
243            "--region".into(),
244            region.into(),
245            "--query".into(),
246            "sort_by(InstanceRefreshes,&StartTime)[-1].Status".into(),
247            "--output".into(),
248            "text".into(),
249        ],
250        "aws autoscaling describe-instance-refreshes",
251    )?;
252
253    Ok(out
254        .map(|s| s.trim().to_string())
255        .filter(|s| !s.is_empty() && s != "None"))
256}
257
258pub fn ec2_autoscaling_rollback_instance_refresh(asg: &str, region: &str) -> anyhow::Result<()> {
259    use crate::prelude::anyhow::Context as _;
260    use std::process::Command;
261
262    let output = Command::new("aws")
263        .args([
264            "autoscaling",
265            "rollback-instance-refresh",
266            "--auto-scaling-group-name",
267            asg,
268            "--region",
269            region,
270        ])
271        .output()
272        .with_context(|| {
273            format!(
274                "Rollback of instance refresh for Auto Scaling Group '{}' in region '{}' should succeed",
275                asg, region
276            )
277        })?;
278
279    if !output.status.success() {
280        let stderr = String::from_utf8_lossy(&output.stderr);
281        anyhow::bail!(
282            "Rollback of instance refresh for Auto Scaling Group '{}' in region '{}' should succeed, \
283             but AWS CLI exited with:\n{}",
284            asg,
285            region,
286            stderr
287        );
288    }
289
290    Ok(())
291}
292
293pub fn ec2_autoscaling_describe_groups_json(
294    region: &str,
295    asg_name: &str,
296) -> anyhow::Result<String> {
297    aws_cli_capture_stdout(
298        vec![
299            "autoscaling".into(),
300            "describe-auto-scaling-groups".into(),
301            "--auto-scaling-group-names".into(),
302            asg_name.into(),
303            "--region".into(),
304            region.into(),
305            "--output".into(),
306            "json".into(),
307        ],
308        "aws autoscaling describe-auto-scaling-groups",
309        None,
310        None,
311    )
312    .map(|s| s.trim_end().to_string())
313}
314
315pub fn ec2_elbv2_describe_target_health_json(
316    region: &str,
317    target_group_arn: &str,
318) -> anyhow::Result<String> {
319    aws_cli_capture_stdout(
320        vec![
321            "elbv2".into(),
322            "describe-target-health".into(),
323            "--target-group-arn".into(),
324            target_group_arn.into(),
325            "--region".into(),
326            region.into(),
327            "--output".into(),
328            "json".into(),
329        ],
330        "aws elbv2 describe-target-health",
331        None,
332        None,
333    )
334    .map(|s| s.trim_end().to_string())
335}
336
337// ECR -----------------------------------------------------------------------
338
339pub fn ecr_ensure_repo_exists(repository: &str, region: &str) -> anyhow::Result<()> {
340    // We do not care about stdout for these calls; only success/failure.
341    if aws_cli_quiet(
342        vec![
343            "ecr".into(),
344            "describe-repositories".into(),
345            "--repository-names".into(),
346            repository.into(),
347            "--region".into(),
348            region.into(),
349        ],
350        None,
351        None,
352        "aws ecr describe-repositories should succeed",
353    )
354    .is_ok()
355    {
356        // repository found
357        return Ok(());
358    }
359    // create the repository
360    aws_cli_quiet(
361        vec![
362            "ecr".into(),
363            "create-repository".into(),
364            "--repository-name".into(),
365            repository.into(),
366            "--region".into(),
367            region.into(),
368        ],
369        None,
370        None,
371        "aws ecr create-repository should succeed",
372    )
373}
374
375pub fn ecr_docker_login(account_id: &str, region: &str) -> anyhow::Result<()> {
376    let registry = format!("{account_id}.dkr.ecr.{region}.amazonaws.com");
377    // get login password
378    let pass = aws_cli_capture_stdout(
379        vec![
380            "ecr".into(),
381            "get-login-password".into(),
382            "--region".into(),
383            region.into(),
384        ],
385        "aws ecr get-login-password",
386        None,
387        None,
388    )?;
389    // docker login
390    let mut proc = Command::new("docker")
391        .arg("login")
392        .args(["--username", "AWS"])
393        .arg("--password-stdin")
394        .arg(&registry)
395        .stdin(Stdio::piped())
396        .spawn()
397        .context("spawning docker login")?;
398    proc.stdin
399        .as_mut()
400        .ok_or_else(|| anyhow::anyhow!("no stdin for docker login"))?
401        .write_all(pass.trim_end().as_bytes())?;
402    let status = proc.wait().context("waiting on docker login")?;
403    if !status.success() {
404        return Err(anyhow::anyhow!("docker login failed: {status}"));
405    }
406    Ok(())
407}
408
409pub fn ecr_get_manifest(
410    repository: &str,
411    region: &str,
412    tag: &str,
413) -> anyhow::Result<Option<String>> {
414    let args = vec![
415        "ecr".into(),
416        "batch-get-image".into(),
417        "--repository-name".into(),
418        repository.into(),
419        "--region".into(),
420        region.into(),
421        "--image-ids".into(),
422        format!("imageTag={tag}"),
423        "--accepted-media-types".into(),
424        "application/vnd.docker.distribution.manifest.v2+json".into(),
425        "--query".into(),
426        "images[0].imageManifest".into(),
427        "--output".into(),
428        "text".into(),
429    ];
430    match aws_cli_try_capture_stdout(args, "aws ecr batch-get-image")? {
431        Some(s) => {
432            let s = s.trim().to_string();
433            if s.is_empty() || s == "None" {
434                Ok(None)
435            } else {
436                Ok(Some(s))
437            }
438        }
439        None => Ok(None),
440    }
441}
442
443pub fn ecr_put_manifest(
444    repository: &str,
445    region: &str,
446    tag: &str,
447    manifest: &str,
448) -> anyhow::Result<()> {
449    aws_cli_quiet(
450        vec![
451            "ecr".into(),
452            "put-image".into(),
453            "--repository-name".into(),
454            repository.into(),
455            "--region".into(),
456            region.into(),
457            "--image-tag".into(),
458            tag.into(),
459            "--image-manifest".into(),
460            manifest.into(),
461        ],
462        None,
463        None,
464        "aws ecr put-image should succeed",
465    )
466}
467
468/// Query the digest for a given repository, region and tag
469pub fn ecr_image_digest(
470    repository: &str,
471    tag: &str,
472    region: &str,
473) -> anyhow::Result<Option<String>> {
474    let json = aws_cli_capture_stdout(
475        vec![
476            "ecr".into(),
477            "describe-images".into(),
478            "--repository-name".into(),
479            repository.into(),
480            "--region".into(),
481            region.into(),
482            "--image-ids".into(),
483            format!("imageTag={tag}"),
484            "--query".into(),
485            "imageDetails[0].imageDigest".into(),
486            "--output".into(),
487            "text".into(),
488        ],
489        "aws ecr describe-images for digest",
490        None,
491        None,
492    )?;
493
494    let digest = json.trim();
495    if digest.is_empty() || digest.eq_ignore_ascii_case("None") {
496        Ok(None)
497    } else {
498        Ok(Some(digest.to_string()))
499    }
500}
501
502/// Generate the AWS Console URL that leads directly to the image details page
503/// for the given repository and tag.
504/// If the digest cannot be retrieved, return None.
505pub fn ecr_image_url(repository: &str, tag: &str, region: &str) -> anyhow::Result<Option<String>> {
506    let account_id = aws_account_id()?;
507    if let Some(digest) = ecr_image_digest(repository, tag, region)? {
508        Ok(Some(format!(
509            "https://{region}.console.aws.amazon.com/ecr/repositories/private/{account_id}/{repository}/_/image/{digest}/details?region={region}",
510            region = region,
511            account_id = account_id,
512            repository = repository,
513            digest = digest,
514        )))
515    } else {
516        Ok(None)
517    }
518}
519
520/// Get the commit sha for an alias tag (i.e. 'latest' or 'rollback')
521pub fn ecr_get_commit_sha_tag_from_alias_tag(
522    repository: &str,
523    alias_tag: &str,
524    region: &str,
525) -> anyhow::Result<Option<String>> {
526    // Describe the image by alias tag to get its image details with all tags
527    let json = aws_cli_capture_stdout(
528        vec![
529            "ecr".into(),
530            "describe-images".into(),
531            "--repository-name".into(),
532            repository.into(),
533            "--region".into(),
534            region.into(),
535            "--image-ids".into(),
536            format!("imageTag={alias_tag}"),
537            "--query".into(),
538            "imageDetails[0].imageTags".into(),
539            "--output".into(),
540            "json".into(),
541        ],
542        "aws ecr describe-images",
543        None,
544        None,
545    )?;
546
547    let v: serde_json::Value =
548        serde_json::from_str(&json).context("parsing describe-images output")?;
549    let tags = v.as_array().cloned().unwrap_or_default();
550
551    // Return the first non-alias tag that looks like a commit sha
552    let is_hexish = |s: &str| {
553        let len = s.len();
554        (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
555    };
556    let mut candidates: Vec<String> = tags
557        .into_iter()
558        .filter_map(|t| t.as_str().map(|s| s.to_string()))
559        .filter(|s| s != "latest" && s != "rollback" && is_hexish(s))
560        .collect();
561    candidates.sort_by_key(|s| std::cmp::Reverse(s.len()));
562    Ok(candidates.into_iter().next())
563}
564
565/// Get the commit sha tag for the most recently pushed image in the repo.
566pub fn ecr_get_last_pushed_commit_sha_tag(
567    repository: &str,
568    region: &str,
569) -> anyhow::Result<Option<String>> {
570    // Get tags for the most recent image by pushed time
571    let json = aws_cli_capture_stdout(
572        vec![
573            "ecr".into(),
574            "describe-images".into(),
575            "--repository-name".into(),
576            repository.into(),
577            "--region".into(),
578            region.into(),
579            "--query".into(),
580            "max_by(imageDetails, & imagePushedAt).imageTags".into(),
581            "--output".into(),
582            "json".into(),
583        ],
584        "aws ecr describe-images (last pushed)",
585        None,
586        None,
587    )?;
588
589    let v: serde_json::Value = serde_json::from_str(&json).context("parsing last-pushed tags")?;
590    let tags = v.as_array().cloned().unwrap_or_default();
591
592    // Return a non-alias tag that looks like a commit sha
593    let is_hexish = |s: &str| {
594        let len = s.len();
595        (7..=40).contains(&len) && s.chars().all(|c| c.is_ascii_hexdigit())
596    };
597    let mut candidates: Vec<String> = tags
598        .into_iter()
599        .filter_map(|t| t.as_str().map(|s| s.to_string()))
600        .filter(|s| s != "latest" && s != "rollback" && is_hexish(s))
601        .collect();
602    candidates.sort_by_key(|s| std::cmp::Reverse(s.len()));
603    Ok(candidates.into_iter().next())
604}
605
606/// Fetch the latest numerical tag and return it incremented by 1
607pub fn ecr_compute_next_numeric_tag(repository: &str, region: &str) -> anyhow::Result<u64> {
608    let json = aws_cli_capture_stdout(
609        vec![
610            "ecr".into(),
611            "describe-images".into(),
612            "--repository-name".into(),
613            repository.into(),
614            "--region".into(),
615            region.into(),
616            "--query".into(),
617            "imageDetails[].imageTags[]".into(),
618            "--output".into(),
619            "json".into(),
620        ],
621        "aws ecr describe-images",
622        None,
623        None,
624    )?;
625
626    let v: serde_json::Value =
627        serde_json::from_str(&json).context("parsing describe-images output")?;
628    let mut max_seen: u64 = 0;
629    if let serde_json::Value::Array(tags) = v {
630        for t in tags {
631            if let Some(s) = t.as_str() {
632                if !s.is_empty() && s.chars().all(|c| c.is_ascii_digit()) {
633                    if let Ok(n) = s.parse::<u64>() {
634                        if n > max_seen {
635                            max_seen = n;
636                        }
637                    }
638                }
639            }
640        }
641    }
642
643    Ok(max_seen.saturating_add(1).max(1))
644}
645
646/// Quietly delete an ECR tag (batch-delete-image), discarding stdout but keeping stderr
647/// and failing on non-zero exit.
648pub fn aws_ecr_delete_tag_quiet(
649    repository: &str,
650    region: &str,
651    image_id: &str,     // e.g. "imageTag=rollback_stag"
652    rollback_tag: &str, // for error messages
653) -> anyhow::Result<()> {
654    let mut cmd = Command::new("aws");
655    cmd.arg("ecr")
656        .arg("batch-delete-image")
657        .arg("--repository-name")
658        .arg(repository)
659        .arg("--image-ids")
660        .arg(image_id)
661        .arg("--region")
662        .arg(region);
663
664    // Disable AWS pager and auto-prompt so we never get an interactive UI.
665    cmd.env("AWS_PAGER", "");
666    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
667
668    // Discard stdout to avoid JSON noise, keep stderr inherited.
669    cmd.stdout(Stdio::null());
670
671    let status = cmd.status().with_context(|| {
672        format!(
673            "removing '{rollback_tag}' tag should succeed (failed to spawn aws ecr batch-delete-image)"
674        )
675    })?;
676
677    if !status.success() {
678        anyhow::bail!("removing '{rollback_tag}' tag should succeed (exit status {status})");
679    }
680
681    Ok(())
682}
683
684// Secrets Manager ------------------------------------------------------------
685
686/// Fetch the SecretString for a given secret.
687/// `secret_id` can be a name or an ARN.
688/// `out_format` can be either "text" or "json"
689pub fn secretsmanager_get_secret_string(
690    secret_id: &str,
691    region: &str,
692    out_format: &str,
693) -> anyhow::Result<String> {
694    let out = aws_cli_capture_stdout(
695        vec![
696            "secretsmanager".into(),
697            "get-secret-value".into(),
698            "--secret-id".into(),
699            secret_id.into(),
700            "--region".into(),
701            region.into(),
702            "--query".into(),
703            "SecretString".into(),
704            "--output".into(),
705            out_format.into(),
706        ],
707        "aws secretsmanager get-secret-value",
708        None,
709        None,
710    )?;
711
712    Ok(out.trim_end().to_string())
713}
714
715/// Put a new SecretString value for the given secret.
716/// This creates a new version of the secret.
717pub fn secretsmanager_put_secret_string(
718    secret_id: &str,
719    region: &str,
720    secret_string: &str,
721) -> anyhow::Result<()> {
722    // we avoid using `aws_cli` here to prevent logging the secret value in the process command line.
723    let mut cmd = Command::new("aws");
724    cmd.arg("secretsmanager")
725        .arg("put-secret-value")
726        .arg("--secret-id")
727        .arg(secret_id)
728        .arg("--region")
729        .arg(region)
730        .arg("--secret-string")
731        .arg(secret_string);
732
733    // Disable AWS pager and auto-prompt.
734    cmd.env("AWS_PAGER", "");
735    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
736
737    let status = cmd
738        .status()
739        .with_context(|| "aws secretsmanager put-secret-value should succeed".to_string())?;
740
741    if !status.success() {
742        return Err(anyhow::anyhow!(
743            "aws secretsmanager put-secret-value should succeed (exit status {status})"
744        ));
745    }
746
747    Ok(())
748}
749
750/// Create an empty Secrets Manager secret (metadata only, no initial version).
751pub fn secretsmanager_create_empty_secret(
752    name: &str,
753    region: &str,
754    description: Option<&str>,
755) -> anyhow::Result<()> {
756    let mut args = vec![
757        "secretsmanager".into(),
758        "create-secret".into(),
759        "--name".into(),
760        name.into(),
761        "--region".into(),
762        region.into(),
763    ];
764
765    if let Some(desc) = description {
766        args.push("--description".into());
767        args.push(desc.into());
768    }
769
770    aws_cli_quiet(args, None, None, "aws secretsmanager create-secret failed")
771}
772
773/// List all versions (including deprecated ones) for a given secret as raw JSON.
774/// `secret_id` can be a name or an ARN.
775pub fn secretsmanager_list_secret_versions_json(
776    secret_id: &str,
777    region: &str,
778) -> anyhow::Result<String> {
779    let out = aws_cli_capture_stdout(
780        vec![
781            "secretsmanager".into(),
782            "list-secret-version-ids".into(),
783            "--secret-id".into(),
784            secret_id.into(),
785            "--region".into(),
786            region.into(),
787            "--include-deprecated".into(),
788            "--output".into(),
789            "json".into(),
790        ],
791        "aws secretsmanager list-secret-version-ids",
792        None,
793        None,
794    )?;
795
796    Ok(out.trim_end().to_string())
797}
798
799/// Describe a Secrets Manager secret as raw JSON.
800pub fn secretsmanager_describe_secret(secret_id: &str, region: &str) -> anyhow::Result<String> {
801    let out = aws_cli_capture_stdout(
802        vec![
803            "secretsmanager".into(),
804            "describe-secret".into(),
805            "--secret-id".into(),
806            secret_id.into(),
807            "--region".into(),
808            region.into(),
809            "--output".into(),
810            "json".into(),
811        ],
812        "aws secretsmanager describe-secret",
813        None,
814        None,
815    )?;
816
817    Ok(out.trim_end().to_string())
818}
819
820/// Return Ok(true) if the secret exists, Ok(false) if it does not.
821pub fn secretsmanager_secret_exists(secret_id: &str, region: &str) -> anyhow::Result<bool> {
822    let mut cmd = Command::new("aws");
823    cmd.arg("secretsmanager")
824        .arg("describe-secret")
825        .arg("--secret-id")
826        .arg(secret_id)
827        .arg("--region")
828        .arg(region);
829
830    // Disable AWS pager and auto-prompt.
831    cmd.env("AWS_PAGER", "");
832    cmd.env("AWS_CLI_AUTO_PROMPT", "off");
833
834    let output = cmd.output().with_context(|| {
835        format!(
836            "Invoking 'aws secretsmanager describe-secret' for '{}' in region '{}' should succeed",
837            secret_id, region
838        )
839    })?;
840
841    if output.status.success() {
842        // Secret exists
843        return Ok(true);
844    }
845
846    let stderr = String::from_utf8_lossy(&output.stderr);
847
848    if stderr.contains("ResourceNotFoundException") {
849        // Secret does not exist
850        return Ok(false);
851    }
852
853    // other error
854    Err(anyhow::anyhow!(
855        "aws secretsmanager describe-secret for '{}' in region '{}' should succeed (exit status: {}, stderr: {})",
856        secret_id,
857        region,
858        output.status,
859        stderr.trim(),
860    ))
861}
862
863// Systems Manager -----------------------------------------------------------
864
865/// document to be able to login as a specific user in an SSM session
866pub fn ensure_ssm_document(doc_name: &str, region: &str, login_user: &str) -> anyhow::Result<()> {
867    let document_json = format!(
868        r#"{{
869        "schemaVersion": "1.0",
870        "description": "Xtask interactive shell",
871        "sessionType": "Standard_Stream",
872        "inputs": {{
873            "runAsEnabled": true,
874            "runAsDefaultUser": "{user}",
875            "shellProfile": {{
876                "linux": "cd ~; exec bash -l"
877            }}
878        }}
879    }}"#,
880        user = login_user,
881    );
882
883    // Check if document exists
884    let mut check_cmd = std::process::Command::new("aws");
885    check_cmd.args([
886        "ssm",
887        "describe-document",
888        "--name",
889        doc_name,
890        "--region",
891        region,
892    ]);
893    check_cmd.env("AWS_PAGER", "");
894    check_cmd.env("AWS_CLI_AUTO_PROMPT", "off");
895
896    let check = check_cmd.output().context("describe-document should run")?;
897
898    if !check.status.success() {
899        // Create doc
900        eprintln!("📄 Creating SSM document '{doc_name}'...");
901        let mut create_cmd = std::process::Command::new("aws");
902        create_cmd
903            .args([
904                "ssm",
905                "create-document",
906                "--name",
907                doc_name,
908                "--content",
909                &document_json,
910                "--document-type",
911                "Session",
912                "--region",
913                region,
914            ])
915            .env("AWS_PAGER", "")
916            .env("AWS_CLI_AUTO_PROMPT", "off");
917
918        let create = create_cmd.output().context("create-document should run")?;
919
920        if !create.status.success() {
921            let stderr = String::from_utf8_lossy(&create.stderr);
922            // In case of race
923            if !stderr.contains("AlreadyExistsException") {
924                anyhow::bail!("create-document failed:\n{stderr}");
925            }
926        }
927    } else {
928        // Update doc to ensure latest content
929        eprintln!("📄 Updating SSM document '{doc_name}' to latest content...");
930        let mut update_cmd = std::process::Command::new("aws");
931        update_cmd
932            .args([
933                "ssm",
934                "update-document",
935                "--name",
936                doc_name,
937                "--content",
938                &document_json,
939                "--document-version",
940                "$LATEST",
941                "--region",
942                region,
943            ])
944            .env("AWS_PAGER", "")
945            .env("AWS_CLI_AUTO_PROMPT", "off");
946
947        let update = update_cmd.output().context("update-document should run")?;
948
949        if !update.status.success() {
950            let stderr = String::from_utf8_lossy(&update.stderr);
951
952            // If content is identical, treat as success
953            if stderr.contains("DuplicateDocumentContent") {
954                eprintln!("ℹ️ SSM document '{doc_name}' is already up to date.");
955                return Ok(());
956            }
957            anyhow::bail!("update-document failed:\n{stderr}");
958        }
959    }
960
961    Ok(())
962}
963
964// Cloudwatch ----------------------------------------------------------------
965
966pub fn cloudwatch_describe_log_streams_json(
967    region: &str,
968    log_group_name: &str,
969    limit: u32,
970) -> anyhow::Result<String> {
971    // we take the most-recent streams first to pick the best match quickly
972    aws_cli_capture_stdout(
973        vec![
974            "logs".into(),
975            "describe-log-streams".into(),
976            "--log-group-name".into(),
977            log_group_name.into(),
978            "--region".into(),
979            region.into(),
980            "--order-by".into(),
981            "LastEventTime".into(),
982            "--descending".into(),
983            "--max-items".into(),
984            limit.to_string(),
985            "--output".into(),
986            "json".into(),
987        ],
988        "aws logs describe-log-streams",
989        None,
990        None,
991    )
992    .map(|s| s.trim_end().to_string())
993}