xbp-deploy 10.43.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>,
    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>,
    pub platforms: Vec<String>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployView {
    pub provider: String,
    pub envs: HashMap<String, ServiceDeployEnvView>,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployEnvView {
    pub namespace: Option<String>,
    pub replicas: Option<u32>,
    pub health: Vec<String>,
    pub kubernetes: Option<ServiceK8sView>,
}

#[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,
    pub version: String,
    pub image: Option<OciRef>,
    pub image_ref: Option<String>,
    pub digest: Option<String>,
    pub deploy: ServiceDeployPlan,
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ServiceDeployPlan {
    pub namespace: Option<String>,
    pub workload: 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() {
            lines.push(format!(
                "{}. {} [{}] v{}",
                idx + 1,
                svc.name,
                svc.provider,
                svc.version
            ));
            if let Some(ns) = &svc.deploy.namespace {
                lines.push(format!("   namespace: {ns}"));
            }
            if let Some(image) = &svc.image_ref {
                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(),
        }
    }
}