Skip to main content

tracel_xtask/commands/
container.rs

1use serde::Deserialize;
2use std::io::Write as _;
3/// Manage containers.
4/// Current implementation uses `docker` and `AWS ECR` as container registry.
5use 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    /// Path to build file relative to context directory (i.e. a Dockerfile)
34    pub build_file: PathBuf,
35    /// Build context directory (default to repository root)
36    #[arg(long)]
37    pub context_dir: Option<PathBuf>,
38    /// Local image name
39    #[arg(long)]
40    pub image: String,
41    /// Local tag (defaults to "latest" if omitted)
42    #[arg(long)]
43    pub build_tag: Option<String>,
44    /// Build arguments
45    #[arg(long)]
46    pub build_args: Vec<String>,
47    /// When set, always build the container even if the build tag already exists in ECR.
48    #[arg(long)]
49    pub force: bool,
50    /// Region where the container repository lives
51    #[arg(long)]
52    pub region: String,
53}
54
55#[derive(clap::Args, Default, Clone, PartialEq)]
56pub struct ContainerHostSubCmdArgs {
57    /// Region of the Auto Scaling Group / container host
58    #[arg(long)]
59    pub region: String,
60
61    /// Name of the Auto Scaling Group hosting the containers
62    #[arg(long, value_name = "ASG_NAME")]
63    pub asg: String,
64
65    /// Login user for the SSM interactive shell
66    #[arg(long, default_value = "ubuntu")]
67    pub user: String,
68
69    /// Show instance system log instead of opening an SSM shell
70    #[arg(long)]
71    pub system_log: bool,
72}
73
74#[derive(clap::Args, Default, Clone, PartialEq)]
75pub struct ContainerListSubCmdArgs {
76    /// Region where the container repository lives
77    #[arg(long)]
78    pub region: String,
79    /// Container repository name
80    #[arg(long)]
81    pub repository: String,
82    /// The tag reprensenting the latest tag (defaults to the environment name if omitted)
83    #[arg(long)]
84    pub latest_tag: Option<String>,
85    /// Rollback tag applied by this command (defaults to 'rollback_<environment>' if omitted)
86    #[arg(long)]
87    pub rollback_tag: Option<String>,
88}
89
90#[derive(clap::Args, Default, Clone, PartialEq)]
91pub struct ContainerLogsSubCmdArgs {
92    /// AWS region to read logs from
93    #[arg(long)]
94    pub region: String,
95    /// CloudWatch Logs log group name
96    #[arg(long, value_name = "LOG_GROUP")]
97    pub log_group: String,
98    /// Follow stream logs (like 'tail -f')
99    #[arg(long, default_value_t = false)]
100    pub follow: bool,
101    /// Only show logs newer than this duration (AWS CLI syntax like: 10m, 2h, 1d)
102    #[arg(long, default_value = "10m")]
103    pub since: String,
104    /// Optional specific log stream names. Repeatable.
105    #[arg(long, value_name = "LOG_STREAM", action = clap::ArgAction::Append)]
106    pub log_stream_name: Vec<String>,
107    /// If set, pick an instance from the specified ASG and tail a stream named after the instance id.
108    #[arg(long, value_name = "ASG_NAME")]
109    pub asg: Option<String>,
110}
111
112#[derive(clap::Args, Default, Clone, PartialEq)]
113pub struct ContainerPullSubCmdArgs {
114    /// Region where the container repository lives
115    #[arg(long)]
116    pub region: String,
117    /// Container repository name
118    #[arg(long)]
119    pub repository: String,
120    /// Image tag to pull
121    #[arg(long)]
122    pub tag: String,
123    /// Platform to pull (e.g. linux/amd64), if omitted then docker's default platform is used
124    #[arg(long)]
125    pub platform: Option<String>,
126}
127
128#[derive(clap::Args, Default, Clone, PartialEq)]
129pub struct ContainerPushSubCmdArgs {
130    /// Local image name (the one used in the build command)
131    #[arg(long)]
132    pub image: String,
133    /// Local image tag (the one used when building), usually it is the commit SHA
134    #[arg(long)]
135    pub local_tag: String,
136    /// Region where the container repository lives
137    #[arg(long)]
138    pub region: String,
139    /// Container repository name to push into
140    #[arg(long)]
141    pub repository: String,
142    /// Additional explicit remote tag to add (pushed alongside the commit SHA)
143    #[arg(long)]
144    pub additional_tag: Option<String>,
145    /// When set, also add the next monotonic tag alongside the commit SHA
146    #[arg(long)]
147    pub auto_remote_tag: bool,
148    /// Required container platform (e.g. linux/amd64). If set, the local image must match.
149    #[arg(long)]
150    pub platform: Option<ContainerPlatform>,
151}
152
153#[derive(clap::Args, Default, Clone, PartialEq)]
154pub struct ContainerPromoteSubCmdArgs {
155    /// Region where the container repository lives
156    #[arg(long)]
157    pub region: String,
158    /// Container repository name
159    #[arg(long)]
160    pub repository: String,
161    /// Build tag to promote for the given environment
162    #[arg(long)]
163    pub build_tag: String,
164    /// Promote tag applied by this command (defaults to the environment name if omitted)
165    #[arg(long)]
166    pub promote_tag: Option<String>,
167    /// Rollback tag applied by this command (defaults to 'rollback_<environment>' if omitted)
168    #[arg(long)]
169    pub rollback_tag: Option<String>,
170}
171
172#[derive(clap::Args, Default, Clone, PartialEq)]
173pub struct ContainerRollbackSubCmdArgs {
174    /// Region where the container repository lives
175    #[arg(long)]
176    pub region: String,
177    /// Container repository name
178    #[arg(long)]
179    pub repository: String,
180    /// Promote tag applied by this command (defaults to the environment name if omitted)
181    #[arg(long)]
182    pub promote_tag: Option<String>,
183    /// Rollback tag to promote to promote tag (defaults to 'rollback_<environment>' if omitted)
184    #[arg(long)]
185    pub rollback_tag: Option<String>,
186}
187
188#[derive(clap::Args, Clone, PartialEq, Debug)]
189pub struct ContainerRolloutSubCmdArgs {
190    /// Region of the Auto Scaling Group
191    #[arg(long)]
192    pub region: String,
193
194    /// Name of the Auto Scaling Group to refresh
195    #[arg(long, value_name = "ASG_NAME")]
196    pub asg: String,
197
198    /// Strategy for instance refresh (Rolling is the standard choice for zero-downtime rollouts)
199    #[arg(long, value_name = "Rolling", default_value_t = ContainerRolloutSubCmdArgs::default().strategy)]
200    pub strategy: String,
201
202    /// Seconds for instance warmup
203    #[arg(long, value_name = "SECS", default_value_t = ContainerRolloutSubCmdArgs::default().instance_warmup)]
204    pub instance_warmup: u64,
205
206    /// Maximum healthy percentage during the rollout
207    #[arg(long, value_name = "PCT", default_value_t = ContainerRolloutSubCmdArgs::default().max_healthy_percentage)]
208    pub max_healthy_percentage: u8,
209
210    /// Minimum healthy percentage during the rollout
211    #[arg(long, value_name = "PCT", default_value_t = ContainerRolloutSubCmdArgs::default().min_healthy_percentage)]
212    pub min_healthy_percentage: u8,
213
214    /// Container promote tag, defaults to 'latest'.
215    #[arg(long)]
216    pub promote_tag: Option<String>,
217
218    /// Container repository.
219    #[arg(long)]
220    pub repository: Option<String>,
221
222    /// If set, skip replacing instances that already match the launch template/config
223    #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().skip_matching)]
224    pub skip_matching: bool,
225
226    /// Wait until the refresh completes
227    #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().wait)]
228    pub wait: bool,
229
230    /// Max seconds to wait when --wait is set
231    #[arg(long, default_value_t = ContainerRolloutSubCmdArgs::default().wait_timeout_secs)]
232    pub wait_timeout_secs: u64,
233
234    /// Poll interval seconds when --wait is set
235    #[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    /// Fully qualified image reference (e.g. 123.dkr.ecr.us-east-1.amazonaws.com/bc-backend:latest)
242    #[arg(long)]
243    pub image: String,
244
245    /// Container name
246    #[arg(long)]
247    pub name: Option<String>,
248
249    /// Optional env-file to pass to docker
250    #[arg(long)]
251    pub env_file: Option<std::path::PathBuf>,
252
253    /// When set, use `--network host`
254    #[arg(long)]
255    pub host_network: bool,
256
257    /// Extra docker run args, passed as-is after the standard flags
258    #[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
296/// Wrapper used only for display purposes.
297pub 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
320/// Extract the first 8 hex chars of the sha256 digest from an OCI manifest JSON.
321fn 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 the image tag already exists in ECR, skip docker build unless forced.
374    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 is positional
398        context_dir.to_string_lossy().into(),
399    ];
400    for kv in build_args.build_args {
401        // before context dir
402        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    // current latest
478    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    // current rollback
487    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    // latest non-alias tag (so not latest or rollback tagged)
496    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        // no need to show the instance ID
529        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    // Build docker args
569    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    // pull image
583    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    // check for repository existenz
597    ecr_ensure_repo_exists(&push_args.repository, &push_args.region)?;
598    // check for correct container platform
599    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    // check if the container has already been pushed
607    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 an explicit extra tag is requested, alias it to the same manifest without re-pushing.
618        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        // login
634        let account_id = aws_account_id()?;
635        ecr_docker_login(&account_id, &push_args.region)?;
636
637        // push image with primary tag (commit sha)
638        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        // Collect any additional tags we should add in addition to the commit sha
663        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        // Push additional tags
675        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
717/// promote: point N to `latest` and move the previous `latest` to `rollback`
718fn 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    // Fetch current 'latest' manifest and the new manifest to promote.
726    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 'latest' tag is already the target manifest then do nothing
753    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 there was a previous 'latest', move it to 'rollback'.
764    if let Some(current_manifest) = current_latest_manifest {
765        let rollback_tag = rollback_tag(promote_args.rollback_tag, env);
766        // this should never happen, report a warning in case the rollback tag is already
767        // applied to the current manifest and then do nothing
768        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(&current_manifest),
780            );
781        } else {
782            // Update rollback manifest
783            ecr_put_manifest(
784                &promote_args.repository,
785                &promote_args.region,
786                &rollback_tag,
787                &current_manifest,
788            )
789            .context(format!(
790                "'{rollback_tag}' should be updated to the previous '{promote_tag}'"
791            ))?;
792        }
793    }
794
795    // At last, update 'latest' to the new manifest.
796    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    // Report
807    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
827/// rollback: promote current 'rollback_tag' container to 'promote_tag' and then remove 'rollback_tag'
828fn rollback(rollback_args: ContainerRollbackSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
829    let rollback_tag = rollback_tag(rollback_args.rollback_tag, env);
830    // Fetch the manifest of the 'rollback' tag
831    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    // If currently promoted container is different of the rollback one,
843    // then put the promoted tag on the rollback manifest
844    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(&current_rb_manifest)
852    {
853        ecr_put_manifest(
854            &rollback_args.repository,
855            &rollback_args.region,
856            &promote_tag,
857            &current_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(&current_rb_manifest),
865        );
866    } else {
867        eprintln!(
868            "โ„น๏ธ '{promote_tag}' already points to the '{rollback_tag}' manifest, skipping promotion..."
869        );
870    }
871
872    // Remove the 'rollback' tag so it no longer aliases this image.
873    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            // This would be unusual since all the containers should be tagged with the commit sha
898            eprintln!(
899                "โš ๏ธ '{promote_tag}' updated, but could not resolve the underlying commit SHA."
900            );
901        }
902    }
903
904    Ok(())
905}
906
907/// rollout: rollout latest promoted container for current environment
908fn rollout(rollout_args: ContainerRolloutSubCmdArgs, env: &Environment) -> anyhow::Result<()> {
909    // Build preferences JSON strictly from flags
910    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    // Kick off the refresh
919    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    // show the concrete commit SHA tag of the container being rolled out
934    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        // Track whether we already triggered a container rollback
962        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            // elapsed time in mm:ss (within current window)
985            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                    // FIRST TIMEOUT โ†’ trigger container rollback and restart the timer
1022                    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                        // restart the timer for a second window
1043                        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                    // SECOND TIMEOUT โ†’ hard error out
1056                    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    // Extra args come before the image
1093    cli_args.extend(args.extra_arg.clone());
1094
1095    // Finally, the image (repo:tag, commit tag, whatever)
1096    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}