shipper_core/state/execution_state/
mod.rs1use std::fs;
18use std::io::Write;
19use std::path::{Path, PathBuf};
20
21use anyhow::{Context, Result};
22
23use crate::runtime::environment::collect_environment_fingerprint;
24use shipper_types::{ExecutionState, Receipt, ReconciliationReport};
25
26#[cfg(test)]
27mod tests;
28
29pub const CURRENT_RECEIPT_VERSION: &str = "shipper.receipt.v2";
31
32pub const MINIMUM_SUPPORTED_VERSION: &str = "shipper.receipt.v1";
34
35pub const CURRENT_STATE_VERSION: &str = "shipper.state.v1";
37
38pub const CURRENT_PLAN_VERSION: &str = "shipper.plan.v1";
40
41pub const STATE_FILE: &str = "state.json";
42pub const RECEIPT_FILE: &str = "receipt.json";
43pub const RECONCILIATION_FILE: &str = "reconciliation.json";
44pub const REMEDIATION_PLAN_FILE: &str = "remediation-plan.json";
45
46pub fn state_path(state_dir: &Path) -> PathBuf {
47 state_dir.join(STATE_FILE)
48}
49
50pub fn receipt_path(state_dir: &Path) -> PathBuf {
51 state_dir.join(RECEIPT_FILE)
52}
53
54pub fn reconciliation_path(state_dir: &Path) -> PathBuf {
55 state_dir.join(RECONCILIATION_FILE)
56}
57
58pub fn remediation_plan_path(state_dir: &Path) -> PathBuf {
59 state_dir.join(REMEDIATION_PLAN_FILE)
60}
61
62pub fn load_state(state_dir: &Path) -> Result<Option<ExecutionState>> {
63 let path = state_path(state_dir);
64 if !path.exists() {
65 return Ok(None);
66 }
67 let content = fs::read_to_string(&path)
68 .with_context(|| format!("failed to read state file {}", path.display()))?;
69 let st: ExecutionState = serde_json::from_str(&content)
70 .with_context(|| format!("failed to parse state JSON {}", path.display()))?;
71 Ok(Some(st))
72}
73
74pub fn save_state(state_dir: &Path, state: &ExecutionState) -> Result<()> {
75 fs::create_dir_all(state_dir)
76 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
77
78 let path = state_path(state_dir);
79 atomic_write_json(&path, state)
80}
81
82pub fn write_receipt(state_dir: &Path, receipt: &Receipt) -> Result<()> {
83 fs::create_dir_all(state_dir)
84 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
85
86 let path = receipt_path(state_dir);
87 atomic_write_json(&path, receipt)
88}
89
90pub fn write_reconciliation_report(state_dir: &Path, report: &ReconciliationReport) -> Result<()> {
91 fs::create_dir_all(state_dir)
92 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
93
94 let path = reconciliation_path(state_dir);
95 atomic_write_json(&path, report)
96}
97
98pub fn clear_state(state_dir: &Path) -> Result<()> {
100 let path = state_path(state_dir);
101 if path.exists() {
102 fs::remove_file(&path)
103 .with_context(|| format!("failed to remove state file {}", path.display()))?;
104 }
105 Ok(())
106}
107
108pub fn has_incomplete_state(state_dir: &Path) -> bool {
110 state_path(state_dir).exists() && !receipt_path(state_dir).exists()
111}
112
113pub fn load_state_encrypted(
115 state_dir: &Path,
116 encrypt_config: &shipper_encrypt::EncryptionConfig,
117) -> Result<Option<ExecutionState>> {
118 let path = state_path(state_dir);
119 if !path.exists() {
120 return Ok(None);
121 }
122
123 let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
124 let content = encryption.read_file(&path)?;
125
126 let st: ExecutionState = serde_json::from_str(&content)
127 .with_context(|| format!("failed to parse state JSON {}", path.display()))?;
128 Ok(Some(st))
129}
130
131pub fn save_state_encrypted(
133 state_dir: &Path,
134 state: &ExecutionState,
135 encrypt_config: &shipper_encrypt::EncryptionConfig,
136) -> Result<()> {
137 fs::create_dir_all(state_dir)
138 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
139
140 let path = state_path(state_dir);
141
142 let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
143 let data = serde_json::to_vec_pretty(state).context("failed to serialize state JSON")?;
144 encryption.write_file(&path, &data)
145}
146
147pub fn write_receipt_encrypted(
149 state_dir: &Path,
150 receipt: &Receipt,
151 encrypt_config: &shipper_encrypt::EncryptionConfig,
152) -> Result<()> {
153 fs::create_dir_all(state_dir)
154 .with_context(|| format!("failed to create state dir {}", state_dir.display()))?;
155
156 let path = receipt_path(state_dir);
157
158 let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
159 let data = serde_json::to_vec_pretty(receipt).context("failed to serialize receipt JSON")?;
160 encryption.write_file(&path, &data)
161}
162
163pub fn load_receipt_encrypted(
165 state_dir: &Path,
166 encrypt_config: &shipper_encrypt::EncryptionConfig,
167) -> Result<Option<Receipt>> {
168 let path = receipt_path(state_dir);
169 if !path.exists() {
170 return Ok(None);
171 }
172
173 let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
174 let content = encryption.read_file(&path)?;
175
176 if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
178 if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
180 return migrate_receipt_encrypted(&path, encrypt_config).map(Some);
183 }
184 return Ok(Some(receipt));
185 }
186
187 migrate_receipt_encrypted(&path, encrypt_config).map(Some)
189}
190
191fn migrate_receipt_encrypted(
193 path: &Path,
194 encrypt_config: &shipper_encrypt::EncryptionConfig,
195) -> Result<Receipt> {
196 let encryption = shipper_encrypt::StateEncryption::new(encrypt_config.clone())?;
197 let content = encryption.read_file(path)?;
198
199 let value: serde_json::Value = serde_json::from_str(&content)
200 .with_context(|| format!("failed to parse receipt JSON {}", path.display()))?;
201
202 let receipt_version = value
203 .get("receipt_version")
204 .and_then(|v| v.as_str())
205 .unwrap_or("shipper.receipt.v1")
206 .to_string();
207
208 validate_receipt_version(&receipt_version)?;
209
210 let receipt = match receipt_version.as_str() {
211 "shipper.receipt.v1" => migrate_v1_to_v2(value)?,
212 "shipper.receipt.v2" => serde_json::from_value(value)
213 .with_context(|| format!("failed to deserialize receipt v2 from {}", path.display()))?,
214 _ => serde_json::from_value(value).with_context(|| {
215 format!(
216 "failed to deserialize receipt with unknown version {} from {}",
217 receipt_version,
218 path.display()
219 )
220 })?,
221 };
222
223 Ok(receipt)
224}
225
226pub fn validate_receipt_version(version: &str) -> Result<()> {
228 shipper_types::schema::validate_schema_version(version, MINIMUM_SUPPORTED_VERSION, "receipt")
229}
230
231pub fn migrate_receipt(path: &Path) -> Result<Receipt> {
233 let content = fs::read_to_string(path)
235 .with_context(|| format!("failed to read receipt file {}", path.display()))?;
236
237 let value: serde_json::Value = serde_json::from_str(&content)
238 .with_context(|| format!("failed to parse receipt JSON {}", path.display()))?;
239
240 let receipt_version = value
242 .get("receipt_version")
243 .and_then(|v| v.as_str())
244 .unwrap_or("shipper.receipt.v1") .to_string(); validate_receipt_version(&receipt_version)?;
249
250 let receipt = match receipt_version.as_str() {
252 "shipper.receipt.v1" => migrate_v1_to_v2(value)?,
253 "shipper.receipt.v2" => serde_json::from_value(value)
254 .with_context(|| format!("failed to deserialize receipt v2 from {}", path.display()))?,
255 _ => {
256 serde_json::from_value(value).with_context(|| {
258 format!(
259 "failed to deserialize receipt with unknown version {} from {}",
260 receipt_version,
261 path.display()
262 )
263 })?
264 }
265 };
266
267 Ok(receipt)
268}
269
270fn migrate_v1_to_v2(mut receipt: serde_json::Value) -> Result<Receipt> {
272 if receipt.get("git_context").is_none() {
274 receipt["git_context"] = serde_json::Value::Null;
275 }
276
277 if receipt.get("environment").is_none() {
279 let environment = collect_environment_fingerprint();
280 receipt["environment"] = serde_json::to_value(environment)
281 .context("failed to serialize environment fingerprint")?;
282 }
283
284 receipt["receipt_version"] = serde_json::Value::String(CURRENT_RECEIPT_VERSION.to_string());
286
287 serde_json::from_value(receipt).context("failed to deserialize migrated receipt")
289}
290
291pub fn load_receipt(state_dir: &Path) -> Result<Option<Receipt>> {
293 let path = receipt_path(state_dir);
294 if !path.exists() {
295 return Ok(None);
296 }
297
298 let content = fs::read_to_string(&path)
300 .with_context(|| format!("failed to read receipt file {}", path.display()))?;
301
302 if let Ok(receipt) = serde_json::from_str::<Receipt>(&content) {
304 if let Err(_e) = validate_receipt_version(&receipt.receipt_version) {
306 return migrate_receipt(&path).map(Some);
308 }
309 return Ok(Some(receipt));
310 }
311
312 migrate_receipt(&path).map(Some)
314}
315
316pub fn fsync_parent_dir(path: &Path) {
320 if let Some(parent) = path.parent()
321 && let Ok(dir) = fs::File::open(parent)
322 {
323 let _ = dir.sync_all();
324 }
325}
326
327pub(crate) fn atomic_write_json<T: serde::Serialize>(path: &Path, value: &T) -> Result<()> {
328 let tmp = path.with_extension("tmp");
329 let data = serde_json::to_vec_pretty(value).context("failed to serialize JSON")?;
330
331 {
332 let mut f = fs::File::create(&tmp)
333 .with_context(|| format!("failed to create tmp file {}", tmp.display()))?;
334 f.write_all(&data)
335 .with_context(|| format!("failed to write tmp file {}", tmp.display()))?;
336 f.sync_all().ok();
337 }
338
339 fs::rename(&tmp, path).with_context(|| {
340 format!(
341 "failed to rename tmp file {} to {}",
342 tmp.display(),
343 path.display()
344 )
345 })?;
346
347 fsync_parent_dir(path);
348
349 Ok(())
350}