xbp-deploy 10.57.0

Service-centric declarative deploy engine for XBP.
Documentation
//! Deploy destination / provider classification.
//!
//! Destinations are the user-facing names (`cloudflare`, `kubernetes`, `railway`, …).
//! Providers are the engine backends those names map to.
//!
//! Kubernetes apply/rollout is **only** for k8s destinations. Other destinations
//! never populate `k8s_plan` or call kubectl.

/// Sentinel destination: expand to every enabled destination on the service.
pub const DESTINATION_ALL: &str = "all";

/// Well-known destination aliases → canonical destination id.
pub fn normalize_destination(raw: &str) -> String {
    let s = raw.trim().to_ascii_lowercase();
    match s.as_str() {
        "all" | "*" | "both" => DESTINATION_ALL.into(),
        "cf" | "cloudflare" | "cloudflare-containers" | "cloudflare-container"
        | "cloudflare-worker" | "worker" | "workers" => "cloudflare".into(),
        "k8s" | "kubernetes" | "kube" => "kubernetes".into(),
        "k8s-operator" | "kubernetes-operator" | "operator" => "kubernetes-operator".into(),
        "railway" | "rw" => "railway".into(),
        "oci" | "registry" | "ghcr" => "oci".into(),
        "docker-compose" | "compose" => "docker-compose".into(),
        "local" | "preflight" | "test" => "local".into(),
        "pm2" => "pm2".into(),
        "systemd" => "systemd".into(),
        other => other.to_string(),
    }
}

/// True when the user asked for every configured destination (`--to all`).
pub fn is_all_destinations(destinations: &[String]) -> bool {
    destinations
        .iter()
        .any(|d| normalize_destination(d) == DESTINATION_ALL)
}

/// Map a destination id to the engine provider string.
pub fn destination_to_provider(destination: &str) -> String {
    match normalize_destination(destination).as_str() {
        "cloudflare" => "cloudflare-containers".into(),
        "kubernetes" => "kubernetes".into(),
        "kubernetes-operator" => "kubernetes-operator".into(),
        "railway" => "railway".into(),
        "oci" => "oci".into(),
        "docker-compose" => "docker-compose".into(),
        "local" => "local".into(),
        "pm2" => "pm2".into(),
        "systemd" => "systemd".into(),
        other => other.to_string(),
    }
}

/// Infer destination label from a stored provider string (config / plan).
pub fn provider_to_destination(provider: &str) -> String {
    normalize_destination(provider)
}

/// Providers that use the Kubernetes adapter (apply / rollout / k8s verify).
pub fn is_kubernetes_provider(provider: &str) -> bool {
    matches!(
        normalize_destination(provider).as_str(),
        "kubernetes" | "kubernetes-operator"
    )
}

/// Providers that deploy via the Cloudflare Worker / Containers workflow.
pub fn is_cloudflare_provider(provider: &str) -> bool {
    normalize_destination(provider) == "cloudflare"
}

/// Local preflight-only provider (commands / scripts; no remote apply).
pub fn is_local_provider(provider: &str) -> bool {
    normalize_destination(provider) == "local"
}

pub fn is_railway_provider(provider: &str) -> bool {
    normalize_destination(provider) == "railway"
}

pub fn is_oci_provider(provider: &str) -> bool {
    normalize_destination(provider) == "oci"
}

/// Canonical priority for stable ordering (lower runs first).
pub fn provider_priority(provider: &str) -> u8 {
    match normalize_destination(provider).as_str() {
        "kubernetes-operator" => 0,
        "kubernetes" => 1,
        "local" => 2,
        "cloudflare" => 3,
        "oci" | "docker-compose" => 4,
        "railway" => 5,
        "pm2" | "systemd" => 6,
        _ => 9,
    }
}

/// Short help list of supported `--to` destinations.
pub fn known_destinations() -> &'static [&'static str] {
    &[
        "all",
        "cloudflare",
        "kubernetes",
        "kubernetes-operator",
        "railway",
        "oci",
        "docker-compose",
        "local",
        "pm2",
        "systemd",
    ]
}

/// Parse CLI `--to` values (comma-separated and/or repeated) into canonical destinations.
///
/// `--to all` is preserved as a single `all` sentinel and expanded per-service
/// against `services[].deploy.destinations`.
pub fn parse_destinations(raw: &[String]) -> Result<Vec<String>, String> {
    let mut out = Vec::new();
    for item in raw {
        for part in item.split(',') {
            let part = part.trim();
            if part.is_empty() {
                continue;
            }
            let dest = normalize_destination(part);
            if dest == DESTINATION_ALL {
                // Explicit all wins over a mixed list.
                return Ok(vec![DESTINATION_ALL.into()]);
            }
            if !out.iter().any(|d| d == &dest) {
                out.push(dest);
            }
        }
    }
    if out.is_empty() {
        return Err(
            "no deploy destinations given (examples: --to all, --to cloudflare,kubernetes, --to kubernetes)"
                .into(),
        );
    }
    Ok(out)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn k8s_classification_unchanged() {
        assert!(is_kubernetes_provider("kubernetes"));
        assert!(is_kubernetes_provider("kubernetes-operator"));
        assert!(is_kubernetes_provider("k8s"));
        assert!(!is_kubernetes_provider("worker"));
        assert!(!is_kubernetes_provider("cloudflare-containers"));
    }

    #[test]
    fn cloudflare_aliases() {
        assert!(is_cloudflare_provider("cloudflare-containers"));
        assert!(is_cloudflare_provider("cf"));
        assert!(is_cloudflare_provider("worker"));
        assert_eq!(normalize_destination("CF"), "cloudflare");
        assert_eq!(destination_to_provider("cloudflare"), "cloudflare-containers");
        assert!(!is_cloudflare_provider("kubernetes"));
    }

    #[test]
    fn operator_still_before_runtime_and_cf() {
        assert!(provider_priority("kubernetes-operator") < provider_priority("kubernetes"));
        assert!(provider_priority("kubernetes") < provider_priority("cloudflare-containers"));
        assert!(provider_priority("kubernetes") < provider_priority("worker"));
        assert!(provider_priority("cloudflare") < provider_priority("railway"));
    }

    #[test]
    fn parse_to_list() {
        let d = parse_destinations(&[
            "cloudflare".into(),
            "k8s,railway".into(),
        ])
        .unwrap();
        assert_eq!(d, vec!["cloudflare", "kubernetes", "railway"]);
    }

    #[test]
    fn parse_to_all() {
        assert_eq!(
            parse_destinations(&["all".into()]).unwrap(),
            vec!["all"]
        );
        assert_eq!(
            parse_destinations(&["both".into()]).unwrap(),
            vec!["all"]
        );
        // Mixed with all → all only
        assert_eq!(
            parse_destinations(&["k8s".into(), "all".into()]).unwrap(),
            vec!["all"]
        );
        assert!(is_all_destinations(&["all".into()]));
        assert!(!is_all_destinations(&["kubernetes".into()]));
    }
}