xbp-deploy 10.46.0

Service-centric declarative deploy engine for XBP.
Documentation
use crate::error::{DeployError, Result};
use crate::types::{DeployTarget, ProjectConfig, ServiceConfigView};

#[derive(Debug, Clone)]
pub struct TargetResolution {
    pub target: DeployTarget,
    pub services: Vec<ServiceConfigView>,
    pub group_order: Option<Vec<String>>,
    pub note: Option<String>,
}

/// Resolve target with priority:
/// 1. exact group match
/// 2. exact service match
/// 3. unique prefix match
/// 4. error with suggestions
pub fn resolve_target(
    config: &ProjectConfig,
    raw: &str,
    env: &str,
) -> Result<TargetResolution> {
    let raw = raw.trim();
    if raw.is_empty() {
        return Err(unknown(raw, config, env, "deploy target is required"));
    }

    if raw.eq_ignore_ascii_case("all") {
        let services: Vec<_> = config
            .services
            .iter()
            .filter(|s| service_has_env(s, env))
            .cloned()
            .collect();
        if services.is_empty() {
            return Err(DeployError::Validation(format!(
                "no services declare deploy.envs.{env}"
            )));
        }
        return Ok(TargetResolution {
            target: DeployTarget::All,
            services,
            group_order: None,
            note: None,
        });
    }

    // 1. exact group
    if let Some(group) = config.groups.get(raw) {
        let order = if group.order.is_empty() {
            group.services.clone()
        } else {
            group.order.clone()
        };
        let mut services = Vec::new();
        for name in &order {
            let svc = config
                .services
                .iter()
                .find(|s| s.name == *name)
                .cloned()
                .ok_or_else(|| {
                    DeployError::Validation(format!(
                        "deploy group `{raw}` references unknown service `{name}`"
                    ))
                })?;
            if !service_has_env(&svc, env) {
                return Err(DeployError::Validation(format!(
                    "service `{name}` in group `{raw}` has no deploy.envs.{env}"
                )));
            }
            services.push(svc);
        }
        let note = if config.services.iter().any(|s| s.name == raw) {
            Some(format!(
                "target `{raw}` matches both a deploy group and a service name; using group"
            ))
        } else {
            None
        };
        return Ok(TargetResolution {
            target: DeployTarget::Group(raw.to_string()),
            services,
            group_order: Some(order),
            note,
        });
    }

    // 2. exact service
    if let Some(svc) = config.services.iter().find(|s| s.name == raw).cloned() {
        if !service_has_env(&svc, env) {
            return Err(DeployError::Validation(format!(
                "service `{raw}` has no deploy.envs.{env}"
            )));
        }
        return Ok(TargetResolution {
            target: DeployTarget::Service(raw.to_string()),
            services: vec![svc],
            group_order: None,
            note: None,
        });
    }

    // 3. unique prefix
    let group_prefixes: Vec<String> = config
        .groups
        .keys()
        .filter(|k| k.starts_with(raw))
        .cloned()
        .collect();
    let service_prefixes: Vec<String> = config
        .services
        .iter()
        .map(|s| s.name.clone())
        .filter(|n| n.starts_with(raw))
        .collect();

    if group_prefixes.len() == 1 && service_prefixes.is_empty() {
        return resolve_target(config, &group_prefixes[0], env);
    }
    if service_prefixes.len() == 1 && group_prefixes.is_empty() {
        return resolve_target(config, &service_prefixes[0], env);
    }

    Err(unknown(raw, config, env, &format!("unknown deploy target `{raw}`")))
}

fn service_has_env(svc: &ServiceConfigView, env: &str) -> bool {
    svc.deploy
        .as_ref()
        .map(|d| d.envs.contains_key(env))
        .unwrap_or(false)
}

fn unknown(target: &str, config: &ProjectConfig, env: &str, prefix: &str) -> DeployError {
    let mut suggestions = Vec::new();
    for g in config.groups.keys() {
        suggestions.push(format!("- {g} (group)"));
    }
    for s in config.services.iter().filter(|s| service_has_env(s, env)) {
        suggestions.push(format!("- {} (service)", s.name));
    }
    suggestions.push("- all".into());
    let body = if suggestions.is_empty() {
        String::new()
    } else {
        format!("\nDid you mean:\n{}", suggestions.join("\n"))
    };
    DeployError::UnknownTarget {
        target: target.to_string(),
        suggestions: format!(". {prefix}{body}"),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::*;
    use std::collections::HashMap;
    use std::path::PathBuf;

    fn cfg() -> ProjectConfig {
        let mut groups = HashMap::new();
        groups.insert(
            "athena".into(),
            DeployGroupView {
                description: None,
                services: vec!["athena-runtime".into()],
                order: vec!["athena-runtime".into()],
            },
        );
        let mut envs = HashMap::new();
        envs.insert(
            "production".into(),
            ServiceDeployEnvView {
                namespace: Some("ns".into()),
                replicas: None,
                health: vec![],
                kubernetes: None,
            },
        );
        ProjectConfig {
            project_name: "demo".into(),
            version: "1.0.0".into(),
            project_root: PathBuf::from("."),
            services: vec![ServiceConfigView {
                name: "athena-runtime".into(),
                root_directory: None,
                version: None,
                depends_on: vec![],
                oci: None,
                deploy: Some(ServiceDeployView {
                    provider: "kubernetes".into(),
                    envs,
                }),
            }],
            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,
        }
    }

    #[test]
    fn prefers_group() {
        let r = resolve_target(&cfg(), "athena", "production").unwrap();
        assert!(matches!(r.target, DeployTarget::Group(_)));
    }

    #[test]
    fn prefix_unique() {
        let r = resolve_target(&cfg(), "athena-r", "production").unwrap();
        assert!(matches!(r.target, DeployTarget::Service(_)));
    }
}