xbp-deploy 10.43.0

Service-centric declarative deploy engine for XBP.
Documentation
use std::collections::BTreeMap;
use std::path::Path;

use chrono::Utc;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};

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

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct DeployLockImage {
    #[serde(rename = "ref")]
    pub image_ref: String,
    pub digest: String,
}

#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
pub struct DeployLockFile {
    pub hash: String,
    pub generated_at: String,
    pub env: String,
    pub target: String,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub git_sha: Option<String>,
    #[serde(default, skip_serializing_if = "Option::is_none")]
    pub version: Option<String>,
    #[serde(default)]
    pub images: BTreeMap<String, DeployLockImage>,
}

/// Deterministic hash of plan identity (order, digests, images, env, target).
pub fn compute_plan_hash(plan: &DeployPlan) -> String {
    let mut hasher = Sha256::new();
    hasher.update(plan.env.as_bytes());
    hasher.update(plan.target.label().as_bytes());
    hasher.update(plan.project_version.as_bytes());
    for name in &plan.order {
        hasher.update(name.as_bytes());
    }
    for svc in &plan.services {
        hasher.update(svc.name.as_bytes());
        hasher.update(svc.version.as_bytes());
        if let Some(r) = &svc.image_ref {
            hasher.update(r.as_bytes());
        }
        if let Some(d) = &svc.digest {
            hasher.update(d.as_bytes());
        }
    }
    format!("sha256:{:x}", hasher.finalize())
}

pub fn lock_from_plan(plan: &DeployPlan) -> DeployLockFile {
    let mut images = BTreeMap::new();
    for svc in &plan.services {
        if let (Some(image_ref), Some(digest)) = (&svc.image_ref, &svc.digest) {
            images.insert(
                svc.name.clone(),
                DeployLockImage {
                    image_ref: image_ref.clone(),
                    digest: digest.clone(),
                },
            );
        }
    }
    DeployLockFile {
        hash: plan.hash.clone(),
        generated_at: Utc::now().to_rfc3339(),
        env: plan.env.clone(),
        target: plan.target.label(),
        git_sha: plan.git_sha.clone(),
        version: Some(plan.project_version.clone()),
        images,
    }
}

pub fn write_lock(path: &Path, lock: &DeployLockFile) -> Result<()> {
    if let Some(parent) = path.parent() {
        std::fs::create_dir_all(parent).map_err(|e| DeployError::Io(e.to_string()))?;
    }
    let body = serde_json::to_string_pretty(lock).map_err(|e| DeployError::Io(e.to_string()))?;
    std::fs::write(path, body).map_err(|e| DeployError::Io(e.to_string()))?;
    Ok(())
}

pub fn load_lock(path: &Path) -> Result<Option<DeployLockFile>> {
    if !path.exists() {
        return Ok(None);
    }
    let raw = std::fs::read_to_string(path).map_err(|e| DeployError::Io(e.to_string()))?;
    let lock = serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
    Ok(Some(lock))
}

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

    #[test]
    fn hash_stable() {
        let plan = DeployPlan {
            target: DeployTarget::Group("athena".into()),
            env: "production".into(),
            project: "p".into(),
            project_version: "1.0.0".into(),
            git_sha: None,
            services: vec![],
            order: vec!["a".into()],
            oci_plan: OciPlan::default(),
            k8s_plan: K8sPlanView::default(),
            hash: String::new(),
        };
        let h1 = compute_plan_hash(&plan);
        let h2 = compute_plan_hash(&plan);
        assert_eq!(h1, h2);
        assert!(h1.starts_with("sha256:"));
    }
}