xbp-deploy 10.57.0

Service-centric declarative deploy engine for XBP.
Documentation
//! Filter a built [`DeployPlan`] by service membership (`--only` / `--exclude`).

use std::collections::{BTreeMap, BTreeSet, HashSet};

use crate::error::{DeployError, Result};
use crate::lock::compute_plan_hash;
use crate::types::{DeployPlan, ServicePlan};

/// Logical service name from a plan step or order entry (`svc` or `svc@destination`).
pub fn logical_service_name(step_or_name: &str) -> &str {
    step_or_name
        .split_once('@')
        .map(|(base, _)| base)
        .unwrap_or(step_or_name)
}

/// Keep only plan steps whose logical service name is in `keep`.
///
/// Recomputes `hash`. Returns an error when `keep` is empty or when no steps remain.
pub fn retain_services_in_plan(mut plan: DeployPlan, keep: &HashSet<String>) -> Result<DeployPlan> {
    if keep.is_empty() {
        return Err(DeployError::Validation(
            "no services left in deploy plan after filter".into(),
        ));
    }

    plan.services
        .retain(|svc| keep.contains(logical_service_name(&svc.name)));
    if plan.services.is_empty() {
        return Err(DeployError::Validation(
            "no services left in deploy plan after filter".into(),
        ));
    }

    plan.order
        .retain(|step| keep.contains(logical_service_name(step)));

    plan.oci_plan.images = plan
        .oci_plan
        .images
        .into_iter()
        .filter(|(name, _)| keep.contains(logical_service_name(name)))
        .collect::<BTreeMap<_, _>>();

    plan.k8s_plan
        .services
        .retain(|svc| keep.contains(logical_service_name(&svc.service)));

    // Recompute default namespace when remaining k8s services share one.
    let flag_ns = plan.k8s_plan.default_namespace.clone();
    let mut service_ns = plan
        .k8s_plan
        .services
        .iter()
        .filter_map(|s| s.namespace.as_ref())
        .map(|s| s.trim())
        .filter(|s| !s.is_empty());
    plan.k8s_plan.default_namespace = if let Some(first) = service_ns.next() {
        if service_ns.all(|n| n == first) {
            Some(first.to_string())
        } else {
            flag_ns
        }
    } else {
        flag_ns
    };

    plan.hash = compute_plan_hash(&plan);
    Ok(plan)
}

/// Apply optional `--only` / `--exclude` filters to a plan.
///
/// - `only`: if non-empty, keep intersection of plan services with this set.
/// - `exclude`: drop these names after `only`.
///
/// Names are matched on logical service names. Unknown names (not present in the
/// original plan) produce a validation error with suggestions.
pub fn filter_deploy_plan(
    plan: DeployPlan,
    only: &[String],
    exclude: &[String],
) -> Result<DeployPlan> {
    let present: BTreeSet<String> = plan
        .services
        .iter()
        .map(|s| logical_service_name(&s.name).to_string())
        .collect();

    if only.is_empty() && exclude.is_empty() {
        return Ok(plan);
    }

    let only_norm: Vec<String> = only
        .iter()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();
    let exclude_norm: Vec<String> = exclude
        .iter()
        .map(|s| s.trim().to_string())
        .filter(|s| !s.is_empty())
        .collect();

    validate_known_names(&present, &only_norm, "only")?;
    validate_known_names(&present, &exclude_norm, "exclude")?;

    let mut keep: HashSet<String> = if only_norm.is_empty() {
        present.iter().cloned().collect()
    } else {
        only_norm.into_iter().collect()
    };

    for name in &exclude_norm {
        keep.remove(name);
    }

    retain_services_in_plan(plan, &keep)
}

/// Unique logical service names in plan order (first appearance wins).
pub fn unique_service_names(plan: &DeployPlan) -> Vec<String> {
    let mut seen = HashSet::new();
    let mut out = Vec::new();
    for step in &plan.order {
        let name = logical_service_name(step).to_string();
        if seen.insert(name.clone()) {
            out.push(name);
        }
    }
    if out.is_empty() {
        for svc in &plan.services {
            let name = logical_service_name(&svc.name).to_string();
            if seen.insert(name.clone()) {
                out.push(name);
            }
        }
    }
    out
}

/// Build MultiSelect-style labels for plan services (unique names).
pub fn service_plan_labels(plan: &DeployPlan) -> Vec<(String, String)> {
    let mut by_name: BTreeMap<String, &ServicePlan> = BTreeMap::new();
    for svc in &plan.services {
        by_name
            .entry(logical_service_name(&svc.name).to_string())
            .or_insert(svc);
    }
    unique_service_names(plan)
        .into_iter()
        .filter_map(|name| {
            let svc = by_name.get(&name)?;
            let ns = svc
                .deploy
                .namespace
                .as_deref()
                .filter(|s| !s.is_empty())
                .map(|s| format!("  ·  ns={s}"))
                .unwrap_or_default();
            let label = format!("{}  ·  {}{}", name, svc.provider, ns);
            Some((name, label))
        })
        .collect()
}

