xbp-deploy 10.57.0

Service-centric declarative deploy engine for XBP.
Documentation
use url::Url;

use crate::error::{DeployError, Result};
use crate::types::{DeployTarget, ProjectConfig};

/// Validate project config before planning.
///
/// `target` scopes how strictly `deploy.groups` is checked:
/// - [`DeployTarget::Service`] — only that service's deploy.envs block is validated;
///   stale `groups.all` members must not block a single-service deploy.
/// - [`DeployTarget::Group`] / [`DeployTarget::All`] — group membership is checked,
///   but unknown members are soft (ignored) unless a group has zero known services.
pub fn validate_project_config(config: &ProjectConfig, env: &str) -> Result<()> {
    validate_project_config_for_target(config, env, None)
}

/// Like [`validate_project_config`], with optional deploy target scope.
pub fn validate_project_config_for_target(
    config: &ProjectConfig,
    env: &str,
    target: Option<&DeployTarget>,
) -> Result<()> {
    let mut errors: Vec<String> = Vec::new();

    let only_service = match target {
        Some(DeployTarget::Service(name)) => Some(name.as_str()),
        _ => None,
    };

    for svc in &config.services {
        if let Some(name) = only_service {
            if svc.name != name {
                continue;
            }
        }

        let Some(deploy) = &svc.deploy else {
            continue;
        };
        let Some(env_cfg) = deploy.envs.get(env) else {
            continue;
        };

        if let Some(oci) = &svc.oci {
            if oci.image.trim().is_empty() {
                errors.push(format!("service `{}` oci.image is empty", svc.name));
            }
        }

        let provider: &str = deploy.provider.as_str();
        // Namespace is only required for Kubernetes providers (CF / local skip this).
        if crate::providers::is_kubernetes_provider(provider) {
            let ns = env_cfg.namespace.clone().or_else(|| {
                config
                    .kubernetes
                    .as_ref()
                    .and_then(|k| k.default_namespace.clone())
            });
            if ns.as_ref().map(|s| s.trim().is_empty()).unwrap_or(true) {
                errors.push(format!(
                    "service `{}` kubernetes deploy requires namespace or kubernetes.default_namespace",
                    svc.name
                ));
            }
        }

        for health in &env_cfg.health {
            if Url::parse(health).is_err() {
                errors.push(format!(
                    "service `{}` invalid health URL `{health}`",
                    svc.name
                ));
            }
        }

        // Athena guardrail: do not invent unknown CRD kinds in path names.
        if svc.name.contains("athena") {
            for path in [
                env_cfg
                    .kubernetes
                    .as_ref()
                    .and_then(|k| k.crds_path.as_deref()),
                env_cfg
                    .kubernetes
                    .as_ref()
                    .and_then(|k| k.install_path.as_deref()),
            ]
            .into_iter()
            .flatten()
            {
                if path.to_ascii_lowercase().contains("athenacluster") {
                    errors.push(format!(
                        "service `{}` must not reference AthenaCluster paths (`{path}`)",
                        svc.name
                    ));
                }
            }
        }
    }

    // Group membership only matters when deploying a group or everything.
    // Single-service deploys must not fail because `groups.all` is stale.
    let check_groups = match target {
        Some(DeployTarget::Service(_)) => false,
        Some(DeployTarget::Group(_) | DeployTarget::All) | None => true,
    };

    if check_groups {
        for (name, group) in &config.groups {
            // When deploying one named group, only validate that group.
            if let Some(DeployTarget::Group(wanted)) = target {
                if name != wanted {
                    continue;
                }
            }

            let members = if group.order.is_empty() {
                &group.services
            } else {
                &group.order
            };
            let known: Vec<&str> = members
                .iter()
                .map(String::as_str)
                .filter(|svc| config.services.iter().any(|s| s.name == *svc))
                .collect();
            let unknown: Vec<&str> = members
                .iter()
                .map(String::as_str)
                .filter(|svc| !config.services.iter().any(|s| s.name == *svc))
                .collect();
            if !unknown.is_empty() && known.is_empty() {
                errors.push(format!(
                    "deploy.groups.{name} has no known services (unknown: {})",
                    unknown.join(", ")
                ));
            }
            // Residual unknowns are non-fatal: CLI heal + map_config prune them;
            // group resolve soft-skips any leftovers.
        }
    }

    if errors.is_empty() {
        Ok(())
    } else {
        Err(DeployError::Validation(errors.join("; ")))
    }
}

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

    #[test]
    fn rejects_athenacluster_path() {
        let mut envs: HashMap<String, ServiceDeployEnvView> = HashMap::new();
        envs.insert(
            "production".into(),
            ServiceDeployEnvView {
                env: Default::default(),
                env_files: vec![],
                config_files: vec![],
                container_port: None,
                required_env: vec![],
                expose: None,
                namespace: Some("athena-system".into()),
                replicas: None,
                health: vec!["https://example.com/health".into()],
                kubernetes: Some(ServiceK8sView {
                    manifests_base: None,
                    manifests_overlay: None,
                    workload: None,
                    service: None,
                    crds_path: Some("config/athenacluster".into()),
                    install_path: None,
                    selector: None,
                }),
            },
        );
        let config = ProjectConfig {
            project_name: "x".into(),
            version: "1".into(),
            project_root: PathBuf::from("."),
            services: vec![ServiceConfigView {
                environment: Default::default(),
                port: None,
                name: "athena-operator".into(),
                root_directory: None,
                version: None,
                depends_on: vec![],
                oci: None,
                deploy: Some(ServiceDeployView {
                    provider: "kubernetes-operator".into(),
                    worker: None,
                    rollout: None,
                    destinations: Default::default(),
                    envs,
                }),
            }],
            groups: HashMap::new(),
            default_env: None,
            kubernetes: None,
            history_dir: PathBuf::from(".xbp/deployments"),
            lock_file: PathBuf::from(".xbp/deploy-lock.json"),
            git_sha: None,
        };
        let err = validate_project_config(&config, "production").unwrap_err();
        assert!(err.to_string().contains("AthenaCluster"));
    }

    #[test]
    fn single_service_ignores_stale_groups_all() {
        let mut envs: HashMap<String, ServiceDeployEnvView> = HashMap::new();
        envs.insert(
            "production".into(),
            ServiceDeployEnvView {
                env: Default::default(),
                env_files: vec![],
                config_files: vec![],
                container_port: None,
                required_env: vec![],
                expose: None,
                namespace: Some("ns".into()),
                replicas: None,
                health: vec!["https://example.com/health".into()],
                kubernetes: None,
            },
        );
        let mut groups = HashMap::new();
        groups.insert(
            "all".into(),
            DeployGroupView {
                description: None,
                services: vec![
                    "athena-studio".into(),
                    "@lexington/studio".into(),
                    "default".into(),
                ],
                order: vec![],
            },
        );
        let config = ProjectConfig {
            project_name: "x".into(),
            version: "1".into(),
            project_root: PathBuf::from("."),
            services: vec![ServiceConfigView {
                environment: Default::default(),
                port: None,
                name: "athena-studio".into(),
                root_directory: None,
                version: None,
                depends_on: vec![],
                oci: None,
                deploy: Some(ServiceDeployView {
                    provider: "kubernetes".into(),
                    worker: None,
                    rollout: None,
                    destinations: Default::default(),
                    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,
        };

        // Full config validation soft-passes residual unknowns when some are known.
        validate_project_config(&config, "production").unwrap();

        // Single-service path never looks at groups.
        validate_project_config_for_target(
            &config,
            "production",
            Some(&DeployTarget::Service("athena-studio".into())),
        )
        .unwrap();
    }
}