Skip to main content

xbp_deploy/
history.rs

1use std::path::{Path, PathBuf};
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::{DeployError, Result};
7use crate::types::DeployPlan;
8
9#[derive(Debug, Clone, Serialize, Deserialize)]
10pub struct HistoryEntry {
11    pub id: String,
12    pub status: String,
13    pub path: String,
14    pub timestamp: DateTime<Utc>,
15    pub env: String,
16    pub target: String,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize, Default)]
20pub struct DeployHistoryIndex {
21    pub entries: Vec<HistoryEntry>,
22}
23
24#[derive(Debug, Clone, Serialize, Deserialize)]
25pub struct DeployHistoryRecord {
26    pub id: String,
27    pub timestamp: DateTime<Utc>,
28    pub env: String,
29    pub target: String,
30    pub target_kind: String,
31    pub status: String,
32    pub services: Vec<String>,
33    pub git_sha: Option<String>,
34    pub project_version: Option<String>,
35    pub digests: std::collections::BTreeMap<String, String>,
36    pub kubernetes_context: Option<String>,
37    pub namespace: Option<String>,
38    pub summary: String,
39    pub error: Option<String>,
40    pub plan: DeployPlan,
41}
42
43pub struct DeployHistoryStore {
44    pub dir: PathBuf,
45}
46
47impl DeployHistoryStore {
48    pub fn new(dir: impl Into<PathBuf>) -> Self {
49        Self { dir: dir.into() }
50    }
51
52    pub fn index_path(&self) -> PathBuf {
53        self.dir.join("index.json")
54    }
55
56    pub fn load_index(&self) -> Result<DeployHistoryIndex> {
57        let path = self.index_path();
58        if !path.exists() {
59            return Ok(DeployHistoryIndex::default());
60        }
61        let raw = std::fs::read_to_string(&path).map_err(|e| DeployError::Io(e.to_string()))?;
62        serde_json::from_str(&raw).map_err(|e| DeployError::Io(e.to_string()))
63    }
64
65    pub fn write_record(&self, record: &DeployHistoryRecord) -> Result<PathBuf> {
66        std::fs::create_dir_all(&self.dir).map_err(|e| DeployError::Io(e.to_string()))?;
67        let file_name = format!("{}.json", record.id);
68        let path = self.dir.join(&file_name);
69        let body =
70            serde_json::to_string_pretty(record).map_err(|e| DeployError::Io(e.to_string()))?;
71        std::fs::write(&path, body).map_err(|e| DeployError::Io(e.to_string()))?;
72
73        let mut index = self.load_index()?;
74        index.entries.retain(|e| e.id != record.id);
75        index.entries.insert(
76            0,
77            HistoryEntry {
78                id: record.id.clone(),
79                status: record.status.clone(),
80                path: file_name,
81                timestamp: record.timestamp,
82                env: record.env.clone(),
83                target: record.target.clone(),
84            },
85        );
86        // Cap index size
87        index.entries.truncate(200);
88        let index_body =
89            serde_json::to_string_pretty(&index).map_err(|e| DeployError::Io(e.to_string()))?;
90        std::fs::write(self.index_path(), index_body)
91            .map_err(|e| DeployError::Io(e.to_string()))?;
92        Ok(path)
93    }
94
95    pub fn latest_status(&self, target: &str, env: &str) -> Result<Option<HistoryEntry>> {
96        let index = self.load_index()?;
97        Ok(index
98            .entries
99            .into_iter()
100            .find(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env)))
101    }
102
103    pub fn list(&self, target: &str, env: &str, limit: usize) -> Result<Vec<HistoryEntry>> {
104        let index = self.load_index()?;
105        Ok(index
106            .entries
107            .into_iter()
108            .filter(|e| (target == "all" || e.target == target) && (env.is_empty() || e.env == env))
109            .take(limit)
110            .collect())
111    }
112}
113
114pub fn make_record_id(env: &str, target: &str, ts: DateTime<Utc>) -> String {
115    let safe_target: String = target
116        .chars()
117        .map(|c| {
118            if c.is_ascii_alphanumeric() || c == '-' || c == '_' {
119                c
120            } else {
121                '_'
122            }
123        })
124        .collect();
125    format!("{}-{}-{}", ts.format("%Y%m%dT%H%M%SZ"), env, safe_target)
126}
127
128pub fn record_from_plan(
129    plan: &DeployPlan,
130    ok: bool,
131    summary: String,
132    error: Option<String>,
133    kubernetes_context: Option<String>,
134) -> DeployHistoryRecord {
135    let ts = Utc::now();
136    let id = make_record_id(&plan.env, &plan.target.label(), ts);
137    let mut digests = std::collections::BTreeMap::new();
138    for svc in &plan.services {
139        if let Some(d) = &svc.digest {
140            digests.insert(svc.name.clone(), d.clone());
141        }
142    }
143    DeployHistoryRecord {
144        id,
145        timestamp: ts,
146        env: plan.env.clone(),
147        target: plan.target.label(),
148        target_kind: plan.target.kind_str().into(),
149        status: if ok { "success".into() } else { "failed".into() },
150        services: plan.order.clone(),
151        git_sha: plan.git_sha.clone(),
152        project_version: Some(plan.project_version.clone()),
153        digests,
154        kubernetes_context,
155        namespace: plan
156            .services
157            .first()
158            .and_then(|s| s.deploy.namespace.clone()),
159        summary,
160        error,
161        plan: plan.clone(),
162    }
163}
164
165/// Path helper for tests.
166#[allow(dead_code)]
167pub fn history_dir(root: &Path, rel: &Path) -> PathBuf {
168    root.join(rel)
169}