Skip to main content

xbp_deploy/
lock.rs

1use std::collections::BTreeMap;
2use std::path::Path;
3
4use chrono::Utc;
5use serde::{Deserialize, Serialize};
6use sha2::{Digest, Sha256};
7
8use crate::error::{DeployError, Result};
9use crate::types::DeployPlan;
10
11#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct DeployLockImage {
13    #[serde(rename = "ref")]
14    pub image_ref: String,
15    pub digest: String,
16}
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
19pub struct DeployLockFile {
20    pub hash: String,
21    pub generated_at: String,
22    pub env: String,
23    pub target: String,
24    #[serde(default, skip_serializing_if = "Option::is_none")]
25    pub git_sha: Option<String>,
26    #[serde(default, skip_serializing_if = "Option::is_none")]
27    pub version: Option<String>,
28    #[serde(default)]
29    pub images: BTreeMap<String, DeployLockImage>,
30}
31
32/// Deterministic hash of plan identity (order, digests, images, env, target).
33pub fn compute_plan_hash(plan: &DeployPlan) -> String {
34    let mut hasher = Sha256::new();
35    hasher.update(plan.env.as_bytes());
36    hasher.update(plan.target.label().as_bytes());
37    hasher.update(plan.project_version.as_bytes());
38    for name in &plan.order {
39        hasher.update(name.as_bytes());
40    }
41    for svc in &plan.services {
42        hasher.update(svc.name.as_bytes());
43        hasher.update(svc.version.as_bytes());
44        if let Some(r) = &svc.image_ref {
45            hasher.update(r.as_bytes());
46        }
47        if let Some(d) = &svc.digest {
48            hasher.update(d.as_bytes());
49        }
50    }
51    format!("sha256:{:x}", hasher.finalize())
52}
53
54pub fn lock_from_plan(plan: &DeployPlan) -> DeployLockFile {
55    let mut images = BTreeMap::new();
56    for svc in &plan.services {
57        if let (Some(image_ref), Some(digest)) = (&svc.image_ref, &svc.digest) {
58            images.insert(
59                svc.name.clone(),
60                DeployLockImage {
61                    image_ref: image_ref.clone(),
62                    digest: digest.clone(),
63                },
64            );
65        }
66    }
67    DeployLockFile {
68        hash: plan.hash.clone(),
69        generated_at: Utc::now().to_rfc3339(),
70        env: plan.env.clone(),
71        target: plan.target.label(),
72        git_sha: plan.git_sha.clone(),
73        version: Some(plan.project_version.clone()),
74        images,
75    }
76}
77
78pub fn write_lock(path: &Path, lock: &DeployLockFile) -> Result<()> {
79    if let Some(parent) = path.parent() {
80        std::fs::create_dir_all(parent).map_err(|e| DeployError::Io(e.to_string()))?;
81    }
82    let body = serde_json::to_string_pretty(lock).map_err(|e| DeployError::Io(e.to_string()))?;
83    std::fs::write(path, body).map_err(|e| DeployError::Io(e.to_string()))?;
84    Ok(())
85}
86
87pub fn load_lock(path: &Path) -> Result<Option<DeployLockFile>> {
88    if !path.exists() {
89        return Ok(None);
90    }
91    let raw = std::fs::read_to_string(path).map_err(|e| DeployError::Io(e.to_string()))?;
92    let lock = serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))?;
93    Ok(Some(lock))
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use crate::types::*;
100
101    #[test]
102    fn hash_stable() {
103        let plan = DeployPlan {
104            target: DeployTarget::Group("athena".into()),
105            env: "production".into(),
106            project: "p".into(),
107            project_version: "1.0.0".into(),
108            git_sha: None,
109            services: vec![],
110            order: vec!["a".into()],
111            oci_plan: OciPlan::default(),
112            k8s_plan: K8sPlanView::default(),
113            hash: String::new(),
114        };
115        let h1 = compute_plan_hash(&plan);
116        let h2 = compute_plan_hash(&plan);
117        assert_eq!(h1, h2);
118        assert!(h1.starts_with("sha256:"));
119    }
120}