1use serde::Deserialize;
2use std::io::Write as _;
3use std::path::PathBuf;
6use std::time::{Duration, Instant};
7
8use crate::prelude::anyhow::Context as _;
9use crate::prelude::*;
10use crate::utils::aws::cli::{
11 aws_account_id, ec2_autoscaling_latest_instance_refresh_status,
12 ec2_autoscaling_start_instance_refresh, ecr_compute_next_numeric_tag, ecr_docker_login,
13 ecr_ensure_repo_exists, ecr_get_commit_sha_tag_from_alias_tag,
14 ecr_get_last_pushed_commit_sha_tag, ecr_get_manifest, ecr_put_manifest,
15};
16use crate::utils::aws::instance_system_log::stream_system_log;
17use crate::utils::git::git_repo_root_or_cwd;
18use crate::utils::process::{run_process, run_process_capture_stdout};
19
20const SSM_SESSION_DOC: &str = "Xtask-Container-InteractiveShell";
21
22#[tracel_xtask_macros::declare_command_args(None, ContainerSubCommand)]
23pub struct ContainerCmdArgs {}
24
25impl Default for ContainerSubCommand {
26 fn default() -> Self {
27 ContainerSubCommand::Build(ContainerBuildSubCmdArgs::default())
28 }
29}
30
31#[derive(clap::Args, Default, Clone, PartialEq)]
32pub struct ContainerBuildSubCmdArgs {
33 pub build_file: PathBuf,
35 #[arg(long)]
37 pub context_dir: Option<PathBuf>,
38 #[arg(long)]
40 pub image: String,
41 #[arg(long)]
43 pub build_tag: Option<String>,
44 #[arg(long)]
46 pub build_args: Vec<String>,
47 #[arg(long)]
49 pub force: bool,
50 #[arg(long)]
52 pub region: String,
53}
54
55#[derive(clap::Args, Default, Clone, PartialEq)]
56pub struct ContainerHostSubCmdArgs {
57 #[arg(long)]
59 pub region: String,
60
61 #[arg(long, value_name = "ASG_NAME")]
63 pub asg: String,
64
65 #[arg(long, default_value = "ubuntu")]
67 pub user: String,
68
69 #[arg(long)]
71 pub system_log: bool,
72}
73
74#[derive(clap::Args, Default, Clone, PartialEq)]
75pub struct ContainerListSubCmdArgs {
76 #[arg(long)]
78 pub region: String,
79 #[arg(long)]
81 pub repository: String,
82 #[arg(long)]
84 pub latest_tag: Option<String>,
85 #[arg(long)]
87 pub rollback_tag: Option<String>,
88}
89
90#[derive(clap::Args, Default, Clone, PartialEq)]
91pub struct ContainerLogsSubCmdArgs {
92 #[arg(long)]
94 pub region: String,
95 #[arg(long, value_name = "LOG_GROUP")]
97 pub log_group: String,
98 #[arg(long, default_value_t = false)]
100 pub follow: bool,
101 #[arg(long, default_value = "10m")]
103 pub since: String,
104 #[arg(long, value_name = "LOG_STREAM", action = clap::ArgAction::Append)]
106 pub log_stream_name: Vec<String>,
107 #[arg(long, value_name = "ASG_NAME")]
109 pub asg: Option<String>,
110}
111
112#[derive(clap::Args, Default, Clone, PartialEq)]
113pub struct ContainerPullSubCmdArgs {
114 #[arg(long)]
116 pub region: String,
117 #[arg(long)]
119 pub repository: String,
120 #[arg(long)]
122 pub tag: String,
123 #[arg(long)]
125 pub platform: Option<String>,
126}
127
128#[derive(clap::Args, Default, Clone, PartialEq)]
129pub struct ContainerPushSubCmdArgs {
130 #[arg(long)]
132 pub image: String,
133 #[arg(long)]
135 pub local_tag: String,
136 #[arg(long)]
138 pub region: String,
139 #[arg(long)]
141 pub repository: String,
142 #[arg(long)]
144 pub additional_tag: Option<String>,
145 #[arg(long)]
147 pub auto_remote_tag: bool,
148 #[arg(long)]
150 pub platform: Option<ContainerPlatform>,
151}
152
153#[derive(clap::Args, Default, Clone, PartialEq)]
154pub struct ContainerPromoteSubCmdArgs {
155 #[arg(long)]
157 pub region: String,
158 #[arg(long)]
160 pub repository: String,
161 #[arg(long)]
163 pub build_tag: String,
164 #[arg(long)]
166 pub promote_tag: Option<String>,
167 #[arg(long)]
169 pub rollback_tag: Option<String>,
170}
171
172#[derive(clap::Args, Default, Clone, PartialEq)]
173pub struct ContainerRollbackSubCmdArgs {
174 #[arg(long)]
176 pub region: String,
177 #[arg(long)]
179 pub repository: String,
180 #[arg(long)]
182 pub promote_tag: Option<String>,
183 #[arg(long)]
185 pub rollback_tag: Option<String>,
186}
187
188#[derive(clap::Args, Clone, PartialEq, Debug)]
189pub struct ContainerRolloutSubCmdArgs {
190 #[arg(long)]
192 pub region: String,
193
194 #[arg(long, value_name = "ASG_NAME")]
196 pub asg: String,
197
198 #[arg(long, value_name = "Rolling", default_value_t = ContainerRolloutSubCmdArgs::default().strategy)]
200 pub strategy: String,
201
202 #[arg(long, value_name = "SECS", default_value_t = ContainerRolloutSubCmdArgs::default().instance_warmup)]
204 pub instance_warmup: u64,
205
206 #[arg(long, value_name = "PCT", default_value_t = ContainerRolloutSubCmdArgs::default().max_healthy_percentage)]
208 pub max_healthy_percentage: u8,
209
210 #[arg(long, value_name = "PCT", default_value_t = ContainerRolloutSubCmdArgs::default().min_healthy_percentage)]
212 pub min_healthy_percentage: u8,
213
214 #[arg(long)]
216 pub promote_tag: Option<String>,
217
218 #[arg(long)]
220 pub repository: Option<String>,
221
222 #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().skip_matching)]
224 pub skip_matching: bool,
225
226 #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().wait)]
228 pub wait: bool,
229
230 #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().wait_timeout_secs)]
232 pub wait_timeout_secs: u64,
233
234 #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().wait_poll_secs)]
236 pub wait_poll_secs: u64,
237}
238
239#[derive(clap::Args, Default, Clone, PartialEq)]
240pub struct ContainerRunSubCmdArgs {
241 #[arg(long)]
243 pub image: String,
244
245 #[arg(long)]
247 pub name: Option<String>,
248
249 #[arg(long)]
251 pub env_file: Option<std::path::PathBuf>,
252
253 #[arg(long)]
255 pub host_network: bool,
256
257 #[arg(long)]
259 pub extra_arg: Vec<String>,
260}
261
262impl Default for ContainerRolloutSubCmdArgs {
263 fn default() -> Self {
264 Self {
265 region: String::new(),
266 asg: String::new(),
267 strategy: "Rolling".to_string(),
268 instance_warmup: 60,
269 max_healthy_percentage: 125,
270 min_healthy_percentage: 100,
271 promote_tag: None,
272 repository: None,
273 skip_matching: false,
274 wait: false,
275 wait_timeout_secs: 1800,
276 wait_poll_secs: 10,
277 }
278 }
279}
280
281#[derive(clap::ValueEnum, Clone, Debug, PartialEq)]
282pub enum ContainerPlatform {
283 LinuxAmd64,
284 LinuxArm64,
285}
286
287impl std::fmt::Display for ContainerPlatform {
288 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
289 match self {
290 ContainerPlatform::LinuxAmd64 => write!(f, "linux/amd64"),
291 ContainerPlatform::LinuxArm64 => write!(f, "linux/arm64"),
292 }
293 }
294}
295
296pub struct ManifestDigestDisplay<'a>(pub &'a str);
298
299impl<'a> std::fmt::Display for ManifestDigestDisplay<'a> {
300 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
301 match manifest_digest_short8(self.0) {
302 Ok(Some(d)) => write!(f, "{d}"),
303 _ => write!(f, "<unknown>"),
304 }
305 }
306}
307
308#[derive(Debug, Deserialize)]
309#[serde(rename_all = "camelCase")]
310struct OciIndex {
311 manifests: Vec<OciDescriptor>,
312}
313
314#[derive(Debug, Deserialize)]
315#[serde(rename_all = "camelCase")]
316struct OciDescriptor {
317 digest: String,
318}
319
320fn manifest_digest_short8(manifest_json: &str) -> anyhow::Result<Option<String>> {
322 let index: OciIndex = match serde_json::from_str(manifest_json) {
323 Ok(v) => v,
324 Err(_) => return Ok(None),
325 };
326
327 let digest = index.manifests.first().map(|m| m.digest.as_str());
328 let digest = match digest {
329 Some(d) => d,
330 None => return Ok(None),
331 };
332
333 let hex = digest.strip_prefix("sha256:").unwrap_or(digest);
334 Ok(Some(hex.chars().take(8).collect()))
335}
336
337pub fn handle_command(
338 args: ContainerCmdArgs,
339 env: Environment,
340 _ctx: Context,
341) -> anyhow::Result<()> {
342 match args.get_command() {
343 ContainerSubCommand::Build(build_args) => build(build_args),
344 ContainerSubCommand::Host(host_args) => host(host_args),
345 ContainerSubCommand::List(list_args) => list(list_args, &env),
346 ContainerSubCommand::Logs(logs_args) => logs(logs_args),
347 ContainerSubCommand::Pull(pull_args) => pull(pull_args),
348 ContainerSubCommand::Push(push_args) => push(push_args),
349 ContainerSubCommand::Promote(promote_args) => promote(promote_args, &env),
350 ContainerSubCommand::Rollback(rollback_args) => rollback(rollback_args, &env),
351 ContainerSubCommand::Rollout(rollout_args) => rollout(rollout_args, &env),
352 ContainerSubCommand::Run(run_args) => run(run_args),
353 }
354}
355
356fn promote_tag(tag: Option<String>, env: &Environment) -> String {
357 tag.unwrap_or(env.to_string())
358}
359
360fn rollback_tag(tag: Option<String>, env: &Environment) -> String {
361 tag.unwrap_or(format!("rollback_{env}"))
362}
363
364fn build(build_args: ContainerBuildSubCmdArgs) -> anyhow::Result<()> {
365 let context_dir = build_args.context_dir.unwrap_or(git_repo_root_or_cwd()?);
366 let build_file_path = if build_args.build_file.is_absolute() {
367 build_args.build_file.clone()
368 } else {
369 context_dir.join(&build_args.build_file)
370 };
371 let tag = build_args.build_tag.as_deref().unwrap_or("latest");
372
373 if ecr_get_manifest(&build_args.image, &build_args.region, tag)?.is_some() {
375 if build_args.force {
376 eprintln!(
377 "โ ๏ธ tag already exists in ECR. Forcing build the docker image because '--force' is set."
378 );
379 } else {
380 eprintln!(
381 "โ
Image already present in ECR: {}:{} (manifest {}). Skipping build.",
382 build_args.image,
383 tag,
384 ManifestDigestDisplay(
385 &ecr_get_manifest(&build_args.image, &build_args.region, tag)?
386 .unwrap_or_default()
387 ),
388 );
389 return Ok(());
390 }
391 }
392
393 let mut args: Vec<String> = vec![
394 "build".into(),
395 format!("--file={}", build_file_path.to_string_lossy()),
396 format!("--tag={}:{}", build_args.image, tag),
397 context_dir.to_string_lossy().into(),
399 ];
400 for kv in build_args.build_args {
401 args.insert(args.len() - 1, format!("--build-arg={kv}"));
403 }
404
405 docker_cli(args, None, None, "docker build should succeed")?;
406
407 let image = build_args.image;
408 eprintln!("๐ฆ Built container image: {image}");
409 eprintln!("๐ท๏ธ Image tag: {tag}");
410 eprintln!("๐ Full name: {image}:{tag}");
411 Ok(())
412}
413
414fn host(args: ContainerHostSubCmdArgs) -> anyhow::Result<()> {
415 let selected =
416 crate::utils::aws::asg_instance_picker::pick_asg_instance(&args.region, &args.asg)?;
417 if args.system_log {
418 eprintln!(
419 "๐ Streaming system log for {} ({}, {}) โ Ctrl-C to stop",
420 selected.instance_id,
421 selected.private_ip.as_deref().unwrap_or("no-ip"),
422 selected.az
423 );
424 stream_system_log(&args.region, &selected.instance_id)
425 } else {
426 aws::cli::ensure_ssm_document(SSM_SESSION_DOC, &args.region, &args.user)?;
427 eprintln!(
428 "๐ Connecting to {} ({}, {})",
429 selected.instance_id,
430 selected.private_ip.as_deref().unwrap_or("no-ip"),
431 selected.az
432 );
433
434 run_process(
435 "aws",
436 &[
437 "ssm",
438 "start-session",
439 "--target",
440 &selected.instance_id,
441 "--region",
442 &args.region,
443 "--document-name",
444 SSM_SESSION_DOC,
445 ],
446 None,
447 None,
448 "SSM session to container host should start successfully",
449 )
450 }
451}
452
453fn list(list_args: ContainerListSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
454 let ecr_repository = &list_args.repository;
455 let latest_tag = promote_tag(list_args.latest_tag, env);
456 let rollback_tag = rollback_tag(list_args.rollback_tag, env);
457 let latest_present =
458 ecr_get_manifest(ecr_repository, &list_args.region, &latest_tag)?.is_some();
459 let rollback_present =
460 ecr_get_manifest(ecr_repository, &list_args.region, &rollback_tag)?.is_some();
461 let latest_commit_tag = if latest_present {
462 ecr_get_commit_sha_tag_from_alias_tag(ecr_repository, &latest_tag, &list_args.region)?
463 } else {
464 None
465 };
466 let rollback_tag = if rollback_present {
467 ecr_get_commit_sha_tag_from_alias_tag(ecr_repository, &rollback_tag, &list_args.region)?
468 } else {
469 None
470 };
471 let last_pushed_tag = ecr_get_last_pushed_commit_sha_tag(ecr_repository, &list_args.region)?;
472
473 eprintln!(
474 "๐ Repository: {ecr_repository} (region {})",
475 list_args.region
476 );
477 match (latest_present, &latest_commit_tag) {
479 (true, Some(t)) => {
480 let url = aws::cli::ecr_image_url(ecr_repository, t, &list_args.region)?.unwrap();
481 eprintln!("โข latest: โ
\n ๐ท {t}\n ๐ {url}");
482 }
483 (true, None) => eprintln!("โข latest: โ
\n found but tag unknown"),
484 _ => eprintln!("โข latest: โ"),
485 }
486 match (rollback_present, &rollback_tag) {
488 (true, Some(t)) => {
489 let url = aws::cli::ecr_image_url(ecr_repository, t, &list_args.region)?.unwrap();
490 eprintln!("โข rollback: โ
\n ๐ท {t}\n ๐ {url}");
491 }
492 (true, None) => eprintln!("โข rollback: โ
\n found but tag unknown"),
493 _ => eprintln!("โข rollback: โ"),
494 }
495 match &last_pushed_tag {
497 Some(t) => {
498 let url = aws::cli::ecr_image_url(ecr_repository, t, &list_args.region)?.unwrap();
499 eprintln!("โข last pushed: โ
\n ๐ท {t}\n ๐ {url}");
500 }
501 None => eprintln!("โข last pushed: โ"),
502 }
503
504 Ok(())
505}
506
507fn logs(mut args: ContainerLogsSubCmdArgs) -> anyhow::Result<()> {
508 let mut format = "detailed";
509 if let Some(asg) = args.asg.as_deref() {
510 let selected =
511 crate::utils::aws::asg_instance_picker::pick_asg_instance(&args.region, asg)?;
512 eprintln!(
513 "๐ชต Tailing CloudWatch logs for ASG instance {}\n IP: {}\n AZ: {}\n Log group: {}",
514 selected.instance_id,
515 selected.private_ip.as_deref().unwrap_or("no-ip"),
516 selected.az,
517 args.log_group,
518 );
519
520 let stream =
521 crate::utils::aws::instance_logs::resolve_log_stream_name_containing_instance_id(
522 &args.region,
523 &args.log_group,
524 &selected.instance_id,
525 )?;
526 eprintln!(" Stream: {stream}");
527 args.log_stream_name.push(stream);
528 format = "short";
530 } else {
531 eprintln!(
532 "๐ชต Tailing CloudWatch logs\n Log group: {}\n Region: {}\n Since: {}\n Follow: {}",
533 args.log_group, args.region, args.since, args.follow,
534 );
535 }
536
537 let mut cli_args: Vec<String> = vec![
538 "logs".into(),
539 "tail".into(),
540 args.log_group.clone(),
541 "--region".into(),
542 args.region.clone(),
543 "--since".into(),
544 args.since.clone(),
545 "--format".into(),
546 format.into(),
547 ];
548
549 if args.follow {
550 cli_args.push("--follow".into());
551 }
552
553 if !args.log_stream_name.is_empty() {
554 cli_args.push("--log-stream-names".into());
555 cli_args.extend(args.log_stream_name.clone());
556 }
557
558 crate::utils::aws::cli::aws_cli(cli_args, None, None, "aws logs tail should succeed")
559}
560
561fn pull(args: ContainerPullSubCmdArgs) -> anyhow::Result<()> {
562 let account_id = aws_account_id()?;
563 eprintln!(
564 "๐ฅ Pulling image from ECR\n Account: {account_id}\n Region: {}\n Repo: {}\n Tag: {}",
565 args.region, args.repository, args.tag
566 );
567 ecr_docker_login(&account_id, &args.region)?;
568 let full_ref = format!(
570 "{account}.dkr.ecr.{region}.amazonaws.com/{repo}:{tag}",
571 account = account_id,
572 region = args.region,
573 repo = args.repository,
574 tag = args.tag,
575 );
576 let mut docker_args: Vec<String> = vec!["pull".into()];
577 if let Some(ref platform) = args.platform {
578 docker_args.push("--platform".into());
579 docker_args.push(platform.clone());
580 }
581 docker_args.push(full_ref.clone());
582 docker_cli(docker_args, None, None, "docker pull should succeed")?;
584 let url = aws::cli::ecr_image_url(&args.repository, &args.tag, &args.region)?;
585 eprintln!("โ
Pulled image: {full_ref}");
586 eprintln!("๐ฅ Pulled image from ECR");
587 eprintln!("๐๏ธ ECR repository: {}", args.repository);
588 eprintln!("๐ท๏ธ Tag: {}", args.tag);
589 if let Some(url) = url {
590 eprintln!("๐ Console URL: {url}");
591 }
592 Ok(())
593}
594
595fn push(push_args: ContainerPushSubCmdArgs) -> anyhow::Result<()> {
596 ecr_ensure_repo_exists(&push_args.repository, &push_args.region)?;
598 if let Some(ref required) = push_args.platform {
600 ensure_local_image_platform(
601 &push_args.image,
602 &push_args.local_tag,
603 &required.to_string(),
604 )?;
605 }
606 if let Some(existing_manifest) = ecr_get_manifest(
608 &push_args.repository,
609 &push_args.region,
610 &push_args.local_tag,
611 )? {
612 eprintln!(
613 "โน๏ธ Image with commit tag '{}' already exists in ECR, skipping push...",
614 push_args.local_tag
615 );
616
617 if let Some(explicit) = &push_args.additional_tag {
619 eprintln!(
620 "๐ท๏ธ Adding explicit alias tag '{}' to existing image",
621 explicit
622 );
623 ecr_put_manifest(
624 &push_args.repository,
625 &push_args.region,
626 explicit,
627 &existing_manifest,
628 )?;
629 eprintln!("โ
Added alias tag '{}'", explicit);
630 }
631 eprintln!("๐ Push completed");
632 } else {
633 let account_id = aws_account_id()?;
635 ecr_docker_login(&account_id, &push_args.region)?;
636
637 let registry = format!("{}.dkr.ecr.{}.amazonaws.com", account_id, push_args.region);
639 let repo_full = format!("{}/{}", registry, push_args.repository);
640 let primary_remote = format!("{repo_full}:{}", push_args.local_tag);
641 eprintln!(
642 "โก๏ธ Preparing to push primary tag (commit): {}",
643 push_args.local_tag
644 );
645 docker_cli(
646 vec![
647 "tag".into(),
648 format!("{}:{}", push_args.image, push_args.local_tag),
649 primary_remote.clone(),
650 ],
651 None,
652 None,
653 "docker tag (primary) should succeed",
654 )?;
655 docker_cli(
656 vec!["push".into(), primary_remote.clone()],
657 None,
658 None,
659 "docker push (primary) should succeed",
660 )?;
661
662 let mut extra_tags: Vec<String> = Vec::new();
664 if push_args.auto_remote_tag {
665 let next = ecr_compute_next_numeric_tag(&push_args.repository, &push_args.region)?;
666 eprintln!("๐ข Auto monotonic tag computed: {}", next);
667 extra_tags.push(next.to_string());
668 }
669 if let Some(explicit) = &push_args.additional_tag {
670 eprintln!("๐ท๏ธ Adding explicit extra tag: {}", explicit);
671 extra_tags.push(explicit.clone());
672 }
673
674 for tag in &extra_tags {
676 let remote = format!("{repo_full}:{tag}");
677 docker_cli(
678 vec![
679 "tag".into(),
680 format!("{}:{}", push_args.image, push_args.local_tag),
681 remote.clone(),
682 ],
683 None,
684 None,
685 "docker tag should succeed",
686 )?;
687 docker_cli(
688 vec!["push".into(), remote.clone()],
689 None,
690 None,
691 "docker push should succeed",
692 )?;
693 eprintln!("โ
Added extra tag: {}", tag);
694 }
695 eprintln!("๐ Push completed");
696 }
697
698 let url = aws::cli::ecr_image_url(
699 &push_args.repository,
700 &push_args.local_tag,
701 &push_args.region,
702 )?
703 .unwrap();
704 eprintln!(
705 "๐ค Pushed image: {}:{}",
706 push_args.image, push_args.local_tag
707 );
708 eprintln!("๐๏ธ ECR repository: {}", push_args.repository);
709 eprintln!(
710 "๐ Remote ref: {}:{}",
711 push_args.repository, push_args.local_tag
712 );
713 eprintln!("๐ Console URL: {url}");
714 Ok(())
715}
716
717fn promote(promote_args: ContainerPromoteSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
719 let promote_tag = promote_tag(promote_args.promote_tag, env);
720 eprintln!(
721 "Promoting '{}' to '{}'...",
722 &promote_args.build_tag, &promote_tag
723 );
724
725 let current_latest_manifest =
727 ecr_get_manifest(&promote_args.repository, &promote_args.region, &promote_tag)
728 .context("current '{promote_tag}' manifest should be retrievable")?;
729 if let Some(ref current) = current_latest_manifest {
730 eprintln!(
731 "Found previously promoted image with tag '{promote_tag}': {}",
732 ManifestDigestDisplay(current),
733 );
734 }
735 let to_promote_manifest = ecr_get_manifest(
736 &promote_args.repository,
737 &promote_args.region,
738 &promote_args.build_tag,
739 )?
740 .ok_or_else(|| {
741 anyhow::anyhow!(
742 "Tag '{}' not found in '{}'",
743 promote_args.build_tag,
744 promote_args.repository
745 )
746 })?;
747 eprintln!(
748 "Found new image to promote with tag '{promote_tag}': {}",
749 ManifestDigestDisplay(&to_promote_manifest),
750 );
751
752 if let Some(ref current) = current_latest_manifest {
754 if current == &to_promote_manifest {
755 eprintln!(
756 "โน๏ธ Tag '{}' is already promoted as '{promote_tag}' in registry '{}', no changes needed.",
757 promote_args.build_tag, promote_args.repository
758 );
759 return Ok(());
760 }
761 }
762
763 if let Some(current_manifest) = current_latest_manifest {
765 let rollback_tag = rollback_tag(promote_args.rollback_tag, env);
766 let current_rollback_manifest = ecr_get_manifest(
769 &promote_args.repository,
770 &promote_args.region,
771 &rollback_tag,
772 )
773 .context("current '{rollback_tag}' manifest should be retrievable")?;
774 if let Some(rollback_manifest) = current_rollback_manifest
775 && rollback_manifest == current_manifest
776 {
777 eprintln!(
778 "โ ๏ธ Tag '{rollback_tag}' is already assigned to manifest '{}', this should not happen and might indicate a bug!",
779 ManifestDigestDisplay(¤t_manifest),
780 );
781 } else {
782 ecr_put_manifest(
784 &promote_args.repository,
785 &promote_args.region,
786 &rollback_tag,
787 ¤t_manifest,
788 )
789 .context(format!(
790 "'{rollback_tag}' should be updated to the previous '{promote_tag}'"
791 ))?;
792 }
793 }
794
795 ecr_put_manifest(
797 &promote_args.repository,
798 &promote_args.region,
799 &promote_tag,
800 &to_promote_manifest,
801 )
802 .context(format!(
803 "'{promote_tag}' should be updated to the target manifest"
804 ))?;
805
806 eprintln!(
808 "โ
Promoted '{}' to '{promote_tag}'.",
809 promote_args.build_tag
810 );
811 let url = aws::cli::ecr_image_url(
812 &promote_args.repository,
813 &promote_args.build_tag,
814 &promote_args.region,
815 )?
816 .unwrap();
817 eprintln!("๐๏ธ Repository: {}", promote_args.repository);
818 eprintln!(
819 "๐ท๏ธ Tag โ (build) {} โ (latest) {promote_tag}",
820 promote_args.build_tag
821 );
822 eprintln!("โฉ๏ธ Previous '{promote_tag}' container (if any) moved to 'rollback_{promote_tag}'");
823 eprintln!("๐ Console URL: {url}");
824 Ok(())
825}
826
827fn rollback(rollback_args: ContainerRollbackSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
829 let rollback_tag = rollback_tag(rollback_args.rollback_tag, env);
830 let current_rb_manifest = ecr_get_manifest(
832 &rollback_args.repository,
833 &rollback_args.region,
834 &rollback_tag,
835 )?
836 .ok_or_else(|| {
837 anyhow::anyhow!(
838 "No '{rollback_tag}' tag found in '{}'",
839 rollback_args.repository
840 )
841 })?;
842 let promote_tag = promote_tag(rollback_args.promote_tag, env);
845 if ecr_get_manifest(
846 &rollback_args.repository,
847 &rollback_args.region,
848 &promote_tag,
849 )?
850 .as_ref()
851 != Some(¤t_rb_manifest)
852 {
853 ecr_put_manifest(
854 &rollback_args.repository,
855 &rollback_args.region,
856 &promote_tag,
857 ¤t_rb_manifest,
858 )
859 .context(format!(
860 "'{promote_tag}' should be updated to the '{rollback_tag}' manifest"
861 ))?;
862 eprintln!(
863 "โ
Promoted '{rollback_tag}' manifest '{}' to '{promote_tag}'.",
864 ManifestDigestDisplay(¤t_rb_manifest),
865 );
866 } else {
867 eprintln!(
868 "โน๏ธ '{promote_tag}' already points to the '{rollback_tag}' manifest, skipping promotion..."
869 );
870 }
871
872 let filter = format!("imageTag={rollback_tag}");
874 aws::cli::aws_ecr_delete_tag_quiet(
875 &rollback_args.repository,
876 &rollback_args.region,
877 &filter,
878 &rollback_tag,
879 )?;
880 eprintln!("๐งน Removed '{rollback_tag}' tag.");
881 eprintln!("โช Rolled back!");
882 eprintln!("๐๏ธ Repository: {}", rollback_args.repository);
883 let promote_commit_sha = aws::cli::ecr_get_commit_sha_tag_from_alias_tag(
884 &rollback_args.repository,
885 &promote_tag,
886 &rollback_args.region,
887 )?;
888 match promote_commit_sha {
889 Some(t) => {
890 let url =
891 aws::cli::ecr_image_url(&rollback_args.repository, &t, &rollback_args.region)?
892 .unwrap();
893 eprintln!("โ
'{promote_tag}' now points to: {t}");
894 eprintln!("๐ Console URL: {url}");
895 }
896 None => {
897 eprintln!(
899 "โ ๏ธ '{promote_tag}' updated, but could not resolve the underlying commit SHA."
900 );
901 }
902 }
903
904 Ok(())
905}
906
907fn rollout(rollout_args: ContainerRolloutSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
909 let preferences = serde_json::json!({
911 "InstanceWarmup": rollout_args.instance_warmup,
912 "MaxHealthyPercentage": rollout_args.max_healthy_percentage,
913 "MinHealthyPercentage": rollout_args.min_healthy_percentage,
914 "SkipMatching": rollout_args.skip_matching,
915 })
916 .to_string();
917
918 let refresh_id = ec2_autoscaling_start_instance_refresh(
920 &rollout_args.asg,
921 &rollout_args.region,
922 &rollout_args.strategy,
923 Some(&preferences),
924 )
925 .context("instance refresh should start")?;
926
927 let console_url = format!(
928 "https://{region}.console.aws.amazon.com/ec2/home?region={region}#AutoScalingGroupDetails:id={asg};view=instanceRefresh",
929 region = rollout_args.region,
930 asg = rollout_args.asg,
931 );
932
933 let promote_tag = promote_tag(rollout_args.promote_tag, env);
935 let container_line = match rollout_args.repository.as_deref() {
936 Some(repo) => {
937 ecr_get_commit_sha_tag_from_alias_tag(repo, &promote_tag, &rollout_args.region)?
938 .map(|commit_tag| format!(" Image: {repo}:{commit_tag}"))
939 }
940 None => None,
941 };
942
943 eprintln!("๐ Started instance refresh");
944 eprintln!(" ASG: {}", rollout_args.asg);
945 eprintln!(" Region: {}", rollout_args.region);
946 if let Some(line) = container_line {
947 eprintln!("{line}");
948 }
949 eprintln!(" Refresh: {}", refresh_id);
950 eprintln!(" Console: {}", console_url);
951
952 if rollout_args.wait {
953 let spinner_frames = ["โ ", "โ ", "โ น", "โ ธ", "โ ผ", "โ ด", "โ ฆ", "โ ง", "โ ", "โ "];
954 let mut frame_index = 0;
955
956 let mut start = Instant::now();
957 let timeout = Duration::from_secs(rollout_args.wait_timeout_secs);
958 let poll = Duration::from_secs(rollout_args.wait_poll_secs);
959 const CLR_EOL: &str = "\x1b[K";
960
961 let mut rollback_triggered = false;
963
964 loop {
965 let spinner = spinner_frames[frame_index % spinner_frames.len()];
966 frame_index += 1;
967
968 let status_opt = ec2_autoscaling_latest_instance_refresh_status(
969 &rollout_args.asg,
970 &rollout_args.region,
971 )
972 .context("instance refresh status should be retrievable")?;
973
974 let (emoji, msg) = match status_opt.as_deref() {
975 Some("Pending") => ("โณ", "Pending"),
976 Some("InProgress") => ("๐ง", "In progress"),
977 Some("Successful") => ("โ
", "Completed successfully"),
978 Some("Failed") => ("โ", "Failed"),
979 Some("Cancelled") => ("โ ๏ธ", "Cancelled"),
980 Some(other) => ("โ", other),
981 None => ("๐", "Waiting..."),
982 };
983
984 let elapsed = start.elapsed();
986 let elapsed_secs = elapsed.as_secs();
987 let min = elapsed_secs / 60;
988 let sec = elapsed_secs % 60;
989
990 print!(
991 "\r{spinner} {emoji} ({min:02}:{sec:02}) Refreshing {asg} โ Status: {msg:<20}{CLR_EOL}",
992 asg = rollout_args.asg,
993 msg = msg,
994 );
995 std::io::stdout().flush().ok();
996
997 match status_opt.as_deref() {
998 Some("Successful") => {
999 println!("\rโ
Rollout completed successfully in {min:02}:{sec:02}!{CLR_EOL}");
1000
1001 if rollback_triggered {
1002 anyhow::bail!(
1003 "rollout completed successfully but a container rollback was triggered during the wait window"
1004 );
1005 }
1006 return Ok(());
1007 }
1008 Some("Failed") => {
1009 println!("\rโ Rollout failed after {min:02}:{sec:02}.{CLR_EOL}");
1010 anyhow::bail!("rollout finished with status: Failed");
1011 }
1012 Some("Cancelled") => {
1013 println!("\rโ ๏ธ Rollout cancelled after {min:02}:{sec:02}.{CLR_EOL}");
1014 anyhow::bail!("rollout finished with status: Cancelled");
1015 }
1016 _ => {}
1017 }
1018
1019 if elapsed >= timeout {
1020 if !rollback_triggered {
1021 println!(
1023 "\rโฐ Timeout after {min:02}:{sec:02} (limit: {}s).{CLR_EOL}",
1024 rollout_args.wait_timeout_secs
1025 );
1026 eprintln!(
1027 "๐ Rolling back container state while keeping the current instance refresh..."
1028 );
1029
1030 let rollback_tag = rollback_tag(None, env);
1031 if let Some(ref repo) = rollout_args.repository {
1032 let rb_args = ContainerRollbackSubCmdArgs {
1033 region: rollout_args.region.clone(),
1034 repository: repo.clone(),
1035 promote_tag: Some(promote_tag.clone()),
1036 rollback_tag: Some(rollback_tag),
1037 };
1038
1039 rollback(rb_args, env).context("Container rollback should succeed")?;
1040
1041 rollback_triggered = true;
1042 start = Instant::now();
1044 continue;
1045 } else {
1046 eprintln!(
1047 "โ ๏ธ No container repository was provided to 'rollout', skipping container rollback."
1048 );
1049 anyhow::bail!(
1050 "rollout timed out after {} seconds and no container repository was provided to roll back",
1051 rollout_args.wait_timeout_secs
1052 );
1053 }
1054 } else {
1055 println!(
1057 "\rโฐ Timeout after container rollback: {min:02}:{sec:02} (extra limit: {}s).{CLR_EOL}",
1058 rollout_args.wait_timeout_secs
1059 );
1060 anyhow::bail!(
1061 "rollout still not successful after container rollback and an additional {} seconds",
1062 rollout_args.wait_timeout_secs
1063 );
1064 }
1065 }
1066
1067 std::thread::sleep(poll);
1068 }
1069 }
1070
1071 Ok(())
1072}
1073
1074fn run(args: ContainerRunSubCmdArgs) -> anyhow::Result<()> {
1075 let mut cli_args: Vec<String> = vec!["run".into(), "--rm".into()];
1076
1077 if let Some(ref name) = args.name {
1078 cli_args.push("--name".into());
1079 cli_args.push(name.clone());
1080 }
1081
1082 if let Some(ref env_file) = args.env_file {
1083 cli_args.push("--env-file".into());
1084 cli_args.push(env_file.to_string_lossy().into_owned());
1085 }
1086
1087 if args.host_network {
1088 cli_args.push("--network".into());
1089 cli_args.push("host".into());
1090 }
1091
1092 cli_args.extend(args.extra_arg.clone());
1094
1095 cli_args.push(args.image.clone());
1097
1098 docker_cli(cli_args, None, None, "docker run should succeed")?;
1099
1100 eprintln!("โถ๏ธ Running container: {}", args.image);
1101 if let Some(ref env_file) = args.env_file {
1102 eprintln!("๐ Using merged env file: {}", env_file.display());
1103 }
1104 Ok(())
1105}
1106
1107fn docker_cli(
1108 args: Vec<String>,
1109 envs: Option<std::collections::HashMap<&str, &str>>,
1110 path: Option<&std::path::Path>,
1111 error_msg: &str,
1112) -> anyhow::Result<()> {
1113 let arg_refs: Vec<&str> = args.iter().map(|s| s.as_str()).collect();
1114 run_process("docker", &arg_refs, envs, path, error_msg)
1115}
1116
1117fn docker_image_platform(reference: &str) -> anyhow::Result<String> {
1118 let mut cmd = std::process::Command::new("docker");
1119 cmd.arg("inspect")
1120 .arg("--format={{.Os}}/{{.Architecture}}")
1121 .arg(reference);
1122
1123 let out = run_process_capture_stdout(&mut cmd, "docker inspect image platform")?;
1124 Ok(out.trim().to_string())
1125}
1126
1127fn ensure_local_image_platform(
1128 image: &str,
1129 tag: &str,
1130 expected_platform: &str,
1131) -> anyhow::Result<()> {
1132 let reference = format!("{image}:{tag}");
1133 let actual = docker_image_platform(&reference)
1134 .with_context(|| format!("docker inspect for image '{reference}' should succeed"))?;
1135
1136 if actual != expected_platform {
1137 anyhow::bail!(
1138 "Local image '{reference}' platform should be '{expected}', found '{actual}'",
1139 expected = expected_platform,
1140 actual = actual,
1141 );
1142 }
1143
1144 Ok(())
1145}