mod execute;
mod history;
mod plan;
mod providers;
mod resolve;
mod types;
pub use types::{DeployMode, DeployRequest};
use colored::Colorize;
use std::path::PathBuf;
use crate::commands::service::load_xbp_config_with_root;
use self::execute::execute_plan;
use self::history::{list_history, write_history_record};
use self::plan::build_deploy_plan;
use self::resolve::{resolve_deploy_env, resolve_deploy_targets};
use self::types::DeployRunRecord;
pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
let (project_root, config) = load_xbp_config_with_root().await?;
let env = resolve_deploy_env(&config, request.env.as_deref());
let mode = request.mode;
if matches!(mode, DeployMode::History) {
return list_history(&project_root, &config, &request.target, &env, request.limit);
}
let services = resolve_deploy_targets(&config, &request.target, &env)?;
if services.is_empty() {
return Err(format!(
"No deployable services matched target `{}` for env `{}`. \
Configure `services[].deploy.envs.{env}` or a `deploy.groups` entry.",
request.target, env
));
}
let plan = build_deploy_plan(&project_root, &config, &services, &env, &request.target)?;
println!(
"{} {} → {} service(s) env={}",
"Deploy".bright_cyan().bold(),
request.target.bright_white().bold(),
plan.steps.len().to_string().bright_green(),
env.bright_yellow()
);
println!("{}", plan.render_human());
match mode {
DeployMode::Plan => {
if request.json {
println!("{}", serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?);
}
println!(
"{}",
"Plan only (no cluster/registry mutations). Re-run with --run to apply."
.dimmed()
);
Ok(())
}
DeployMode::Status | DeployMode::Verify => {
let report = execute_plan(
&project_root,
&config,
&plan,
mode,
request.yes,
request.debug,
)
.await?;
print_report(&report);
if !report.ok {
return Err(report.summary());
}
Ok(())
}
DeployMode::Run | DeployMode::Promote => {
if matches!(mode, DeployMode::Promote) {
println!(
"{} {}",
"Promote".bright_yellow().bold(),
request
.promote_stage
.as_deref()
.unwrap_or("stable")
.bright_white()
);
}
let report = execute_plan(
&project_root,
&config,
&plan,
DeployMode::Run,
request.yes,
request.debug,
)
.await?;
print_report(&report);
let record = DeployRunRecord::from_report(&request.target, &env, &plan, &report);
write_history_record(&project_root, &config, &record)?;
if !report.ok {
return Err(report.summary());
}
println!(
"{} {}",
"Done".bright_green().bold(),
format!("deployed `{}` to env `{}`", request.target, env).bright_white()
);
Ok(())
}
DeployMode::History => unreachable!("history handled above"),
}
}
fn print_report(report: &types::DeployExecutionReport) {
for line in &report.lines {
println!("{line}");
}
if report.ok {
println!("{}", "Result: ok".bright_green().bold());
} else {
println!("{}", "Result: failed".bright_red().bold());
}
}
#[allow(dead_code)]
pub fn project_root_from_config_path(config_path: &std::path::Path) -> PathBuf {
config_path
.parent()
.and_then(|p| p.parent())
.unwrap_or(config_path)
.to_path_buf()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::strategies::{
DeployGroupConfig, DeployProjectConfig, ServiceConfig, ServiceDeployConfig,
ServiceDeployEnvConfig, ServiceDeployKubernetesConfig, XbpConfig,
};
use std::collections::HashMap;
fn sample_config() -> XbpConfig {
let mut envs = HashMap::new();
envs.insert(
"production".to_string(),
ServiceDeployEnvConfig {
namespace: Some("demo".to_string()),
replicas: Some(2),
health: vec!["https://api.example.com/health".to_string()],
kubernetes: Some(ServiceDeployKubernetesConfig {
manifests: None,
workload: Some("deployment/api".to_string()),
service: Some("api".to_string()),
crds_path: None,
install_path: None,
selector: None,
}),
},
);
let mut groups = HashMap::new();
groups.insert(
"core".to_string(),
DeployGroupConfig {
services: vec!["api".to_string(), "worker".to_string()],
order: vec!["api".to_string(), "worker".to_string()],
description: Some("core stack".to_string()),
},
);
XbpConfig {
project_name: "demo".to_string(),
version: "1.0.0".to_string(),
port: 3000,
build_dir: "./".to_string(),
app_type: None,
build_command: None,
start_command: None,
install_command: None,
environment: None,
services: Some(vec![
ServiceConfig {
name: "api".to_string(),
target: "rust".to_string(),
branch: "main".to_string(),
port: 3000,
root_directory: Some("apps/api".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: Some(ServiceDeployConfig {
provider: Some("kubernetes".to_string()),
envs: envs.clone(),
}),
},
ServiceConfig {
name: "worker".to_string(),
target: "nodejs".to_string(),
branch: "main".to_string(),
port: 3001,
root_directory: Some("apps/worker".to_string()),
environment: None,
url: None,
healthcheck_path: None,
restart_policy: None,
restart_policy_max_failure_count: None,
start_wrapper: None,
commands: None,
force_run_from_root: None,
version_targets: None,
watch_paths: None,
versioning: None,
release: None,
systemd_service_name: None,
systemd: None,
openapi: None,
oci: None,
deploy: Some(ServiceDeployConfig {
provider: Some("worker".to_string()),
envs,
}),
},
]),
openapi: None,
workers: None,
systemd_service_name: None,
systemd: None,
kafka_brokers: None,
kafka_topic: None,
kafka_public_url: None,
log_files: None,
monitor_url: None,
monitor_method: None,
monitor_expected_code: None,
monitor_interval: None,
database: None,
oci: None,
kubernetes: None,
deploy: Some(DeployProjectConfig {
default_env: Some("production".to_string()),
history_dir: Some(".xbp/deployments".to_string()),
lock_file: Some(".xbp/deploy-lock.json".to_string()),
groups,
}),
target: None,
branch: None,
crate_name: None,
npm_script: None,
port_storybook: None,
url: None,
url_storybook: None,
linear: None,
github: None,
publish: None,
version_targets: vec![],
version_domains: vec![],
ignore_paths: vec![],
watch_ignore_paths: vec![],
versioning_disabled: vec![],
release_disabled: vec![],
}
}
#[test]
fn resolves_group_in_order() {
let config = sample_config();
let services = resolve_deploy_targets(&config, "core", "production").unwrap();
assert_eq!(
services
.iter()
.map(|s| s.name.as_str())
.collect::<Vec<_>>(),
vec!["api", "worker"]
);
}
#[test]
fn resolves_single_service() {
let config = sample_config();
let services = resolve_deploy_targets(&config, "api", "production").unwrap();
assert_eq!(services.len(), 1);
assert_eq!(services[0].name, "api");
}
#[test]
fn resolves_all_with_env() {
let config = sample_config();
let services = resolve_deploy_targets(&config, "all", "production").unwrap();
assert_eq!(services.len(), 2);
}
#[test]
fn unknown_target_errors() {
let config = sample_config();
let err = resolve_deploy_targets(&config, "missing", "production").unwrap_err();
assert!(err.contains("unknown deploy target"));
}
#[test]
fn builds_plan_steps() {
let config = sample_config();
let services = resolve_deploy_targets(&config, "core", "production").unwrap();
let plan = build_deploy_plan(
std::path::Path::new("."),
&config,
&services,
"production",
"core",
)
.unwrap();
assert_eq!(plan.steps.len(), 2);
assert_eq!(plan.steps[0].provider, "kubernetes");
assert!(plan.render_human().contains("api"));
}
}