apiforge 0.4.0

Production-grade API release automation CLI. From merged code to healthy pods in production — one command.
Documentation
use crate::error::{ApiForgeError, Result};
use crate::integrations::aws::AwsClient;
use crate::steps::{Step, StepContext, StepOutput};
use async_trait::async_trait;

/// Invalidates CloudFront cache paths after a successful deploy so clients
/// stop receiving stale responses cached in front of the API.
pub struct CloudFrontInvalidateStep;

impl Default for CloudFrontInvalidateStep {
    fn default() -> Self {
        Self::new()
    }
}

impl CloudFrontInvalidateStep {
    pub fn new() -> Self {
        Self
    }

    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_trait]
impl Step for CloudFrontInvalidateStep {
    fn name(&self) -> &str {
        "cloudfront-invalidate"
    }

    fn description(&self) -> &str {
        "Invalidate CloudFront cache paths"
    }

    async fn validate(&self, ctx: &StepContext) -> Result<()> {
        let cf =
            ctx.config.cloudfront.as_ref().ok_or_else(|| {
                ApiForgeError::Config("CloudFront configuration missing".to_string())
            })?;

        if cf.distribution_id.is_empty() {
            return Err(ApiForgeError::Config(
                "cloudfront.distribution_id cannot be empty".to_string(),
            ));
        }

        if ctx.dry_run {
            return Ok(());
        }

        // Verify credentials resolve before the pipeline mutates anything.
        let aws = Self::get_aws_client(ctx).await?;
        aws.get_caller_identity().await?;

        Ok(())
    }

    async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
        let cf =
            ctx.config.cloudfront.as_ref().ok_or_else(|| {
                ApiForgeError::Config("CloudFront configuration missing".to_string())
            })?;

        ctx.report_progress(&format!(
            "cloudfront-invalidate: requesting invalidation for {} path(s)",
            cf.paths.len()
        ));

        let aws = Self::get_aws_client(ctx).await?;
        let invalidation_id = aws
            .create_cloudfront_invalidation(&cf.distribution_id, &cf.paths)
            .await?;

        Ok(StepOutput::ok(format!(
            "Created invalidation {} on distribution {} ({} path(s))",
            invalidation_id,
            cf.distribution_id,
            cf.paths.len()
        )))
    }

    async fn dry_run(&self, ctx: &StepContext) -> Result<StepOutput> {
        match ctx.config.cloudfront.as_ref() {
            Some(cf) => Ok(StepOutput::ok(format!(
                "Would invalidate {} on distribution {}",
                cf.paths.join(", "),
                cf.distribution_id
            ))),
            None => Ok(StepOutput::skipped("No CloudFront configuration")),
        }
    }

    // No rollback: an invalidation only evicts cached content, which is
    // always safe — after a deployment rollback the next invalidation (or
    // natural TTL expiry) re-fills the cache with the rolled-back version.
}