1use crate::store_io::{
14 atomic_write_json, read_json_bounded, remove_if_exists, JsonReadError,
15 MAX_UPDATE_METADATA_BYTES,
16};
17use crate::{ArtifactDescriptor, UpdateError, UpdateResult};
18use serde::{Deserialize, Serialize};
19use std::fs;
20use std::path::{Path, PathBuf};
21
22pub const ACTIVATION_V2_FORMAT_VERSION: u16 = 2;
24const MAX_ATTEMPT_ID_BYTES: usize = 128;
25const MAX_REASON_BYTES: usize = 512;
26const MAX_HOST_BINDING_BYTES: usize = 512;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
30#[serde(rename_all = "snake_case")]
31pub enum ActivationPhaseV2 {
32 Prepared,
34 ActivatedPendingHealth,
36 Committed,
38 RollbackRequired,
40 RolledBack,
42 Aborted,
44 ManualReviewRequired,
46}
47
48#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50pub struct ActivationReceiptV2 {
51 pub format_version: u16,
53 pub attempt_id: String,
55 pub phase: ActivationPhaseV2,
57 pub activated: ArtifactDescriptor,
59 pub previous: Option<ArtifactDescriptor>,
61 pub created_at_ms: u64,
63 pub updated_at_ms: u64,
65 pub host_binding: Option<String>,
67 pub failure_reason: Option<String>,
69}
70
71impl ActivationReceiptV2 {
72 pub fn validate(&self) -> UpdateResult<()> {
74 if self.format_version != ACTIVATION_V2_FORMAT_VERSION {
75 return Err(UpdateError::Recovery(
76 "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
77 ));
78 }
79 validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
80 if self.updated_at_ms < self.created_at_ms {
81 return Err(UpdateError::Recovery(
82 "receipt transition timestamp precedes creation".to_string(),
83 ));
84 }
85 self.activated.validate()?;
86 if let Some(previous) = &self.previous {
87 previous.validate()?;
88 }
89 if let Some(binding) = &self.host_binding {
90 validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
91 }
92 if let Some(reason) = &self.failure_reason {
93 validate_text("failure_reason", reason, MAX_REASON_BYTES)?;
94 }
95 Ok(())
96 }
97}
98
99#[derive(Debug, Clone, PartialEq, Eq)]
101pub enum RecoveryDecision {
102 Idle,
104 PendingActivation {
106 receipt: ActivationReceiptV2,
108 },
109 Committed {
111 receipt: ActivationReceiptV2,
113 },
114 RollbackRequired {
116 receipt: ActivationReceiptV2,
118 },
119 RolledBack {
121 receipt: ActivationReceiptV2,
123 },
124 Aborted {
126 receipt: ActivationReceiptV2,
128 },
129 ManualReviewRequired {
131 reason: String,
133 },
134}
135
136#[derive(Debug, Clone, PartialEq, Eq)]
138pub enum RecoveryAction {
139 ConfirmHealthy {
141 attempt_id: String,
143 activated_sha256: String,
145 at_ms: u64,
147 },
148 RequireRollback {
150 attempt_id: String,
152 reason: String,
154 at_ms: u64,
156 },
157 RecordRolledBack {
159 attempt_id: String,
161 active_sha256: Option<String>,
163 at_ms: u64,
165 },
166 DiscardPrepared {
168 attempt_id: String,
170 at_ms: u64,
172 },
173 MarkManualReview {
175 attempt_id: String,
177 reason: String,
179 at_ms: u64,
181 },
182}
183
184#[derive(Debug, Clone, PartialEq, Eq)]
186pub struct RecoveryResult {
187 pub phase: ActivationPhaseV2,
189 pub idempotent: bool,
191}
192
193#[derive(Debug, Clone)]
195pub struct FileRecoveryStore {
196 root: PathBuf,
197}
198
199impl FileRecoveryStore {
200 pub fn open(root: impl Into<PathBuf>) -> UpdateResult<Self> {
202 let store = Self { root: root.into() };
203 fs::create_dir_all(&store.root).map_err(store_error)?;
204 reject_directory(&store.root)?;
205 Ok(store)
206 }
207
208 pub fn prepare(&self, receipt: ActivationReceiptV2) -> UpdateResult<()> {
210 receipt.validate()?;
211 if receipt.phase != ActivationPhaseV2::Prepared {
212 return Err(UpdateError::Recovery(
213 "new V2 receipts must start in prepared phase".to_string(),
214 ));
215 }
216 if self.read_receipt()?.is_some() {
217 return Err(UpdateError::Recovery(
218 "another V2 activation attempt is already pending".to_string(),
219 ));
220 }
221 atomic_write_json(&self.receipt_path(), &receipt)
222 }
223
224 pub fn mark_activated(&self, attempt_id: &str, at_ms: u64) -> UpdateResult<RecoveryResult> {
226 let receipt = self
227 .read_receipt()?
228 .ok_or_else(|| UpdateError::Recovery("recovery journal is idle".to_string()))?;
229 if receipt.attempt_id != attempt_id {
230 return Err(UpdateError::Recovery(
231 "recovery action belongs to another attempt".to_string(),
232 ));
233 }
234 if receipt.phase == ActivationPhaseV2::ActivatedPendingHealth {
235 return Ok(RecoveryResult {
236 phase: receipt.phase,
237 idempotent: true,
238 });
239 }
240 if receipt.phase != ActivationPhaseV2::Prepared {
241 return Err(UpdateError::Recovery(
242 "activation evidence conflicts with the persisted phase".to_string(),
243 ));
244 }
245 self.write_phase(receipt, ActivationPhaseV2::ActivatedPendingHealth, at_ms)
246 }
247
248 pub fn inspect_recovery(&self) -> UpdateResult<RecoveryDecision> {
250 let Some(receipt) = self.read_receipt()? else {
251 return Ok(RecoveryDecision::Idle);
252 };
253 match receipt.phase {
254 ActivationPhaseV2::Prepared | ActivationPhaseV2::ActivatedPendingHealth => {
255 Ok(RecoveryDecision::PendingActivation { receipt })
256 }
257 ActivationPhaseV2::Committed => Ok(RecoveryDecision::Committed { receipt }),
258 ActivationPhaseV2::RollbackRequired => {
259 Ok(RecoveryDecision::RollbackRequired { receipt })
260 }
261 ActivationPhaseV2::RolledBack => Ok(RecoveryDecision::RolledBack { receipt }),
262 ActivationPhaseV2::Aborted => Ok(RecoveryDecision::Aborted { receipt }),
263 ActivationPhaseV2::ManualReviewRequired => Ok(RecoveryDecision::ManualReviewRequired {
264 reason: receipt
265 .failure_reason
266 .unwrap_or_else(|| "manual review required".to_string()),
267 }),
268 }
269 }
270
271 pub fn replay(&self, action: RecoveryAction) -> UpdateResult<RecoveryResult> {
273 let Some(receipt) = self.read_receipt()? else {
274 return Err(UpdateError::Recovery(
275 "recovery journal is idle".to_string(),
276 ));
277 };
278 let (attempt_id, at_ms) = action_identity(&action);
279 if attempt_id != receipt.attempt_id {
280 return Err(UpdateError::Recovery(
281 "recovery action belongs to another attempt".to_string(),
282 ));
283 }
284 let previous = receipt.phase;
285 let receipt = apply_action(receipt, action)?;
286 let phase = receipt.phase;
287 let idempotent = previous == phase;
288 self.write_receipt(receipt, at_ms)?;
289 Ok(RecoveryResult { phase, idempotent })
290 }
291
292 pub fn clear(&self) -> UpdateResult<()> {
294 remove_if_exists(&self.receipt_path())
295 }
296
297 fn write_phase(
298 &self,
299 mut receipt: ActivationReceiptV2,
300 phase: ActivationPhaseV2,
301 at_ms: u64,
302 ) -> UpdateResult<RecoveryResult> {
303 let previous = receipt.phase;
304 receipt.phase = phase;
305 self.write_receipt(receipt, at_ms)?;
306 Ok(RecoveryResult {
307 phase,
308 idempotent: previous == phase,
309 })
310 }
311
312 fn write_receipt(&self, mut receipt: ActivationReceiptV2, at_ms: u64) -> UpdateResult<()> {
313 receipt.updated_at_ms = at_ms;
314 receipt.validate()?;
315 atomic_write_json(&self.receipt_path(), &receipt)
316 }
317
318 fn read_receipt(&self) -> UpdateResult<Option<ActivationReceiptV2>> {
319 match read_json_bounded::<ActivationReceiptV2>(
320 &self.receipt_path(),
321 MAX_UPDATE_METADATA_BYTES,
322 ) {
323 Ok(receipt) => {
324 if let Some(receipt) = &receipt {
325 receipt.validate()?;
326 }
327 Ok(receipt)
328 }
329 Err(JsonReadError::Io(error)) => Err(store_error(error)),
330 Err(JsonReadError::Decode(_)) => Err(UpdateError::Recovery(
331 "NO MORE SUPPORTED PLEASE UPDATE".to_string(),
332 )),
333 }
334 }
335
336 fn receipt_path(&self) -> PathBuf {
337 self.root.join("activation-v2.json")
338 }
339}
340
341fn apply_action(
342 mut receipt: ActivationReceiptV2,
343 action: RecoveryAction,
344) -> UpdateResult<ActivationReceiptV2> {
345 match action {
346 RecoveryAction::ConfirmHealthy {
347 activated_sha256, ..
348 } => {
349 if receipt.phase == ActivationPhaseV2::Committed {
350 if receipt.activated.sha256() != activated_sha256 {
351 return conflict();
352 }
353 return Ok(receipt);
354 }
355 if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth
356 || receipt.activated.sha256() != activated_sha256
357 {
358 return conflict();
359 }
360 receipt.phase = ActivationPhaseV2::Committed;
361 receipt.failure_reason = None;
362 }
363 RecoveryAction::RequireRollback { reason, .. } => {
364 validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
365 if receipt.phase == ActivationPhaseV2::RollbackRequired {
366 if receipt.failure_reason.as_deref() != Some(reason.as_str()) {
367 return conflict();
368 }
369 return Ok(receipt);
370 }
371 if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth {
372 return conflict();
373 }
374 receipt.phase = ActivationPhaseV2::RollbackRequired;
375 receipt.failure_reason = Some(reason);
376 }
377 RecoveryAction::RecordRolledBack { active_sha256, .. } => {
378 if receipt.phase == ActivationPhaseV2::RolledBack {
379 if receipt.previous.as_ref().map(|previous| previous.sha256())
380 != active_sha256.as_deref()
381 {
382 return conflict();
383 }
384 return Ok(receipt);
385 }
386 if receipt.phase != ActivationPhaseV2::RollbackRequired {
387 return conflict();
388 }
389 match (&receipt.previous, active_sha256) {
390 (None, None) => {}
391 (Some(previous), Some(active)) if previous.sha256() == active => {}
392 _ => return conflict(),
393 }
394 receipt.phase = ActivationPhaseV2::RolledBack;
395 }
396 RecoveryAction::DiscardPrepared { .. } => {
397 if receipt.phase != ActivationPhaseV2::Prepared {
398 return conflict();
399 }
400 receipt.phase = ActivationPhaseV2::Aborted;
401 }
402 RecoveryAction::MarkManualReview { reason, .. } => {
403 validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
404 if receipt.phase == ActivationPhaseV2::ManualReviewRequired {
405 return Ok(receipt);
406 }
407 receipt.phase = ActivationPhaseV2::ManualReviewRequired;
408 receipt.failure_reason = Some(reason);
409 }
410 }
411 Ok(receipt)
412}
413
414fn action_identity(action: &RecoveryAction) -> (&str, u64) {
415 match action {
416 RecoveryAction::ConfirmHealthy {
417 attempt_id, at_ms, ..
418 }
419 | RecoveryAction::RequireRollback {
420 attempt_id, at_ms, ..
421 }
422 | RecoveryAction::RecordRolledBack {
423 attempt_id, at_ms, ..
424 }
425 | RecoveryAction::DiscardPrepared { attempt_id, at_ms }
426 | RecoveryAction::MarkManualReview {
427 attempt_id, at_ms, ..
428 } => (attempt_id, *at_ms),
429 }
430}
431
432fn validate_text(name: &str, value: &str, max_bytes: usize) -> UpdateResult<()> {
433 if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
434 return Err(UpdateError::Recovery(format!("{name} is invalid")));
435 }
436 Ok(())
437}
438
439fn conflict<T>() -> UpdateResult<T> {
440 Err(UpdateError::Recovery(
441 "recovery action conflicts with the persisted phase or evidence".to_string(),
442 ))
443}
444
445fn reject_directory(path: &Path) -> UpdateResult<()> {
446 let metadata = fs::symlink_metadata(path).map_err(store_error)?;
447 if metadata.file_type().is_symlink() || !metadata.is_dir() {
448 return Err(UpdateError::Store(
449 "recovery root is not a regular directory".to_string(),
450 ));
451 }
452 Ok(())
453}
454
455fn store_error(error: std::io::Error) -> UpdateError {
456 UpdateError::Store(error.to_string())
457}