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(),
}
}
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?;
match aws.ensure_repository_exists(repo_name).await {
Ok(repo_uri) => {
tracing::info!("ECR repository '{}' exists", repo_name);
Ok(repo_uri)
}
Err(e) => {
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?;
if matches!(ctx.config.docker.registry, DockerRegistry::AwsEcr) {
let aws = Self::get_aws_client(ctx).await?;
aws.get_caller_identity().await?;
}
Ok(())
}
async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
let docker = DockerClient::new().await?;
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
)))
}
}