apiforge 0.4.0

Production-grade API release automation CLI. From merged code to healthy pods in production — one command.
Documentation
use crate::config::DockerRegistry;
use crate::error::{AwsError, Result};
use crate::integrations::aws::AwsClient;
use crate::integrations::docker::{DockerClient, PushConfig};
use crate::integrations::git::GitRepo;
use crate::steps::{Step, StepContext, StepOutput};
use async_trait::async_trait;
use bollard::auth::DockerCredentials;
use semver::Version;
use std::sync::atomic::{AtomicBool, Ordering};

pub struct DockerPushStep {
    version: Version,
    ecr_repo_created: AtomicBool,
}

impl DockerPushStep {
    pub fn new(version: Version) -> Self {
        Self {
            version,
            ecr_repo_created: AtomicBool::new(false),
        }
    }

    fn get_image_tags(&self, ctx: &StepContext) -> Vec<String> {
        let version_str = self.version.to_string();
        let git_sha_full = GitRepo::open()
            .ok()
            .and_then(|repo| repo.current_commit_sha().ok())
            .unwrap_or_else(|| "unknown".to_string());
        let git_sha = git_sha_full.chars().take(7).collect::<String>();

        ctx.config
            .docker
            .tags
            .iter()
            .map(|t| {
                t.replace("{version}", &version_str)
                    .replace("{major}", &self.version.major.to_string())
                    .replace("{minor}", &self.version.minor.to_string())
                    .replace("{patch}", &self.version.patch.to_string())
                    .replace("{git_sha}", &git_sha)
                    .replace("{git_sha_full}", &git_sha_full)
            })
            .collect()
    }

    async fn get_aws_client(ctx: &StepContext) -> Result<AwsClient> {
        if let Some(ref profile) = ctx.config.aws.profile {
            AwsClient::with_profile(&ctx.config.aws.region, profile).await
        } else {
            AwsClient::new(&ctx.config.aws.region).await
        }
    }

    async fn get_registry_info(
        &self,
        ctx: &StepContext,
    ) -> Result<(String, Option<DockerCredentials>)> {
        let repo = &ctx.config.docker.repository;

        match ctx.config.docker.registry {
            DockerRegistry::AwsEcr => {
                let aws = Self::get_aws_client(ctx).await?;

                let (account_id, _) = aws.get_caller_identity().await?;
                let registry_url = aws.get_ecr_registry_url(&account_id);
                let credentials = aws.get_ecr_authorization().await?;

                Ok((format!("{}/{}", registry_url, repo), Some(credentials)))
            }
            DockerRegistry::DockerHub => Ok((repo.clone(), None)),
            DockerRegistry::Ghcr => Ok((format!("ghcr.io/{}", repo), None)),
            DockerRegistry::Custom => Ok((repo.clone(), None)),
        }
    }

    fn get_registry_info_dry_run(&self, ctx: &StepContext) -> String {
        let repo = &ctx.config.docker.repository;

        match ctx.config.docker.registry {
            DockerRegistry::AwsEcr => format!(
                "<aws-account-id>.dkr.ecr.{}.amazonaws.com/{}",
                ctx.config.aws.region, repo
            ),
            DockerRegistry::DockerHub => repo.clone(),
            DockerRegistry::Ghcr => format!("ghcr.io/{}", repo),
            DockerRegistry::Custom => repo.clone(),
        }
    }

    /// Ensure ECR repository exists, creating it if necessary
    async fn ensure_ecr_repository(&self, ctx: &StepContext) -> Result<String> {
        let repo_name = &ctx.config.docker.repository;
        let aws = Self::get_aws_client(ctx).await?;

        // Try to get existing repository
        match aws.ensure_repository_exists(repo_name).await {
            Ok(repo_uri) => {
                tracing::info!("ECR repository '{}' exists", repo_name);
                Ok(repo_uri)
            }
            Err(e) => {
                // Check if it's a "not found" error
                if let crate::error::ApiForgeError::Aws(AwsError::EcrRepoNotFound(_)) = e {
                    tracing::info!("ECR repository '{}' not found, creating it...", repo_name);
                    let repo_uri = aws.create_repository(repo_name).await?;
                    self.ecr_repo_created.store(true, Ordering::SeqCst);
                    tracing::info!("Created ECR repository: {}", repo_uri);
                    Ok(repo_uri)
                } else {
                    Err(e)
                }
            }
        }
    }
}

#[async_trait]
impl Step for DockerPushStep {
    fn name(&self) -> &str {
        "docker-push"
    }

    fn description(&self) -> &str {
        "Push Docker image to registry"
    }

    async fn validate(&self, ctx: &StepContext) -> Result<()> {
        if ctx.dry_run {
            return Ok(());
        }

        let _docker = DockerClient::new().await?;

        // For ECR, verify credentials and ensure repository exists
        if matches!(ctx.config.docker.registry, DockerRegistry::AwsEcr) {
            let aws = Self::get_aws_client(ctx).await?;

            // Verify credentials work
            aws.get_caller_identity().await?;

            // Note: We don't auto-create during validation, only during execute
            // This allows dry-run to check permissions without creating resources
        }

        Ok(())
    }

    async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
        let docker = DockerClient::new().await?;

        // For ECR, ensure repository exists before pushing
        if matches!(ctx.config.docker.registry, DockerRegistry::AwsEcr) {
            self.ensure_ecr_repository(ctx).await?;
        }

        let (full_image_name, credentials) = self.get_registry_info(ctx).await?;
        let tags = self.get_image_tags(ctx);

        let mut pushed_tags = Vec::new();

        let total_tags = tags.len();
        for (idx, tag) in tags.iter().enumerate() {
            let config = PushConfig {
                image: full_image_name.clone(),
                tag: tag.clone(),
                registry: None,
                credentials: credentials.clone(),
            };

            docker
                .push_image(&config, |msg| {
                    tracing::debug!("{}", msg);
                    ctx.report_progress(&format!(
                        "docker-push: tag {}/{} ({}): {}",
                        idx + 1,
                        total_tags,
                        tag,
                        msg
                    ));
                })
                .await?;

            pushed_tags.push(tag.clone());
        }

        Ok(StepOutput::ok(format!(
            "Pushed {} tags to {}",
            pushed_tags.len(),
            match ctx.config.docker.registry {
                DockerRegistry::AwsEcr => "AWS ECR",
                DockerRegistry::DockerHub => "Docker Hub",
                DockerRegistry::Ghcr => "GitHub Container Registry",
                DockerRegistry::Custom => "custom registry",
            }
        )))
    }

    async fn dry_run(&self, ctx: &StepContext) -> Result<StepOutput> {
        let full_image_name = self.get_registry_info_dry_run(ctx);
        let tags = self.get_image_tags(ctx);

        Ok(StepOutput::ok(format!(
            "Would push {} with tags {} to {:?}",
            full_image_name,
            tags.join(", "),
            ctx.config.docker.registry
        )))
    }
}