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
12fn run_process_quiet(cmd: &mut Command, error_msg: &str) -> anyhow::Result<()> {
15 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
32pub 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
48pub 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 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
76pub 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 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
104pub 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 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
127pub 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
145pub 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
194pub 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 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
231pub 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
337pub fn ecr_ensure_repo_exists(repository: &str, region: &str) -> anyhow::Result<()> {
340 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 return Ok(());
358 }
359 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 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 let mut proc = Command::new("docker")
391 .arg("login")
392 .args(["--username", "AWS"])
393 .arg("--password-stdin")
394 .arg(®istry)
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
468pub 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
502pub 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
520pub 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 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 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
565pub fn ecr_get_last_pushed_commit_sha_tag(
567 repository: &str,
568 region: &str,
569) -> anyhow::Result<Option<String>> {
570 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 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
606pub 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
646pub fn aws_ecr_delete_tag_quiet(
649 repository: &str,
650 region: &str,
651 image_id: &str, rollback_tag: &str, ) -> 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 cmd.env("AWS_PAGER", "");
666 cmd.env("AWS_CLI_AUTO_PROMPT", "off");
667
668 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
684pub 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
715pub fn secretsmanager_put_secret_string(
718 secret_id: &str,
719 region: &str,
720 secret_string: &str,
721) -> anyhow::Result<()> {
722 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 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
750pub 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
773pub 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
799pub 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
820pub 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 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 return Ok(true);
844 }
845
846 let stderr = String::from_utf8_lossy(&output.stderr);
847
848 if stderr.contains("ResourceNotFoundException") {
849 return Ok(false);
851 }
852
853 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
863pub 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 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 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 if !stderr.contains("AlreadyExistsException") {
924 anyhow::bail!("create-document failed:\n{stderr}");
925 }
926 }
927 } else {
928 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 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
964pub fn cloudwatch_describe_log_streams_json(
967 region: &str,
968 log_group_name: &str,
969 limit: u32,
970) -> anyhow::Result<String> {
971 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}