use apiforge::cli::{Cli, Commands};
use apiforge::config::Config;
use clap::Parser;
use std::path::PathBuf;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let cli = Cli::parse();
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::try_from_default_env().unwrap_or_else(|_| {
if cli.debug {
"apiforge=debug".into()
} else {
"apiforge=info".into()
}
}),
)
.without_time()
.init();
match cli.command {
Commands::Init(args) => cmd_init(args).await,
Commands::Doctor => cmd_doctor(&cli.config).await,
Commands::Release(args) => cmd_release(&cli.config, args).await,
Commands::Rollback(args) => cmd_rollback(&cli.config, args).await,
Commands::History(args) => cmd_history(args).await,
Commands::Status => cmd_status(&cli.config).await,
Commands::Config(args) => cmd_config(&cli.config, args).await,
}
}
async fn cmd_init(args: apiforge::cli::InitArgs) -> anyhow::Result<()> {
let name = args.name.unwrap_or_else(|| {
std::env::current_dir()
.ok()
.and_then(|p| p.file_name().map(|n| n.to_string_lossy().to_string()))
.unwrap_or_else(|| "my-project".to_string())
});
let config_path = PathBuf::from("apiforge.toml");
if config_path.exists() && !args.force {
anyhow::bail!("apiforge.toml already exists. Use --force to overwrite.");
}
let default_config = format!(
r#"[project]
name = "{name}"
language = "rust"
[git]
main_branch = "main"
tag_format = "v{{version}}"
changelog = true
commit_message = "chore: release v{{{{ version }}}}"
remote = "origin"
require_clean = true
require_main_branch = true
fetch_timeout_secs = 60
push_timeout_secs = 120
operation_timeout_secs = 30
[docker]
registry = "aws_ecr"
repository = "{name}"
dockerfile = "Dockerfile"
context = "."
tags = ["{{version}}", "latest"]
[kubernetes]
context = "production"
namespace = "default"
deployment = "{name}"
manifest_path = "k8s/deployment.yaml"
image_field = ".spec.template.spec.containers[0].image"
rollout_timeout = 300
min_ready_percent = 100
[aws]
region = "us-east-1"
# Optional: GitHub release configuration
# [github]
# repository = "owner/repo"
# token = "${{GITHUB_TOKEN}}"
# create_release = true
# prerelease = false
# draft = false
# Optional: Notifications
# [notifications.slack]
# webhook_url = "${{SLACK_WEBHOOK_URL}}"
# message = "{{{{ status_emoji }}}} Release {{{{ version }}}} of {{{{ project }}}}: {{{{ status }}}}"
# notify_on = "both"
# Optional: Health check
# [health_check]
# url = "https://api.example.com/health"
# expected_status = 200
# timeout = 60
# interval = 5
"#,
name = name
);
std::fs::write(&config_path, default_config)?;
let gitignore_path = PathBuf::from(".gitignore");
let gitignore = std::fs::read_to_string(&gitignore_path).unwrap_or_default();
if !gitignore.lines().any(|l| l.trim() == ".apiforge/") {
let mut updated = gitignore;
if !updated.is_empty() && !updated.ends_with('\n') {
updated.push('\n');
}
updated.push_str(".apiforge/\n");
std::fs::write(&gitignore_path, updated)?;
println!(" Added .apiforge/ to .gitignore");
}
println!("✓ Initialized apiforge for '{}'", name);
println!(" Created apiforge.toml — edit it to match your project setup.");
println!("\nNext steps:");
println!(" 1. Edit apiforge.toml with your project settings");
println!(" 2. Run 'apiforge doctor' to validate your environment");
println!(" 3. Run 'apiforge release patch --dry-run' to preview a release");
Ok(())
}
async fn cmd_doctor(config_path: &str) -> anyhow::Result<()> {
use colored::Colorize;
println!("\n{}", "▸ Environment checks".bold().cyan());
type ToolCheck = (&'static str, fn() -> bool, &'static str);
let checks: Vec<ToolCheck> = vec![
("git", || which::which("git").is_ok(), "Version control"),
(
"docker",
|| which::which("docker").is_ok(),
"Container builds",
),
(
"kubectl",
|| which::which("kubectl").is_ok(),
"Kubernetes deployment",
),
("aws", || which::which("aws").is_ok(), "AWS CLI (ECR auth)"),
];
let mut all_ok = true;
for (name, check, purpose) in &checks {
let (status, color) = if check() {
("OK", "green")
} else {
all_ok = false;
("MISSING", "yellow")
};
let status_colored = match color {
"green" => status.green(),
"yellow" => status.yellow(),
_ => status.normal(),
};
println!(
" {} {} ... {} ({})",
"•".dimmed(),
name.bold(),
status_colored,
purpose.dimmed()
);
}
println!("\n{}", "▸ Configuration".bold().cyan());
let path = PathBuf::from(config_path);
if path.exists() {
match Config::from_file(&path) {
Ok(config) => {
println!(" {} config ... {}", "•".dimmed(), "OK".green());
println!(" Project: {}", config.project.name);
println!(" Language: {:?}", config.project.language);
println!(" Registry: {:?}", config.docker.registry);
}
Err(e) => {
all_ok = false;
println!(" {} config ... {} ({})", "•".dimmed(), "INVALID".red(), e);
}
}
} else {
all_ok = false;
println!(
" {} config ... {} (run `apiforge init`)",
"•".dimmed(),
"NOT FOUND".yellow()
);
}
println!("\n{}", "▸ Git repository".bold().cyan());
match apiforge::integrations::git::GitRepo::open() {
Ok(repo) => {
println!(" {} repository ... {}", "•".dimmed(), "OK".green());
if let Ok(branch) = repo.current_branch() {
println!(" Branch: {}", branch);
}
if let Ok(Some(tag)) = repo.get_latest_tag("v*") {
println!(" Latest tag: {}", tag);
}
if let Ok(clean) = repo.is_working_tree_clean() {
let status = if clean {
"clean".green()
} else {
"dirty".yellow()
};
println!(" Working tree: {}", status);
}
}
Err(_) => {
all_ok = false;
println!(" {} repository ... {}", "•".dimmed(), "NOT FOUND".red());
}
}
println!();
if all_ok {
println!("{}", " ✓ All checks passed!".green().bold());
} else {
println!(
"{}",
" ⚠ Some checks failed. Fix the issues above before releasing.".yellow()
);
}
Ok(())
}
async fn cmd_release(config_path: &str, args: apiforge::cli::ReleaseArgs) -> anyhow::Result<()> {
use colored::Colorize;
use dialoguer::Confirm;
let path = PathBuf::from(config_path);
let mut config = Config::from_file(&path)?;
if !args.dry_run {
config.resolve_ssm_parameters().await?;
}
let config = config;
let bump_type = args.bump.parse::<apiforge::utils::BumpType>()?;
let repo = apiforge::integrations::git::GitRepo::open()?;
let version_file = config.project.language.version_file();
let version_path = repo.root_path().join(version_file);
let current_version = apiforge::utils::read_version(config.project.language, &version_path)?;
let new_version = apiforge::utils::bump_version(¤t_version, bump_type)?;
let new_version_str = new_version.to_string();
let previous_tag = repo.get_latest_tag(&config.git.tag_format.replace("{version}", "*"))?;
let json_mode = args.output == "json";
use std::fmt::Write as _;
let mut plan = String::new();
let _ = writeln!(plan, "\n{}", "▸ Release Plan".bold().cyan());
let _ = writeln!(plan, " Project: {}", config.project.name.bold());
let _ = writeln!(
plan,
" Version: {} → {}",
current_version.dimmed(),
new_version_str.green().bold()
);
let _ = writeln!(plan, " Bump type: {}", args.bump);
if let Some(ref tag) = previous_tag {
let _ = writeln!(plan, " Previous: {}", tag.dimmed());
}
let _ = writeln!(plan);
let _ = writeln!(plan, "{}", " Steps to execute:".dimmed());
let _ = writeln!(plan, " 1. Validate git repository state");
let _ = writeln!(plan, " 2. Bump version in {}", version_file);
if config.git.changelog && !args.no_changelog {
let _ = writeln!(plan, " 3. Generate changelog");
}
let _ = writeln!(plan, " 4. Commit and tag");
let _ = writeln!(plan, " 5. Push to remote");
if !args.skip_docker {
let _ = writeln!(plan, " 6. Build Docker image");
let _ = writeln!(plan, " 7. Push to {:?}", config.docker.registry);
}
if !args.skip_k8s {
let _ = writeln!(plan, " 8. Update Kubernetes deployment");
let _ = writeln!(plan, " 9. Wait for rollout");
}
if !args.skip_cloudfront && config.cloudfront.is_some() {
let _ = writeln!(plan, " 10. Invalidate CloudFront cache");
}
let github_release_enabled = config
.github
.as_ref()
.map(|g| g.create_release)
.unwrap_or(false);
if !args.skip_github && github_release_enabled {
let _ = writeln!(plan, " 11. Create GitHub release");
}
if config.health_check.is_some() {
let _ = writeln!(plan, " 12. Verify service health");
}
if json_mode {
eprintln!("{}", plan);
} else {
println!("{}", plan);
}
if !args.dry_run && !args.yes {
let confirmed = Confirm::new()
.with_prompt("Proceed with release?")
.default(false)
.interact()?;
if !confirmed {
println!("Release cancelled.");
return Ok(());
}
}
let mut orchestrator =
apiforge::orchestrator::ReleaseOrchestrator::new(config.clone(), args.dry_run);
if json_mode {
orchestrator = orchestrator.with_stderr_output();
}
orchestrator.add_step(Box::new(apiforge::steps::git::GitPreflightStep::new()));
orchestrator.add_step(Box::new(apiforge::steps::git::VersionBumpStep::new(
bump_type,
)));
let changelog_enabled = config.git.changelog && !args.no_changelog;
if changelog_enabled {
orchestrator.add_step(Box::new(apiforge::steps::git::ChangelogStep::new(
new_version_str.clone(),
previous_tag.clone(),
)));
}
orchestrator.add_step(Box::new(
apiforge::steps::git::GitCommitStep::new(new_version_str.clone())
.with_changelog(changelog_enabled),
));
orchestrator.add_step(Box::new(apiforge::steps::git::GitTagStep::new(
new_version.clone(),
)));
orchestrator.add_step(Box::new(apiforge::steps::git::GitPushStep::new(
new_version.clone(),
)));
if !args.skip_docker {
orchestrator.add_step(Box::new(apiforge::steps::docker::DockerBuildStep::new(
new_version.clone(),
)));
orchestrator.add_step(Box::new(apiforge::steps::docker::DockerPushStep::new(
new_version.clone(),
)));
}
if !args.skip_k8s {
orchestrator.add_step(Box::new(apiforge::steps::kubernetes::K8sUpdateStep::new(
new_version.clone(),
)));
orchestrator.add_step(Box::new(apiforge::steps::kubernetes::K8sRolloutStep::new()));
}
if !args.skip_cloudfront && config.cloudfront.is_some() {
orchestrator.add_step(Box::new(
apiforge::steps::cloudfront::CloudFrontInvalidateStep::new(),
));
}
if !args.skip_github && github_release_enabled {
orchestrator.add_step(Box::new(
apiforge::steps::github::GitHubReleaseStep::new(new_version.clone())
.with_previous_tag(previous_tag.clone()),
));
}
if config.health_check.is_some() {
orchestrator.add_step(Box::new(apiforge::steps::health::HealthCheckStep::new(
new_version.clone(),
)));
}
let pipeline_start = std::time::Instant::now();
let report = orchestrator.run().await;
let success = report.error.is_none();
let error_message = report
.error
.as_ref()
.map(|e| apiforge::utils::sanitize_message(&e.to_string()));
if !args.dry_run && !args.skip_notify && config.notifications.is_some() {
send_notifications(&config, &new_version, success, error_message.clone()).await;
}
if !args.dry_run {
record_audit(
&new_version_str,
&bump_type.to_string(),
&report,
pipeline_start.elapsed(),
error_message.clone(),
);
}
if args.output == "json" {
let result = serde_json::json!({
"success": success,
"version": new_version_str,
"bump_type": bump_type.to_string(),
"dry_run": args.dry_run,
"rolled_back": report.rolled_back,
"error": error_message,
"steps": report.steps.iter().map(|(name, o)| serde_json::json!({
"name": name,
"status": match o.status {
apiforge::steps::StepStatus::Success => "success",
apiforge::steps::StepStatus::Skipped => "skipped",
apiforge::steps::StepStatus::Failed => "failed",
},
"message": o.message,
"duration_ms": o.duration_ms
})).collect::<Vec<_>>()
});
println!("{}", serde_json::to_string_pretty(&result)?);
} else if success {
println!(
"\n{}",
format!("✨ Release {} complete!", new_version)
.green()
.bold()
);
println!(" {} steps executed successfully", report.steps.len());
}
match report.error {
Some(e) => Err(e.into()),
None => Ok(()),
}
}
async fn send_notifications(
config: &Config,
version: &semver::Version,
success: bool,
error_message: Option<String>,
) {
use apiforge::steps::notify::{SlackNotifyStep, WebhookNotifyStep};
use apiforge::steps::{Step, StepContext};
let notifications = match config.notifications {
Some(ref n) => n,
None => return,
};
let ctx = StepContext {
config: config.clone(),
dry_run: false,
state: std::collections::HashMap::new(),
progress: None,
};
if notifications.slack.is_some() {
let mut step = SlackNotifyStep::new(version.clone(), success);
if let Some(ref err) = error_message {
step = step.with_error(err.clone());
}
if let Err(e) = step.execute(&ctx).await {
tracing::warn!("Failed to send Slack notification: {}", e);
}
}
if notifications.webhook.is_some() {
let mut step = WebhookNotifyStep::new(version.clone(), success);
if let Some(ref err) = error_message {
step = step.with_error(err.clone());
}
if let Err(e) = step.execute(&ctx).await {
tracing::warn!("Failed to send webhook notification: {}", e);
}
}
}
fn record_audit(
version: &str,
bump_type: &str,
report: &apiforge::orchestrator::RunReport,
duration: std::time::Duration,
error_message: Option<String>,
) {
use apiforge::audit::{AuditStore, ReleaseStatus, StepRecord, StepStatus as AuditStepStatus};
let audit_dir = std::path::Path::new(".apiforge/audit");
match AuditStore::open(audit_dir) {
Ok(store) => {
let mut record = AuditStore::new_record(version, bump_type, false);
record.status = if report.error.is_none() {
ReleaseStatus::Success
} else if report.rolled_back {
ReleaseStatus::RolledBack
} else {
ReleaseStatus::Failed
};
record.duration_ms = duration.as_millis() as u64;
record.steps = report
.steps
.iter()
.map(|(name, o)| StepRecord {
name: name.clone(),
status: match o.status {
apiforge::steps::StepStatus::Success => AuditStepStatus::Success,
apiforge::steps::StepStatus::Skipped => AuditStepStatus::Skipped,
apiforge::steps::StepStatus::Failed => AuditStepStatus::Failed,
},
duration_ms: o.duration_ms,
message: Some(o.message.clone()),
})
.collect();
if let Some(err) = error_message {
record.metadata.insert("error".to_string(), err);
}
if let Err(e) = store.record(&record) {
tracing::warn!("Failed to write audit record: {}", e);
}
if let Err(e) = store.flush() {
tracing::warn!("Failed to flush audit database: {}", e);
}
}
Err(_) => tracing::warn!("Failed to open audit database at {:?}", audit_dir),
}
}
async fn cmd_rollback(config_path: &str, args: apiforge::cli::RollbackArgs) -> anyhow::Result<()> {
use colored::Colorize;
use dialoguer::Confirm;
let path = PathBuf::from(config_path);
let mut config = Config::from_file(&path)?;
if !args.dry_run {
config.resolve_ssm_parameters().await?;
}
let config = config;
let (target_version, current_version, source) = if let Some(ref to_version) = args.to {
(to_version.clone(), None, "--to flag")
} else {
detect_rollback_target(&config, args.dry_run).await?
};
println!("\n{}", "▸ Rollback Plan".bold().cyan());
if let Some(ref current) = current_version {
println!(" Currently deployed: {}", current);
}
println!(
" Target version: {} {}",
target_version.bold(),
format!("(from {})", source).dimmed()
);
if config.health_check.is_some() {
println!(" Post-rollback: health check verification");
}
if args.dry_run {
println!("\n{}", "[dry-run] Would perform the following:".yellow());
println!(" 1. Update Kubernetes deployment to {}", target_version);
println!(" 2. Wait for rollout");
if config.health_check.is_some() {
println!(" 3. Verify health check");
}
return Ok(());
}
if !args.yes {
let confirmed = Confirm::new()
.with_prompt(format!("Roll back to {}?", target_version))
.default(false)
.interact()?;
if !confirmed {
println!("Rollback cancelled.");
return Ok(());
}
}
let out = apiforge::output::OutputManager::new();
out.section("Executing rollback");
let k8s =
apiforge::integrations::kubernetes::K8sClient::new(&config.kubernetes.context).await?;
let image_base = match config.docker.registry {
apiforge::config::DockerRegistry::AwsEcr => {
let aws = if let Some(ref profile) = config.aws.profile {
apiforge::integrations::aws::AwsClient::with_profile(&config.aws.region, profile)
.await?
} else {
apiforge::integrations::aws::AwsClient::new(&config.aws.region).await?
};
let (account_id, _) = aws.get_caller_identity().await?;
let registry_url = aws.get_ecr_registry_url(&account_id);
format!("{}/{}", registry_url, config.docker.repository)
}
apiforge::config::DockerRegistry::Ghcr => {
format!("ghcr.io/{}", config.docker.repository)
}
_ => config.docker.repository.clone(),
};
let target_image = format!("{}:{}", image_base, target_version.trim_start_matches('v'));
out.step_status("k8s-update", &format!("setting image {}...", target_image));
k8s.update_deployment_image(
&config.kubernetes.namespace,
&config.kubernetes.deployment,
&config.kubernetes.image_field,
&target_image,
)
.await?;
out.step_ok("k8s-update");
out.step_status("k8s-rollout", "waiting for rollout...");
let reporter = out.progress_reporter();
k8s.wait_for_rollout(
&config.kubernetes.namespace,
&config.kubernetes.deployment,
config.kubernetes.rollout_timeout,
|status| {
reporter.set_message(&format!(
"k8s-rollout: {}/{} replicas ready",
status.ready_replicas, status.desired_replicas
));
},
)
.await?;
out.step_ok("k8s-rollout");
if config.health_check.is_some() {
let target_semver =
apiforge::utils::parse_version(&target_version).map_err(anyhow::Error::from)?;
out.step_status("health-check", "verifying service health...");
let ctx = apiforge::steps::StepContext {
config: config.clone(),
dry_run: false,
state: std::collections::HashMap::new(),
progress: Some(out.progress_reporter()),
};
use apiforge::steps::Step;
let step = apiforge::steps::health::HealthCheckStep::new(target_semver);
match step.execute(&ctx).await {
Ok(output) => out.step_done("health-check", &output),
Err(e) => {
let safe = apiforge::utils::sanitize_message(&e.to_string());
out.step_fail("health-check", &safe);
anyhow::bail!(
"Rollback to {} applied, but the health check did not pass: {}",
target_version,
safe
);
}
}
}
println!(
"\n{}",
format!("✓ Rollback to {} complete!", target_version)
.green()
.bold()
);
Ok(())
}
async fn detect_rollback_target(
config: &Config,
dry_run: bool,
) -> anyhow::Result<(String, Option<String>, &'static str)> {
let current: Option<semver::Version> = if dry_run {
None
} else {
match apiforge::integrations::kubernetes::K8sClient::new(&config.kubernetes.context).await {
Ok(k8s) => k8s
.get_deployment(&config.kubernetes.namespace, &config.kubernetes.deployment)
.await
.ok()
.and_then(|d| {
d.spec
.as_ref()
.and_then(|s| s.template.spec.as_ref())
.and_then(|s| s.containers.first())
.and_then(|c| c.image.as_deref())
.and_then(apiforge::utils::version_from_image)
}),
Err(_) => None,
}
};
let mut successful: Vec<semver::Version> = Vec::new();
let audit_dir = std::path::Path::new(".apiforge/audit");
if audit_dir.exists() {
if let Ok(store) = apiforge::audit::AuditStore::open(audit_dir) {
if let Ok(records) = store.list(usize::MAX) {
successful = records
.iter()
.filter(|r| r.status == apiforge::audit::ReleaseStatus::Success && !r.dry_run)
.filter_map(|r| semver::Version::parse(r.version.trim_start_matches('v')).ok())
.collect();
}
}
}
let tags: Vec<semver::Version> = apiforge::integrations::git::GitRepo::open()
.ok()
.and_then(|repo| {
repo.list_tags(&config.git.tag_format.replace("{version}", "*"))
.ok()
})
.map(|tags| {
tags.iter()
.filter_map(|t| semver::Version::parse(t.trim_start_matches('v')).ok())
.collect()
})
.unwrap_or_default();
let source = if !successful.is_empty() {
"release history"
} else {
"git tags"
};
let target = apiforge::utils::select_rollback_target(current.as_ref(), &successful, &tags)
.ok_or_else(|| {
anyhow::anyhow!(
"Could not auto-detect a rollback target: no older successful release or tag found. \
Specify the version explicitly with --to <version>."
)
})?;
Ok((target.to_string(), current.map(|v| v.to_string()), source))
}
async fn cmd_history(args: apiforge::cli::HistoryArgs) -> anyhow::Result<()> {
use colored::Colorize;
use comfy_table::{ContentArrangement, Table};
let audit_dir = std::path::Path::new(".apiforge/audit");
if !audit_dir.exists() {
println!("No release history found.");
println!("Run 'apiforge release patch' to create your first release.");
return Ok(());
}
let store = apiforge::audit::AuditStore::open(audit_dir)?;
let all_records = store.list(usize::MAX)?;
let records: Vec<_> = all_records
.into_iter()
.filter(|record| match args.filter.as_deref() {
Some("success") => record.status == apiforge::audit::ReleaseStatus::Success,
Some("failed") => matches!(
record.status,
apiforge::audit::ReleaseStatus::Failed | apiforge::audit::ReleaseStatus::RolledBack
),
_ => true,
})
.take(args.limit)
.collect();
if records.is_empty() {
println!("No release history found.");
println!("Run 'apiforge release patch' to create your first release.");
return Ok(());
}
if args.output == "json" {
let json = serde_json::to_string_pretty(&records)?;
println!("{}", json);
return Ok(());
}
let mut table = Table::new();
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_header(vec!["Timestamp", "Version", "Type", "Status", "Duration"]);
for record in records {
let status_display = match record.status {
apiforge::audit::ReleaseStatus::Success => "✓ success".green().to_string(),
apiforge::audit::ReleaseStatus::Failed => "✗ failed".red().to_string(),
apiforge::audit::ReleaseStatus::RolledBack => "⟲ rolled back".yellow().to_string(),
};
let dry_run_marker = if record.dry_run { " (dry-run)" } else { "" };
table.add_row(vec![
record.timestamp,
format!("{}{}", record.version, dry_run_marker),
record.bump_type,
status_display,
format!("{}ms", record.duration_ms),
]);
}
println!("\n{}", "▸ Release History".bold().cyan());
println!("{table}");
Ok(())
}
async fn cmd_status(config_path: &str) -> anyhow::Result<()> {
use colored::Colorize;
let path = PathBuf::from(config_path);
if !path.exists() {
anyhow::bail!("No apiforge.toml found. Run `apiforge init` first.");
}
let config = Config::from_file(&path)?;
println!("\n{}", "▸ Project Status".bold().cyan());
println!(" Project: {}", config.project.name.bold());
println!(" Language: {:?}", config.project.language);
if let Ok(repo) = apiforge::integrations::git::GitRepo::open() {
println!("\n{}", "▸ Git".bold().cyan());
if let Ok(branch) = repo.current_branch() {
println!(" Branch: {}", branch);
}
if let Ok(Some(tag)) = repo.get_latest_tag("v*") {
println!(" Latest tag: {}", tag.green());
}
if let Ok(sha) = repo.current_commit_sha() {
println!(" HEAD: {}", &sha[..8].dimmed());
}
}
println!("\n{}", "▸ Kubernetes".bold().cyan());
match apiforge::integrations::kubernetes::K8sClient::new(&config.kubernetes.context).await {
Ok(k8s) => {
println!(" Context: {}", config.kubernetes.context);
println!(" Namespace: {}", config.kubernetes.namespace);
match k8s
.get_deployment(&config.kubernetes.namespace, &config.kubernetes.deployment)
.await
{
Ok(deployment) => {
let image = deployment
.spec
.as_ref()
.and_then(|s| s.template.spec.as_ref())
.and_then(|s| s.containers.first())
.map(|c| c.image.as_deref().unwrap_or("unknown"))
.unwrap_or("unknown");
println!(
" Deployment: {} ({})",
config.kubernetes.deployment,
"running".green()
);
println!(" Image: {}", image);
if let Ok(status) = k8s
.get_rollout_status(
&config.kubernetes.namespace,
&config.kubernetes.deployment,
)
.await
{
let ready_status = if status.ready {
format!(
"{}/{} ready",
status.ready_replicas, status.desired_replicas
)
.green()
} else {
format!(
"{}/{} ready",
status.ready_replicas, status.desired_replicas
)
.yellow()
};
println!(" Replicas: {}", ready_status);
}
}
Err(_) => {
println!(
" Deployment: {} ({})",
config.kubernetes.deployment,
"not found".red()
);
}
}
}
Err(_) => {
println!(" {} Unable to connect to cluster", "⚠".yellow());
}
}
Ok(())
}
async fn cmd_config(config_path: &str, args: apiforge::cli::ConfigArgs) -> anyhow::Result<()> {
use apiforge::cli::ConfigCommands;
match args.command {
ConfigCommands::Validate(validate_args) => {
cmd_config_validate(config_path, validate_args).await
}
}
}
async fn cmd_config_validate(
config_path: &str,
args: apiforge::cli::ConfigValidateArgs,
) -> anyhow::Result<()> {
use colored::Colorize;
let path = PathBuf::from(config_path);
let mut checks: Vec<(String, bool, Option<String>)> = Vec::new();
let mut all_ok = true;
let file_exists = path.exists();
checks.push((
"Configuration file exists".to_string(),
file_exists,
if file_exists {
None
} else {
Some(format!("File not found: {}", path.display()))
},
));
if !file_exists {
all_ok = false;
} else {
let content_result = std::fs::read_to_string(&path);
let readable = content_result.is_ok();
let content_error_msg = content_result.as_ref().err().map(|e| e.to_string());
checks.push((
"Configuration file readable".to_string(),
readable,
content_error_msg,
));
if let Ok(content) = content_result {
let toml_result: Result<toml::Value, _> = toml::from_str(&content);
let valid_toml = toml_result.is_ok();
let toml_error_msg = toml_result.as_ref().err().map(|e| e.to_string());
checks.push(("Valid TOML syntax".to_string(), valid_toml, toml_error_msg));
if toml_result.is_ok() {
let config_result = Config::from_file(&path);
let valid_schema = config_result.is_ok();
let config_error_msg = config_result.as_ref().err().map(|e| e.to_string());
checks.push((
"Valid configuration schema".to_string(),
valid_schema,
config_error_msg,
));
if let Ok(config) = config_result {
checks.push((
"Project name specified".to_string(),
!config.project.name.is_empty(),
if config.project.name.is_empty() {
Some("Project name is empty".to_string())
} else {
None
},
));
let tag_format_valid = config.git.tag_format.contains("{version}");
checks.push((
"Git tag format contains {version}".to_string(),
tag_format_valid,
if tag_format_valid {
None
} else {
Some(format!(
"tag_format '{}' must contain {{version}} placeholder",
config.git.tag_format
))
},
));
checks.push((
"Docker repository specified".to_string(),
!config.docker.repository.is_empty(),
if config.docker.repository.is_empty() {
Some("Docker repository is empty".to_string())
} else {
None
},
));
let has_docker_tags = !config.docker.tags.is_empty();
checks.push((
"Docker tags specified".to_string(),
has_docker_tags,
if has_docker_tags {
None
} else {
Some("At least one Docker tag is required".to_string())
},
));
checks.push((
"Kubernetes namespace specified".to_string(),
!config.kubernetes.namespace.is_empty(),
None,
));
checks.push((
"Kubernetes deployment specified".to_string(),
!config.kubernetes.deployment.is_empty(),
None,
));
checks.push((
"Kubernetes context specified".to_string(),
!config.kubernetes.context.is_empty(),
None,
));
let ecr_check = if matches!(
config.docker.registry,
apiforge::config::DockerRegistry::AwsEcr
) {
let has_region = !config.aws.region.is_empty();
(
"AWS region specified (for ECR)".to_string(),
has_region,
if has_region {
None
} else {
Some("AWS region is required when using ECR".to_string())
},
)
} else {
(
"AWS region (not required for non-ECR)".to_string(),
true,
None,
)
};
checks.push(ecr_check);
if let Some(ref github) = config.github {
let repo_valid =
!github.repository.is_empty() && github.repository.contains('/');
checks.push((
"GitHub repository format valid".to_string(),
repo_valid,
if repo_valid {
None
} else {
Some(format!(
"GitHub repository '{}' must be in 'owner/repo' format",
github.repository
))
},
));
}
if let Some(ref hc) = config.health_check {
let url_valid = !hc.url.is_empty()
&& (hc.url.starts_with("http://") || hc.url.starts_with("https://"));
checks.push((
"Health check URL valid".to_string(),
url_valid,
if url_valid {
None
} else {
Some(format!(
"Health check URL '{}' must start with http:// or https://",
hc.url
))
},
));
let interval_valid = hc.interval > 0;
checks.push((
"Health check interval > 0".to_string(),
interval_valid,
if interval_valid {
None
} else {
Some("Health check interval must be greater than 0".to_string())
},
));
let timeout_valid = hc.timeout > 0;
checks.push((
"Health check timeout > 0".to_string(),
timeout_valid,
if timeout_valid {
None
} else {
Some("Health check timeout must be greater than 0".to_string())
},
));
}
if let Some(ref notifications) = config.notifications {
if let Some(ref slack) = notifications.slack {
let webhook_valid = !slack.webhook_url.is_empty()
&& (slack.webhook_url.starts_with("https://hooks.slack.com")
|| slack.webhook_url.contains("${ssm:"));
checks.push((
"Slack webhook URL format valid".to_string(),
webhook_valid,
if webhook_valid {
None
} else {
Some(format!(
"Slack webhook URL should start with https://hooks.slack.com (got: {})",
slack.webhook_url.chars().take(30).collect::<String>()
))
},
));
}
}
if args.verbose {
checks.push((
format!("Language: {:?}", config.project.language),
true,
None,
));
checks.push((
format!("Registry: {:?}", config.docker.registry),
true,
None,
));
checks.push((
format!("Main branch: {}", config.git.main_branch),
true,
None,
));
checks.push((
format!(
"Timeout settings: fetch={}s push={}s op={}s",
config.git.fetch_timeout_secs,
config.git.push_timeout_secs,
config.git.operation_timeout_secs
),
true,
None,
));
}
} else {
all_ok = false;
}
} else {
all_ok = false;
}
} else {
all_ok = false;
}
}
let failures: Vec<_> = checks.iter().filter(|(_, ok, _)| !ok).collect();
if !failures.is_empty() {
all_ok = false;
}
if args.output == "json" {
let result = serde_json::json!({
"valid": all_ok,
"file": config_path,
"checks": checks.iter().map(|(name, ok, error)| {
serde_json::json!({
"name": name,
"passed": *ok,
"error": error
})
}).collect::<Vec<_>>()
});
println!("{}", serde_json::to_string_pretty(&result)?);
} else {
println!("\n{}", "▸ Configuration Validation".bold().cyan());
println!(" File: {}\n", path.display().to_string().dimmed());
for (name, ok, error) in &checks {
let status = if *ok { "✓".green() } else { "✗".red() };
println!(" {} {}", status, name);
if let Some(ref err) = error {
println!(" {} {}", "→".dimmed(), err.dimmed());
}
}
println!();
if all_ok {
println!("{}", " ✓ Configuration is valid!".green().bold());
} else {
println!("{}", " ✗ Configuration has errors".red().bold());
}
}
if all_ok {
Ok(())
} else {
anyhow::bail!("Configuration validation failed");
}
}