xbp-deploy 10.57.0

Service-centric declarative deploy engine for XBP.
Documentation
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, HashMap};
use std::path::PathBuf;
use xbp_oci::OciRef;

/// Resolved deploy target kind.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeployTarget {
    Service(String),
    Group(String),
    All,
}

impl DeployTarget {
    pub fn label(&self) -> String {
        match self {
            Self::Service(s) | Self::Group(s) => s.clone(),
            Self::All => "all".into(),
        }
    }

    pub fn kind_str(&self) -> &'static str {
        match self {
            Self::Service(_) => "service",
            Self::Group(_) => "group",
            Self::All => "all",
        }
    }
}

/// Project config view (CLI maps XbpConfig → this). Pure data, no clap.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ProjectConfig {
    pub project_name: String,
    pub version: String,
    pub project_root: PathBuf,
    pub services: Vec<ServiceConfigView>,
    pub groups: HashMap<String, DeployGroupView>,
    pub default_env: Option<String>,
    pub kubernetes: Option<KubernetesDefaults>,
    pub history_dir: PathBuf,
    pub lock_file: PathBuf,
    pub git_sha: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployGroupView {
    pub description: Option<String>,
    pub services: Vec<String>,
    /// Authoritative order when non-empty.
    pub order: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct KubernetesDefaults {
    pub default_context: Option<String>,
    pub default_namespace: Option<String>,
    pub require_context_confirmation_for_apply: bool,
    pub kubeconfig: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceConfigView {
    pub name: String,
    pub root_directory: Option<String>,
    pub version: Option<String>,
    pub depends_on: Vec<String>,
    /// Service-level env map from `services[].environment` (placeholders allowed).
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub environment: std::collections::BTreeMap<String, String>,
    /// Default process port when deploy.envs has no container_port.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub port: Option<u16>,
    pub oci: Option<ServiceOciView>,
    pub deploy: Option<ServiceDeployView>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceOciView {
    pub image: String,
    pub tag: Option<String>,
    pub tag_from: Option<String>,
    pub dockerfile: Option<String>,
    /// Docker build context (relative to service root or project root).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub context: Option<String>,
    pub platforms: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployView {
    /// Default provider when `--to` is omitted **and** `destinations` is empty.
    /// When `destinations` lists multiple targets, the default is every enabled
    /// destination (multi-target), not this single provider alone.
    pub provider: String,
    /// Optional Cloudflare worker app name (`workers[].name`). When unset,
    /// Cloudflare deploy uses the service name.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worker: Option<String>,
    /// Optional containers rollout for Cloudflare (`immediate` | `gradual`).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollout: Option<String>,
    /// Named deploy destinations for this service (cloudflare, kubernetes, railway, …).
    ///
    /// When non-empty, `xbp deploy <svc> --run` (no `--to`) deploys **all enabled**
    /// destinations. Narrow with `--to cloudflare` / `--to kubernetes`, or force
    /// expansion with `--to all`.
    ///
    /// Keys are destination ids; values override provider/worker/rollout.
    #[serde(default, skip_serializing_if = "HashMap::is_empty")]
    pub destinations: HashMap<String, ServiceDeployDestinationView>,
    pub envs: HashMap<String, ServiceDeployEnvView>,
}

/// One named deploy destination under `services[].deploy.destinations.<name>`.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceDeployDestinationView {
    /// Engine provider (defaults to destination name mapping, e.g. cloudflare → cloudflare-containers).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub provider: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worker: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollout: Option<String>,
    /// When false, destination is ignored unless explicitly selected with `--to`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub enabled: Option<bool>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployEnvView {
    pub namespace: Option<String>,
    pub replicas: Option<u32>,
    pub health: Vec<String>,
    pub kubernetes: Option<ServiceK8sView>,
    #[serde(default, skip_serializing_if = "std::collections::BTreeMap::is_empty")]
    pub env: std::collections::BTreeMap<String, String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub env_files: Vec<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub config_files: Vec<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub container_port: Option<u16>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub required_env: Vec<String>,
    /// How the Service is exposed (bootstrap + local routing).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expose: Option<ServiceExposeView>,
}

/// Public / local routing for a bootstrap Service + post-deploy access.
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ServiceExposeView {
    /// Kubernetes Service type: `ClusterIP` (default), `NodePort`, or `LoadBalancer`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub service_type: Option<String>,
    /// Fixed NodePort (30000–32767) when `service_type = NodePort`.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub node_port: Option<u16>,
    /// Optional hostPort on the Pod (useful for docker-desktop single-node).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub host_port: Option<u16>,
    /// Local port for `kubectl port-forward` (default: container_port).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub local_port: Option<u16>,
    /// Start `kubectl port-forward` after successful rollout (default true when local_port set, else false).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub port_forward: Option<bool>,
    /// Hostnames for local DNS (/etc/hosts or Windows hosts) → 127.0.0.1 after port-forward.
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub dns_hosts: Vec<String>,
    /// DNS write mode: `hosts` (default when dns_hosts set), `none`, or `cloudflare` (A record, best-effort).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dns_mode: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceK8sView {
    pub manifests_base: Option<String>,
    pub manifests_overlay: Option<String>,
    pub workload: Option<String>,
    pub service: Option<String>,
    pub crds_path: Option<String>,
    pub install_path: Option<String>,
    pub selector: Option<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DeployPlan {
    pub target: DeployTarget,
    pub env: String,
    pub project: String,
    pub project_version: String,
    pub git_sha: Option<String>,
    pub services: Vec<ServicePlan>,
    /// Final apply order (service names).
    pub order: Vec<String>,
    pub oci_plan: OciPlan,
    pub k8s_plan: K8sPlanView,
    /// Deterministic hash of the plan content (excluding generated_at).
    pub hash: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServicePlan {
    pub name: String,
    pub provider: String,
    /// User-facing destination (`cloudflare`, `kubernetes`, `railway`, …).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub destination: Option<String>,
    /// Service root relative to project (for docker build context).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub root_directory: Option<String>,
    pub version: String,
    pub image: Option<OciRef>,
    pub image_ref: Option<String>,
    pub digest: Option<String>,
    /// When set, runner builds/pushes this Dockerfile before apply (unless skip_build).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub dockerfile: Option<String>,
    /// Docker build context path (relative).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub build_context: Option<String>,
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub platforms: Vec<String>,
    /// Cloudflare worker app when provider is CF-backed.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub worker_app: Option<String>,
    /// Cloudflare containers rollout hint.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub rollout: Option<String>,
    /// Resolved runtime env for bootstrap / apply (secrets redacted in plan print).
    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
    pub runtime_env: BTreeMap<String, String>,
    /// Container listen port used by bootstrap Service/Deployment.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub container_port: Option<u16>,
    /// Config files to mount (absolute source path + container mount path).
    #[serde(default, skip_serializing_if = "Vec::is_empty")]
    pub config_mounts: Vec<ConfigFileMount>,
    /// Public port / local port-forward / DNS after apply.
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub expose: Option<ServiceExposeView>,
    pub deploy: ServiceDeployPlan,
}

/// One file composed into the workload (ConfigMap mount for k8s bootstrap).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigFileMount {
    /// Absolute path on the operator machine.
    pub source: PathBuf,
    /// Path inside the container (e.g. `/app/config.yaml`).
    pub mount_path: String,
    /// ConfigMap key (basename-safe).
    pub key: String,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployPlan {
    pub namespace: Option<String>,
    pub workload: Option<String>,
    /// Kubernetes Service name (defaults to workload name when unset).
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub service: Option<String>,
    pub health: Vec<String>,
    pub manifest_paths: Vec<PathBuf>,
    pub crds_path: Option<PathBuf>,
    pub install_path: Option<PathBuf>,
    pub selector: Option<String>,
    pub actions: Vec<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct OciPlan {
    pub images: BTreeMap<String, OciImagePlan>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OciImagePlan {
    pub ref_str: String,
    pub digest: Option<String>,
}

#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct K8sPlanView {
    pub context: Option<String>,
    pub default_namespace: Option<String>,
    pub services: Vec<xbp_k8s::K8sServicePlan>,
}

impl DeployPlan {
    pub fn render_human(&self) -> String {
        let mut lines = vec![
            format!(
                "project={} version={} env={} target={} ({})",
                self.project,
                self.project_version,
                self.env,
                self.target.label(),
                self.target.kind_str()
            ),
            format!("hash={}", self.hash),
            format!("order: {}", self.order.join("")),
        ];
        if let Some(sha) = &self.git_sha {
            lines.push(format!("git_sha={sha}"));
        }
        for (idx, svc) in self.services.iter().enumerate() {
            let dest = svc
                .destination
                .clone()
                .unwrap_or_else(|| crate::providers::provider_to_destination(&svc.provider));
            lines.push(format!(
                "{}. {}{} [{}] v{}",
                idx + 1,
                svc.name,
                dest,
                svc.provider,
                svc.version
            ));
            if let Some(ns) = &svc.deploy.namespace {
                lines.push(format!("   namespace: {ns}"));
            }
            if let Some(image) = &svc.image_ref {
                if crate::providers::is_cloudflare_provider(&svc.provider) {
                    // OCI image_ref is for k8s/GHCR; CF workers use OpenNext/Wrangler (or
                    // CF container registry) — do not imply a GHCR build/push.
                    lines.push(format!(
                        "   image: {image} (config only — Cloudflare path does not GHCR-push)"
                    ));
                } else {
                    lines.push(format!("   image: {image}"));
                }
            }
            if let Some(d) = &svc.digest {
                lines.push(format!("   digest: {d}"));
            }
            for a in &svc.deploy.actions {
                lines.push(format!("{a}"));
            }
        }
        lines.join("\n")
    }

    pub fn to_k8s_plan(&self) -> xbp_k8s::K8sPlan {
        xbp_k8s::K8sPlan {
            services: self.k8s_plan.services.clone(),
        }
    }
}