xbp-deploy 10.43.0

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

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

/// Validate project config before planning.
pub fn validate_project_config(config: &ProjectConfig, env: &str) -> Result<()> {
    let mut errors = Vec::new();

    for svc in &config.services {
        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 = deploy.provider.as_str();
        if matches!(provider, "kubernetes" | "kubernetes-operator") {
            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 members must exist.
    for (name, group) in &config.groups {
        for svc in &group.services {
            if !config.services.iter().any(|s| s.name == *svc) {
                errors.push(format!(
                    "deploy.groups.{name} references unknown service `{svc}`"
                ));
            }
        }
    }

    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::new();
        envs.insert(
            "production".into(),
            ServiceDeployEnvView {
                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 {
                name: "athena-operator".into(),
                root_directory: None,
                version: None,
                depends_on: vec![],
                oci: None,
                deploy: Some(ServiceDeployView {
                    provider: "kubernetes-operator".into(),
                    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"));
    }
}