use std::collections::HashMap;
use std::time::Instant;
use crate::config::Config;
use crate::error::{ApiForgeError, Result};
use crate::output::OutputManager;
use crate::steps::{Step, StepContext, StepOutput, StepStatus};
use crate::utils::sanitize_message;
pub struct RunReport {
pub steps: Vec<(String, StepOutput)>,
pub error: Option<ApiForgeError>,
pub rolled_back: bool,
}
impl RunReport {
pub fn success(&self) -> bool {
self.error.is_none()
}
pub fn into_result(self) -> Result<Vec<StepOutput>> {
match self.error {
Some(e) => Err(e),
None => Ok(self.steps.into_iter().map(|(_, out)| out).collect()),
}
}
}
pub struct ReleaseOrchestrator {
steps: Vec<Box<dyn Step>>,
config: Config,
dry_run: bool,
auto_rollback: bool,
output: OutputManager,
}
impl ReleaseOrchestrator {
pub fn new(config: Config, dry_run: bool) -> Self {
Self {
steps: Vec::new(),
config,
dry_run,
auto_rollback: true, output: OutputManager::new(),
}
}
pub fn with_auto_rollback(mut self, enabled: bool) -> Self {
self.auto_rollback = enabled;
self
}
pub fn with_stderr_output(mut self) -> Self {
self.output = OutputManager::stderr();
self
}
pub fn add_step(&mut self, step: Box<dyn Step>) {
self.steps.push(step);
}
pub async fn preflight(&self, ctx: &StepContext) -> Result<()> {
self.output.section("Pre-flight checks");
for step in &self.steps {
self.output.step_status(step.name(), "validating...");
step.validate(ctx).await?;
self.output.step_ok(step.name());
}
self.output.blank_line();
Ok(())
}
async fn rollback_steps(&self, ctx: &StepContext, completed_indices: &[usize]) {
if completed_indices.is_empty() {
return;
}
self.output.blank_line();
self.output.section("Rolling back completed steps");
for &idx in completed_indices.iter().rev() {
let step = &self.steps[idx];
self.output.step_status(step.name(), "rolling back...");
match step.rollback(ctx).await {
Ok(()) => {
self.output
.step_ok(&format!("{} (rolled back)", step.name()));
}
Err(e) => {
let safe_error = sanitize_message(&e.to_string());
self.output
.step_fail(step.name(), &format!("rollback failed: {}", safe_error));
tracing::error!("Failed to rollback step '{}': {}", step.name(), safe_error);
}
}
}
}
pub async fn run(&self) -> RunReport {
let ctx = StepContext {
config: self.config.clone(),
dry_run: self.dry_run,
state: HashMap::new(),
progress: Some(self.output.progress_reporter()),
};
if let Err(e) = self.preflight(&ctx).await {
return RunReport {
steps: Vec::new(),
error: Some(e),
rolled_back: false,
};
}
let mode = if self.dry_run { "Dry-run" } else { "Executing" };
self.output.section(&format!("{} release pipeline", mode));
let mut outputs: Vec<(String, StepOutput)> = Vec::new();
let mut completed_indices: Vec<usize> = Vec::new();
for (idx, step) in self.steps.iter().enumerate() {
let step_start = Instant::now();
self.output.step_status(step.name(), "running...");
let result = if self.dry_run {
step.dry_run(&ctx).await
} else {
step.execute(&ctx).await
};
let elapsed = step_start.elapsed();
match result {
Ok(mut out) => {
out.duration_ms = elapsed.as_millis() as u64;
self.output.step_done(step.name(), &out);
outputs.push((step.name().to_string(), out));
completed_indices.push(idx);
}
Err(e) => {
let safe_error = sanitize_message(&e.to_string());
self.output.step_fail(step.name(), &safe_error);
outputs.push((
step.name().to_string(),
StepOutput {
status: StepStatus::Failed,
message: safe_error,
duration_ms: elapsed.as_millis() as u64,
dry_run_details: None,
},
));
let mut rolled_back = false;
if self.auto_rollback && !self.dry_run && !completed_indices.is_empty() {
self.output.blank_line();
self.output.warn(&format!(
"Step '{}' failed, initiating automatic rollback of {} completed step(s)...",
step.name(),
completed_indices.len()
));
self.rollback_steps(&ctx, &completed_indices).await;
rolled_back = true;
}
return RunReport {
steps: outputs,
error: Some(e),
rolled_back,
};
}
}
}
self.output.blank_line();
RunReport {
steps: outputs,
error: None,
rolled_back: false,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{
AwsConfig, Config, DockerConfig, DockerRegistry, GitConfig, KubernetesConfig, Language,
ProjectConfig,
};
use crate::error::ApiForgeError;
use async_trait::async_trait;
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
struct MockStep {
name: &'static str,
fail_on_execute: bool,
events: Arc<Mutex<Vec<String>>>,
}
impl MockStep {
fn new(name: &'static str, fail_on_execute: bool, events: Arc<Mutex<Vec<String>>>) -> Self {
Self {
name,
fail_on_execute,
events,
}
}
}
#[async_trait]
impl Step for MockStep {
fn name(&self) -> &str {
self.name
}
fn description(&self) -> &str {
"mock test step"
}
async fn validate(&self, _ctx: &StepContext) -> Result<()> {
Ok(())
}
async fn execute(&self, _ctx: &StepContext) -> Result<StepOutput> {
self.events
.lock()
.unwrap()
.push(format!("execute:{}", self.name));
if self.fail_on_execute {
return Err(ApiForgeError::StepFailed(format!(
"step {} failed",
self.name
)));
}
Ok(StepOutput::ok(format!("{} executed", self.name)))
}
async fn dry_run(&self, _ctx: &StepContext) -> Result<StepOutput> {
self.events
.lock()
.unwrap()
.push(format!("dry_run:{}", self.name));
Ok(StepOutput::ok(format!("{} dry-run", self.name)))
}
async fn rollback(&self, _ctx: &StepContext) -> Result<()> {
self.events
.lock()
.unwrap()
.push(format!("rollback:{}", self.name));
Ok(())
}
}
fn test_config() -> Config {
Config {
project: ProjectConfig {
name: "test-project".to_string(),
language: Language::Rust,
},
git: GitConfig {
main_branch: "main".to_string(),
tag_format: "v{version}".to_string(),
changelog: true,
commit_message: "release {{ version }}".to_string(),
remote: "origin".to_string(),
require_clean: false,
require_main_branch: false,
fetch_timeout_secs: 60,
push_timeout_secs: 120,
operation_timeout_secs: 30,
},
docker: DockerConfig {
registry: DockerRegistry::AwsEcr,
repository: "test-repo".to_string(),
dockerfile: "Dockerfile".to_string(),
context: ".".to_string(),
tags: vec!["{version}".to_string(), "latest".to_string()],
build_args: Some(HashMap::new()),
},
kubernetes: KubernetesConfig {
context: "test".to_string(),
namespace: "default".to_string(),
deployment: "test-project".to_string(),
manifest_path: "k8s/deployment.yaml".to_string(),
image_field: ".spec.template.spec.containers[0].image".to_string(),
rollout_timeout: 300,
min_ready_percent: 100,
},
aws: AwsConfig {
region: "us-east-1".to_string(),
profile: None,
},
cloudfront: None,
github: None,
notifications: None,
health_check: None,
}
}
#[tokio::test]
async fn test_run_executes_all_steps_successfully() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut orchestrator = ReleaseOrchestrator::new(test_config(), false);
orchestrator.add_step(Box::new(MockStep::new("step-a", false, events.clone())));
orchestrator.add_step(Box::new(MockStep::new("step-b", false, events.clone())));
let report = orchestrator.run().await;
assert!(report.success());
assert!(!report.rolled_back);
let outputs = report.into_result().unwrap();
assert_eq!(outputs.len(), 2);
assert!(outputs
.iter()
.all(|output| output.status == crate::steps::StepStatus::Success));
assert_eq!(
events.lock().unwrap().clone(),
vec!["execute:step-a", "execute:step-b"]
);
}
#[tokio::test]
async fn test_run_rolls_back_completed_steps_in_reverse_order() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut orchestrator = ReleaseOrchestrator::new(test_config(), false);
orchestrator.add_step(Box::new(MockStep::new("step-a", false, events.clone())));
orchestrator.add_step(Box::new(MockStep::new("step-b", false, events.clone())));
orchestrator.add_step(Box::new(MockStep::new("step-c", true, events.clone())));
let report = orchestrator.run().await;
assert!(report.error.is_some());
assert!(report.rolled_back);
assert_eq!(report.steps.len(), 3);
assert_eq!(report.steps[2].0, "step-c");
assert_eq!(report.steps[2].1.status, crate::steps::StepStatus::Failed);
assert_eq!(
events.lock().unwrap().clone(),
vec![
"execute:step-a",
"execute:step-b",
"execute:step-c",
"rollback:step-b",
"rollback:step-a"
]
);
}
#[tokio::test]
async fn test_run_does_not_rollback_when_disabled() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut orchestrator =
ReleaseOrchestrator::new(test_config(), false).with_auto_rollback(false);
orchestrator.add_step(Box::new(MockStep::new("step-a", false, events.clone())));
orchestrator.add_step(Box::new(MockStep::new("step-b", true, events.clone())));
let report = orchestrator.run().await;
assert!(report.error.is_some());
assert!(!report.rolled_back);
assert_eq!(
events.lock().unwrap().clone(),
vec!["execute:step-a", "execute:step-b"]
);
}
#[tokio::test]
async fn test_dry_run_uses_dry_run_paths() {
let events = Arc::new(Mutex::new(Vec::new()));
let mut orchestrator = ReleaseOrchestrator::new(test_config(), true);
orchestrator.add_step(Box::new(MockStep::new("step-a", false, events.clone())));
orchestrator.add_step(Box::new(MockStep::new("step-b", false, events.clone())));
let report = orchestrator.run().await;
assert!(report.success());
let outputs = report.into_result().unwrap();
assert_eq!(outputs.len(), 2);
assert_eq!(
events.lock().unwrap().clone(),
vec!["dry_run:step-a", "dry_run:step-b"]
);
}
}