xbp 10.40.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
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)?);
    }
    // Operators first when mixed in one plan
    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(|| {
        // Only inherit cluster defaults for k8s providers.
        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(), // per-service version surfaces can refine later
            "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)
}