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
32pub 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}