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>,
}
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:"));
}
}