xbp 10.40.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! Service-first deploy orchestration (`xbp deploy <TARGET>`).
//!
//! Primary units are `services[]`. Top-level `deploy` / `oci` / `kubernetes`
//! only supply defaults and named groups.

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;

/// Entry point for `xbp deploy <target> [flags]`.
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());
    }
}

/// Resolve project root for callers that already loaded config.
#[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"));
    }
}