fn validate_known_names(
    present: &BTreeSet<String>,
    names: &[String],
    flag: &str,
) -> Result<()> {
    let mut unknown = Vec::new();
    for name in names {
        if !present.contains(name) {
            unknown.push(name.clone());
        }
    }
    if unknown.is_empty() {
        return Ok(());
    }
    let suggestions: Vec<&str> = present.iter().map(String::as_str).take(12).collect();
    Err(DeployError::Validation(format!(
        "unknown service name(s) for --{flag}: {}\n\
         services in plan: {}",
        unknown.join(", "),
        if suggestions.is_empty() {
            "(none)".into()
        } else {
            suggestions.join(", ")
        }
    )))
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::types::{
        DeployTarget, K8sPlanView, OciImagePlan, OciPlan, ServiceDeployPlan,
    };

    fn svc(name: &str, provider: &str) -> ServicePlan {
        ServicePlan {
            name: name.into(),
            provider: provider.into(),
            destination: None,
            root_directory: None,
            version: "1.0.0".into(),
            image: None,
            image_ref: None,
            digest: None,
            dockerfile: None,
            build_context: None,
            platforms: vec![],
            worker_app: None,
            rollout: None,
            runtime_env: Default::default(),
            container_port: None,
            config_mounts: vec![],
            expose: None,
            deploy: ServiceDeployPlan {
                namespace: Some("default".into()),
                workload: None,
                service: None,
                health: vec![],
                manifest_paths: vec![],
                crds_path: None,
                install_path: None,
                selector: None,
                actions: vec![],
            },
        }
    }

    fn sample_plan() -> DeployPlan {
        let mut plan = DeployPlan {
            target: DeployTarget::All,
            env: "production".into(),
            project: "demo".into(),
            project_version: "0.1.0".into(),
            git_sha: None,
            services: vec![
                svc("api", "kubernetes"),
                svc("worker", "kubernetes"),
                svc("web", "cloudflare-containers"),
            ],
            order: vec!["api".into(), "worker".into(), "web".into()],
            oci_plan: OciPlan {
                images: BTreeMap::from([(
                    "api".into(),
                    OciImagePlan {
                        ref_str: "ghcr.io/x/api:1".into(),
                        digest: Some("sha256:aaa".into()),
                    },
                )]),
            },
            k8s_plan: K8sPlanView {
                context: Some("docker-desktop".into()),
                default_namespace: Some("default".into()),
                services: vec![
                    xbp_k8s::K8sServicePlan {
                        service: "api".into(),
                        provider: "kubernetes".into(),
                        namespace: Some("default".into()),
                        workload: None,
                        manifest_paths: vec![],
                        crds_path: None,
                        install_path: None,
                        selector: None,
                        health: vec![],
                    },
                    xbp_k8s::K8sServicePlan {
                        service: "worker".into(),
                        provider: "kubernetes".into(),
                        namespace: Some("default".into()),
                        workload: None,
                        manifest_paths: vec![],
                        crds_path: None,
                        install_path: None,
                        selector: None,
                        health: vec![],
                    },
                ],
            },
            hash: String::new(),
        };
        plan.hash = compute_plan_hash(&plan);
        plan
    }

    #[test]
    fn only_keeps_named_services() {
        let plan = sample_plan();
        let filtered =
            filter_deploy_plan(plan, &["api".into(), "web".into()], &[]).expect("filter");
        let names = unique_service_names(&filtered);
        assert_eq!(names, vec!["api".to_string(), "web".to_string()]);
        assert_eq!(filtered.services.len(), 2);
        assert!(filtered.k8s_plan.services.iter().all(|s| s.service == "api"));
        assert!(filtered.oci_plan.images.contains_key("api"));
        assert!(!filtered.hash.is_empty());
    }

    #[test]
    fn exclude_drops_services() {
        let plan = sample_plan();
        let filtered = filter_deploy_plan(plan, &[], &["worker".into()]).expect("filter");
        assert_eq!(
            unique_service_names(&filtered),
            vec!["api".to_string(), "web".to_string()]
        );
    }

    #[test]
    fn only_then_exclude() {
        let plan = sample_plan();
        let filtered =
            filter_deploy_plan(plan, &["api".into(), "worker".into()], &["worker".into()])
                .expect("filter");
        assert_eq!(unique_service_names(&filtered), vec!["api".to_string()]);
    }

    #[test]
    fn empty_result_errors() {
        let plan = sample_plan();
        let err = filter_deploy_plan(plan, &[], &["api".into(), "worker".into(), "web".into()])
            .unwrap_err();
        assert!(err.to_string().contains("no services left"));
    }

    #[test]
    fn unknown_name_errors() {
        let plan = sample_plan();
        let err = filter_deploy_plan(plan, &["nope".into()], &[]).unwrap_err();
        assert!(err.to_string().contains("unknown service"));
        assert!(err.to_string().contains("nope"));
    }

    #[test]
    fn destination_steps_match_logical_name() {
        let mut plan = sample_plan();
        plan.services[0].name = "api@kubernetes".into();
        plan.order[0] = "api@kubernetes".into();
        plan.hash = compute_plan_hash(&plan);
        let filtered = filter_deploy_plan(plan, &["api".into()], &[]).expect("filter");
        assert_eq!(filtered.services.len(), 1);
        assert_eq!(logical_service_name(&filtered.services[0].name), "api");
    }
}