use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::strategies::{ServiceConfig, ServiceDeployEnvConfig, XbpConfig};
use super::types::DeployStepPlan;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployPlan {
pub target: String,
pub env: String,
pub project: String,
pub project_version: String,
pub steps: Vec<DeployStepPlan>,
}
impl DeployPlan {
pub fn render_human(&self) -> String {
let mut lines = Vec::new();
lines.push(format!(
"project={} version={} env={}",
self.project, self.project_version, self.env
));
for (idx, step) in self.steps.iter().enumerate() {
lines.push(format!(
"{}. {} [{}]",
idx + 1,
step.service,
step.provider
));
if let Some(ns) = &step.namespace {
lines.push(format!(" namespace: {ns}"));
}
if let Some(image) = &step.image_ref {
lines.push(format!(" image: {image}"));
}
if let Some(workload) = &step.workload {
lines.push(format!(" workload: {workload}"));
}
for action in &step.actions {
lines.push(format!(" • {action}"));
}
for url in &step.health {
lines.push(format!(" health: {url}"));
}
}
lines.join("\n")
}
}
pub fn build_deploy_plan(
project_root: &Path,
config: &XbpConfig,
services: &[ServiceConfig],
env: &str,
target: &str,
) -> Result<DeployPlan, String> {
let mut steps = Vec::new();
for service in services {
steps.push(build_step(project_root, config, service, env)?);
}
steps.sort_by_key(|s| provider_priority(&s.provider));
Ok(DeployPlan {
target: target.to_string(),
env: env.to_string(),
project: config.project_name.clone(),
project_version: config.version.clone(),
steps,
})
}
fn provider_priority(provider: &str) -> u8 {
match provider {
"kubernetes-operator" => 0,
"kubernetes" => 1,
"worker" => 2,
"pm2" | "systemd" => 3,
_ => 9,
}
}
fn build_step(
project_root: &Path,
config: &XbpConfig,
service: &ServiceConfig,
env: &str,
) -> Result<DeployStepPlan, String> {
let deploy = service
.deploy
.as_ref()
.ok_or_else(|| format!("service `{}` has no deploy config", service.name))?;
let env_cfg = deploy
.envs
.get(env)
.ok_or_else(|| format!("service `{}` missing deploy.envs.{}", service.name, env))?;
let provider = deploy
.provider
.as_deref()
.unwrap_or("kubernetes")
.trim()
.to_string();
let namespace = env_cfg.namespace.clone().or_else(|| {
if matches!(
provider.as_str(),
"kubernetes" | "kubernetes-operator"
) {
config
.kubernetes
.as_ref()
.and_then(|k| k.default_namespace.clone())
} else {
None
}
});
let image_ref = resolve_image_ref(config, service);
let (manifest_paths, workload, crds_path, install_path, selector) =
collect_k8s_paths(project_root, service, env_cfg);
let mut actions = Vec::new();
match provider.as_str() {
"kubernetes-operator" => {
if crds_path.is_some() {
actions.push("apply CRDs".to_string());
}
if install_path.is_some() {
actions.push("apply operator install manifests".to_string());
}
if selector.is_some() {
actions.push("wait for operator selector".to_string());
}
actions.push("verify operator readiness".to_string());
}
"kubernetes" => {
if image_ref.is_some() {
actions.push("resolve/verify OCI image reference".to_string());
}
if !manifest_paths.is_empty() {
actions.push(format!("apply {} manifest path(s)", manifest_paths.len()));
} else {
actions.push("no manifests configured (plan-only metadata)".to_string());
}
if workload.is_some() {
actions.push("wait for workload rollout".to_string());
}
if !env_cfg.health.is_empty() {
actions.push(format!("probe {} health URL(s)", env_cfg.health.len()));
}
}
"worker" => {
actions.push("invoke worker deploy pipeline (wrangler / xbp workers)".to_string());
if let Some(cmd) = service
.commands
.as_ref()
.and_then(|c| c.get("deploy").cloned())
{
actions.push(format!("service command: {cmd}"));
}
}
"pm2" | "systemd" => {
actions.push(format!("local runtime provider `{provider}`"));
if let Some(cmd) = service
.commands
.as_ref()
.and_then(|c| c.get("deploy").or_else(|| c.get("start")).cloned())
{
actions.push(format!("run: {cmd}"));
}
}
other => {
actions.push(format!("provider `{other}` (generic plan)"));
}
}
Ok(DeployStepPlan {
service: service.name.clone(),
provider,
env: env.to_string(),
namespace,
image_ref,
actions,
health: env_cfg.health.clone(),
workload,
manifest_paths,
crds_path,
install_path,
selector,
})
}
fn resolve_image_ref(config: &XbpConfig, service: &ServiceConfig) -> Option<String> {
let oci = service.oci.as_ref()?;
let image = oci.image.as_ref()?.trim();
if image.is_empty() {
return None;
}
let tag = if let Some(tag) = oci.tag.as_deref().map(str::trim).filter(|s| !s.is_empty()) {
tag.to_string()
} else {
match oci.tag_from.as_deref().unwrap_or("project_version") {
"service_version" => config.version.clone(), "git_sha" => "git".to_string(),
"project_version" | _ => config.version.clone(),
}
};
Some(format!("{image}:{tag}"))
}
fn collect_k8s_paths(
project_root: &Path,
service: &ServiceConfig,
env_cfg: &ServiceDeployEnvConfig,
) -> (
Vec<String>,
Option<String>,
Option<String>,
Option<String>,
Option<String>,
) {
let k8s = env_cfg.kubernetes.as_ref();
let mut paths = Vec::new();
if let Some(manifests) = k8s.and_then(|k| k.manifests.as_ref()) {
for candidate in [manifests.base.as_ref(), manifests.overlay.as_ref()]
.into_iter()
.flatten()
{
let abs = resolve_path(project_root, service, candidate);
paths.push(abs.display().to_string());
}
}
let workload = k8s.and_then(|k| k.workload.clone());
let crds_path = k8s
.and_then(|k| k.crds_path.as_ref())
.map(|p| resolve_path(project_root, service, p).display().to_string());
let install_path = k8s
.and_then(|k| k.install_path.as_ref())
.map(|p| resolve_path(project_root, service, p).display().to_string());
let selector = k8s.and_then(|k| k.selector.clone());
(paths, workload, crds_path, install_path, selector)
}
fn resolve_path(project_root: &Path, service: &ServiceConfig, relative: &str) -> PathBuf {
let rel = PathBuf::from(relative);
if rel.is_absolute() {
return rel;
}
if let Some(root) = service.root_directory.as_deref() {
let under_service = project_root.join(root).join(&rel);
if under_service.exists() {
return under_service;
}
}
project_root.join(rel)
}