pub mod map_config;
pub use xbp_deploy::{DeployMode, DeployTarget};
use std::sync::Arc;
use colored::Colorize;
use xbp_deploy::{
DefaultDeployPlanner, DefaultDeployRunner, DefaultDeployVerifier, DeployContext, DeployFlags,
DeployHistoryStore, DeployPlanner, DeployRunner, DeployVerifier,
};
use xbp_k8s::KubectlAdapter;
use xbp_oci::{
resolve_auth_from_candidates, DefaultOciPromoter, DefaultOciResolver, OciAuthRequest, OciClient,
};
use crate::commands::service::load_xbp_config_with_root;
use crate::oci::auth_candidates_for_registry;
use self::map_config::map_project_config;
#[derive(Debug, Clone)]
pub struct DeployRequest {
pub target: String,
pub env: Option<String>,
pub mode: DeployMode,
pub flags: DeployFlags,
pub debug: bool,
}
pub async fn run_deploy(request: DeployRequest) -> Result<(), String> {
let (project_root, xbp_config) = load_xbp_config_with_root().await?;
let config = map_project_config(&project_root, &xbp_config);
let env = request
.env
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_string)
.or_else(|| config.default_env.clone())
.unwrap_or_else(|| "production".into());
if matches!(request.mode, DeployMode::History) {
let store = DeployHistoryStore::new(&config.history_dir);
let entries = store
.list(&request.target, &env, request.flags.history_limit.max(1))
.map_err(|e| e.to_string())?;
if entries.is_empty() {
println!("{}", "No matching deploy history entries.".dimmed());
return Ok(());
}
for e in entries {
let status = if e.status == "success" {
"ok".bright_green().to_string()
} else {
e.status.bright_red().to_string()
};
println!(
"{} {} {} {} {}",
e.timestamp.format("%Y-%m-%d %H:%M:%S UTC"),
status,
e.target.bright_white(),
e.env.bright_yellow(),
e.id
);
}
return Ok(());
}
if matches!(request.mode, DeployMode::Status) {
let store = DeployHistoryStore::new(&config.history_dir);
match store
.latest_status(&request.target, &env)
.map_err(|e| e.to_string())?
{
Some(e) => {
println!(
"{} {} env={} status={}",
"Status".bright_cyan().bold(),
e.target.bright_white(),
e.env.bright_yellow(),
e.status
);
println!("id={} path={}", e.id, e.path);
}
None => println!("{}", "No deploy status recorded.".dimmed()),
}
return Ok(());
}
let target = if request.target.eq_ignore_ascii_case("all") {
DeployTarget::All
} else if config.groups.contains_key(&request.target) {
DeployTarget::Group(request.target.clone())
} else {
DeployTarget::Service(request.target.clone())
};
let ctx = DeployContext {
env: env.clone(),
target,
config: config.clone(),
flags: request.flags.clone(),
mode: request.mode,
};
let oci_resolver = build_oci_resolver(&xbp_config, request.debug);
let planner = DefaultDeployPlanner::new(oci_resolver.clone());
let plan = planner.plan(&ctx).await.map_err(|e| e.to_string())?;
println!(
"{} {} → {} service(s) env={}",
"Deploy".bright_cyan().bold(),
plan.target.label().bright_white().bold(),
plan.order.len().to_string().bright_green(),
env.bright_yellow()
);
println!("{}", plan.render_human());
if let Some(path) = &request.flags.output {
if let Some(parent) = path.parent() {
let _ = std::fs::create_dir_all(parent);
}
std::fs::write(
path,
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?,
)
.map_err(|e| format!("write --output: {e}"))?;
println!("{} {}", "wrote".bright_cyan(), path.display());
}
match request.mode {
DeployMode::Plan => {
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&plan).map_err(|e| e.to_string())?
);
}
println!(
"{}",
"Plan only (no mutations). Re-run with --run to apply.".dimmed()
);
Ok(())
}
DeployMode::Verify => {
let k8s = Arc::new(KubectlAdapter::new(request.debug));
let verifier = DefaultDeployVerifier { k8s };
let report = verifier.verify(&ctx, &plan).await.map_err(|e| e.to_string())?;
for line in &report.lines {
println!("{line}");
}
for drift in &report.drifts {
println!("{} {drift}", "drift".bright_red());
}
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&report).map_err(|e| e.to_string())?
);
}
if report.ok {
println!("{}", "Result: ok".bright_green().bold());
Ok(())
} else {
Err(format!("verify failed: {}", report.drifts.join("; ")))
}
}
DeployMode::Run | DeployMode::Promote => {
let k8s = Arc::new(KubectlAdapter::new(request.debug));
let promoter = build_oci_promoter(&xbp_config);
let confirm: Arc<dyn Fn(String) -> bool + Send + Sync> = Arc::new(|msg| {
if !std::io::IsTerminal::is_terminal(&std::io::stdin()) {
return false;
}
dialoguer::Confirm::new()
.with_prompt(msg)
.default(false)
.interact()
.unwrap_or(false)
});
let runner = DefaultDeployRunner {
k8s,
promoter,
confirm: Some(confirm),
};
let result = runner.run(&ctx, &plan).await.map_err(|e| e.to_string())?;
for line in &result.lines {
println!("{line}");
}
if request.flags.json {
println!(
"{}",
serde_json::to_string_pretty(&result).map_err(|e| e.to_string())?
);
}
if result.ok {
println!("{}", "Result: ok".bright_green().bold());
Ok(())
} else {
Err(result.error.unwrap_or_else(|| "deploy failed".into()))
}
}
DeployMode::History | DeployMode::Status => unreachable!("handled above"),
}
}
fn build_oci_resolver(
xbp_config: &crate::strategies::XbpConfig,
_debug: bool,
) -> Option<Arc<dyn xbp_oci::OciResolver>> {
let auth = resolve_auth_from_candidates(
&OciAuthRequest {
registry: "ghcr.io".into(),
..Default::default()
},
&auth_candidates_for_registry("ghcr.io", xbp_config),
);
let client = OciClient::new(auth);
Some(Arc::new(DefaultOciResolver::new(client)) as Arc<dyn xbp_oci::OciResolver>)
}
fn build_oci_promoter(
xbp_config: &crate::strategies::XbpConfig,
) -> Option<Arc<dyn xbp_oci::OciPromoter>> {
let auth = resolve_auth_from_candidates(
&OciAuthRequest {
registry: "ghcr.io".into(),
..Default::default()
},
&auth_candidates_for_registry("ghcr.io", xbp_config),
);
let client = OciClient::new(auth);
Some(Arc::new(DefaultOciPromoter::new(client)) as Arc<dyn xbp_oci::OciPromoter>)
}
#[cfg(test)]
mod tests {
use super::*;
use xbp_deploy::{resolve_target, DeployGroupView, ProjectConfig, ServiceConfigView};
use std::collections::HashMap;
use std::path::PathBuf;
#[test]
fn group_preferred_over_service_name_collision_is_engine_level() {
let mut groups = HashMap::new();
groups.insert(
"core".into(),
DeployGroupView {
description: None,
services: vec!["api".into()],
order: vec!["api".into()],
},
);
let config = ProjectConfig {
project_name: "demo".into(),
version: "1.0.0".into(),
project_root: PathBuf::from("."),
services: vec![ServiceConfigView {
name: "api".into(),
root_directory: None,
version: None,
depends_on: vec![],
oci: None,
deploy: Some(xbp_deploy::ServiceDeployView {
provider: "kubernetes".into(),
envs: {
let mut e = HashMap::new();
e.insert(
"production".into(),
xbp_deploy::ServiceDeployEnvView {
namespace: Some("ns".into()),
replicas: None,
health: vec![],
kubernetes: None,
},
);
e
},
}),
}],
groups,
default_env: Some("production".into()),
kubernetes: None,
history_dir: PathBuf::from(".xbp/deployments"),
lock_file: PathBuf::from(".xbp/deploy-lock.json"),
git_sha: None,
};
let r = resolve_target(&config, "core", "production").unwrap();
assert!(matches!(r.target, DeployTarget::Group(_)));
}
}