apiforge 0.4.0

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

pub struct K8sRolloutStep {
    timeout: Option<u64>,
}

impl K8sRolloutStep {
    pub fn new() -> Self {
        Self { timeout: None }
    }

    pub fn with_timeout(mut self, timeout: u64) -> Self {
        self.timeout = Some(timeout);
        self
    }
}

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

#[async_trait]
impl Step for K8sRolloutStep {
    fn name(&self) -> &str {
        "k8s-rollout"
    }

    fn description(&self) -> &str {
        "Wait for Kubernetes rollout to complete"
    }

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

        let k8s = K8sClient::new(&ctx.config.kubernetes.context).await?;

        // Verify deployment exists
        k8s.get_deployment(
            &ctx.config.kubernetes.namespace,
            &ctx.config.kubernetes.deployment,
        )
        .await?;

        Ok(())
    }

    async fn execute(&self, ctx: &StepContext) -> Result<StepOutput> {
        let k8s = K8sClient::new(&ctx.config.kubernetes.context).await?;
        let timeout = self
            .timeout
            .unwrap_or(ctx.config.kubernetes.rollout_timeout);

        let status = k8s
            .wait_for_rollout(
                &ctx.config.kubernetes.namespace,
                &ctx.config.kubernetes.deployment,
                timeout,
                |status| {
                    tracing::debug!(
                        "Rollout progress: {}/{} ready",
                        status.ready_replicas,
                        status.desired_replicas
                    );
                    ctx.report_progress(&format!(
                        "k8s-rollout: {}/{} replicas ready ({} updated, {} available)",
                        status.ready_replicas,
                        status.desired_replicas,
                        status.updated_replicas,
                        status.available_replicas
                    ));
                },
            )
            .await?;

        // Check minimum ready percent. A deployment scaled to 0 replicas has
        // nothing to roll out, so treat it as fully ready instead of
        // dividing by zero (NaN casts to 0 and would always fail the check).
        let ready_percent = if status.desired_replicas == 0 {
            100
        } else {
            (status.ready_replicas as f64 / status.desired_replicas as f64 * 100.0) as u8
        };

        if ready_percent < ctx.config.kubernetes.min_ready_percent {
            return Err(crate::error::K8sError::RolloutFailed(format!(
                "Only {}% of replicas ready, minimum is {}%",
                ready_percent, ctx.config.kubernetes.min_ready_percent
            ))
            .into());
        }

        Ok(StepOutput::ok(format!(
            "Rollout complete: {}/{} replicas ready",
            status.ready_replicas, status.desired_replicas
        )))
    }

    async fn dry_run(&self, ctx: &StepContext) -> Result<StepOutput> {
        let timeout = self
            .timeout
            .unwrap_or(ctx.config.kubernetes.rollout_timeout);

        Ok(StepOutput::ok(format!(
            "Would wait for rollout of {} with {}s timeout",
            ctx.config.kubernetes.deployment, timeout
        )))
    }

    async fn rollback(&self, _ctx: &StepContext) -> Result<()> {
        // Intentionally a no-op: this step only *waits* for a rollout, it does
        // not change cluster state. The deployment image change is owned by
        // K8sUpdateStep, whose rollback reverts to the previous revision.
        // If both steps rolled back, the orchestrator's reverse-order rollback
        // would call `rollback_deployment(previous)` twice, landing the
        // deployment two revisions back (N-2) instead of one.
        tracing::debug!("k8s-rollout rollback is a no-op; k8s-update owns the revision revert");
        Ok(())
    }
}