xbp 10.43.0

XBP is a zero-config build pack that can also interact with proxies, kafka, sockets, synthetic monitors.
Documentation
//! CLI bridge for `xbp deploy`.
//!
//! Builds [`xbp_deploy::DeployContext`] from project config and delegates to
//! the declarative engine (planner / runner / verifier). No business logic here.

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,
}

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

    // Provisional target; planner re-resolves with group>service priority.
    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>> {
    // Anonymous/default client; per-image auth can be refined later.
    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() {
        // Smoke: engine target resolver is available to CLI via crate.
        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(_)));
    }
}