use crate::store_io::{
atomic_write_json, read_json_bounded, remove_if_exists, JsonReadError,
MAX_UPDATE_METADATA_BYTES,
};
use crate::{ArtifactDescriptor, UpdateError, UpdateResult};
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::{Path, PathBuf};
pub const ACTIVATION_V2_FORMAT_VERSION: u16 = 2;
const MAX_ATTEMPT_ID_BYTES: usize = 128;
const MAX_REASON_BYTES: usize = 512;
const MAX_HOST_BINDING_BYTES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ActivationPhaseV2 {
Prepared,
ActivatedPendingHealth,
Committed,
RollbackRequired,
RolledBack,
Aborted,
ManualReviewRequired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ActivationReceiptV2 {
pub format_version: u16,
pub attempt_id: String,
pub phase: ActivationPhaseV2,
pub activated: ArtifactDescriptor,
pub previous: Option<ArtifactDescriptor>,
pub created_at_ms: u64,
pub updated_at_ms: u64,
pub host_binding: Option<String>,
pub failure_reason: Option<String>,
}
impl ActivationReceiptV2 {
pub fn validate(&self) -> UpdateResult<()> {
if self.format_version != ACTIVATION_V2_FORMAT_VERSION {
return Err(UpdateError::Recovery(
"NO MORE SUPPORTED PLEASE UPDATE".to_string(),
));
}
validate_text("attempt_id", &self.attempt_id, MAX_ATTEMPT_ID_BYTES)?;
if self.updated_at_ms < self.created_at_ms {
return Err(UpdateError::Recovery(
"receipt transition timestamp precedes creation".to_string(),
));
}
self.activated.validate()?;
if let Some(previous) = &self.previous {
previous.validate()?;
}
if let Some(binding) = &self.host_binding {
validate_text("host_binding", binding, MAX_HOST_BINDING_BYTES)?;
}
if let Some(reason) = &self.failure_reason {
validate_text("failure_reason", reason, MAX_REASON_BYTES)?;
}
Ok(())
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecoveryDecision {
Idle,
PendingActivation {
receipt: ActivationReceiptV2,
},
Committed {
receipt: ActivationReceiptV2,
},
RollbackRequired {
receipt: ActivationReceiptV2,
},
RolledBack {
receipt: ActivationReceiptV2,
},
Aborted {
receipt: ActivationReceiptV2,
},
ManualReviewRequired {
reason: String,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecoveryAction {
ConfirmHealthy {
attempt_id: String,
activated_sha256: String,
at_ms: u64,
},
RequireRollback {
attempt_id: String,
reason: String,
at_ms: u64,
},
RecordRolledBack {
attempt_id: String,
active_sha256: Option<String>,
at_ms: u64,
},
DiscardPrepared {
attempt_id: String,
at_ms: u64,
},
MarkManualReview {
attempt_id: String,
reason: String,
at_ms: u64,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct RecoveryResult {
pub phase: ActivationPhaseV2,
pub idempotent: bool,
}
#[derive(Debug, Clone)]
pub struct FileRecoveryStore {
root: PathBuf,
}
impl FileRecoveryStore {
pub fn open(root: impl Into<PathBuf>) -> UpdateResult<Self> {
let store = Self { root: root.into() };
fs::create_dir_all(&store.root).map_err(store_error)?;
reject_directory(&store.root)?;
Ok(store)
}
pub fn prepare(&self, receipt: ActivationReceiptV2) -> UpdateResult<()> {
receipt.validate()?;
if receipt.phase != ActivationPhaseV2::Prepared {
return Err(UpdateError::Recovery(
"new V2 receipts must start in prepared phase".to_string(),
));
}
if self.read_receipt()?.is_some() {
return Err(UpdateError::Recovery(
"another V2 activation attempt is already pending".to_string(),
));
}
atomic_write_json(&self.receipt_path(), &receipt)
}
pub fn mark_activated(&self, attempt_id: &str, at_ms: u64) -> UpdateResult<RecoveryResult> {
let receipt = self
.read_receipt()?
.ok_or_else(|| UpdateError::Recovery("recovery journal is idle".to_string()))?;
if receipt.attempt_id != attempt_id {
return Err(UpdateError::Recovery(
"recovery action belongs to another attempt".to_string(),
));
}
if receipt.phase == ActivationPhaseV2::ActivatedPendingHealth {
return Ok(RecoveryResult {
phase: receipt.phase,
idempotent: true,
});
}
if receipt.phase != ActivationPhaseV2::Prepared {
return Err(UpdateError::Recovery(
"activation evidence conflicts with the persisted phase".to_string(),
));
}
self.write_phase(receipt, ActivationPhaseV2::ActivatedPendingHealth, at_ms)
}
pub fn inspect_recovery(&self) -> UpdateResult<RecoveryDecision> {
let Some(receipt) = self.read_receipt()? else {
return Ok(RecoveryDecision::Idle);
};
match receipt.phase {
ActivationPhaseV2::Prepared | ActivationPhaseV2::ActivatedPendingHealth => {
Ok(RecoveryDecision::PendingActivation { receipt })
}
ActivationPhaseV2::Committed => Ok(RecoveryDecision::Committed { receipt }),
ActivationPhaseV2::RollbackRequired => {
Ok(RecoveryDecision::RollbackRequired { receipt })
}
ActivationPhaseV2::RolledBack => Ok(RecoveryDecision::RolledBack { receipt }),
ActivationPhaseV2::Aborted => Ok(RecoveryDecision::Aborted { receipt }),
ActivationPhaseV2::ManualReviewRequired => Ok(RecoveryDecision::ManualReviewRequired {
reason: receipt
.failure_reason
.unwrap_or_else(|| "manual review required".to_string()),
}),
}
}
pub fn replay(&self, action: RecoveryAction) -> UpdateResult<RecoveryResult> {
let Some(receipt) = self.read_receipt()? else {
return Err(UpdateError::Recovery(
"recovery journal is idle".to_string(),
));
};
let (attempt_id, at_ms) = action_identity(&action);
if attempt_id != receipt.attempt_id {
return Err(UpdateError::Recovery(
"recovery action belongs to another attempt".to_string(),
));
}
let previous = receipt.phase;
let receipt = apply_action(receipt, action)?;
let phase = receipt.phase;
let idempotent = previous == phase;
self.write_receipt(receipt, at_ms)?;
Ok(RecoveryResult { phase, idempotent })
}
pub fn clear(&self) -> UpdateResult<()> {
remove_if_exists(&self.receipt_path())
}
fn write_phase(
&self,
mut receipt: ActivationReceiptV2,
phase: ActivationPhaseV2,
at_ms: u64,
) -> UpdateResult<RecoveryResult> {
let previous = receipt.phase;
receipt.phase = phase;
self.write_receipt(receipt, at_ms)?;
Ok(RecoveryResult {
phase,
idempotent: previous == phase,
})
}
fn write_receipt(&self, mut receipt: ActivationReceiptV2, at_ms: u64) -> UpdateResult<()> {
receipt.updated_at_ms = at_ms;
receipt.validate()?;
atomic_write_json(&self.receipt_path(), &receipt)
}
fn read_receipt(&self) -> UpdateResult<Option<ActivationReceiptV2>> {
match read_json_bounded::<ActivationReceiptV2>(
&self.receipt_path(),
MAX_UPDATE_METADATA_BYTES,
) {
Ok(receipt) => {
if let Some(receipt) = &receipt {
receipt.validate()?;
}
Ok(receipt)
}
Err(JsonReadError::Io(error)) => Err(store_error(error)),
Err(JsonReadError::Decode(_)) => Err(UpdateError::Recovery(
"NO MORE SUPPORTED PLEASE UPDATE".to_string(),
)),
}
}
fn receipt_path(&self) -> PathBuf {
self.root.join("activation-v2.json")
}
}
fn apply_action(
mut receipt: ActivationReceiptV2,
action: RecoveryAction,
) -> UpdateResult<ActivationReceiptV2> {
match action {
RecoveryAction::ConfirmHealthy {
activated_sha256, ..
} => {
if receipt.phase == ActivationPhaseV2::Committed {
if receipt.activated.sha256() != activated_sha256 {
return conflict();
}
return Ok(receipt);
}
if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth
|| receipt.activated.sha256() != activated_sha256
{
return conflict();
}
receipt.phase = ActivationPhaseV2::Committed;
receipt.failure_reason = None;
}
RecoveryAction::RequireRollback { reason, .. } => {
validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
if receipt.phase == ActivationPhaseV2::RollbackRequired {
if receipt.failure_reason.as_deref() != Some(reason.as_str()) {
return conflict();
}
return Ok(receipt);
}
if receipt.phase != ActivationPhaseV2::ActivatedPendingHealth {
return conflict();
}
receipt.phase = ActivationPhaseV2::RollbackRequired;
receipt.failure_reason = Some(reason);
}
RecoveryAction::RecordRolledBack { active_sha256, .. } => {
if receipt.phase == ActivationPhaseV2::RolledBack {
if receipt.previous.as_ref().map(|previous| previous.sha256())
!= active_sha256.as_deref()
{
return conflict();
}
return Ok(receipt);
}
if receipt.phase != ActivationPhaseV2::RollbackRequired {
return conflict();
}
match (&receipt.previous, active_sha256) {
(None, None) => {}
(Some(previous), Some(active)) if previous.sha256() == active => {}
_ => return conflict(),
}
receipt.phase = ActivationPhaseV2::RolledBack;
}
RecoveryAction::DiscardPrepared { .. } => {
if receipt.phase != ActivationPhaseV2::Prepared {
return conflict();
}
receipt.phase = ActivationPhaseV2::Aborted;
}
RecoveryAction::MarkManualReview { reason, .. } => {
validate_text("failure_reason", &reason, MAX_REASON_BYTES)?;
if receipt.phase == ActivationPhaseV2::ManualReviewRequired {
return Ok(receipt);
}
receipt.phase = ActivationPhaseV2::ManualReviewRequired;
receipt.failure_reason = Some(reason);
}
}
Ok(receipt)
}
fn action_identity(action: &RecoveryAction) -> (&str, u64) {
match action {
RecoveryAction::ConfirmHealthy {
attempt_id, at_ms, ..
}
| RecoveryAction::RequireRollback {
attempt_id, at_ms, ..
}
| RecoveryAction::RecordRolledBack {
attempt_id, at_ms, ..
}
| RecoveryAction::DiscardPrepared { attempt_id, at_ms }
| RecoveryAction::MarkManualReview {
attempt_id, at_ms, ..
} => (attempt_id, *at_ms),
}
}
fn validate_text(name: &str, value: &str, max_bytes: usize) -> UpdateResult<()> {
if value.trim().is_empty() || value.len() > max_bytes || value.chars().any(char::is_control) {
return Err(UpdateError::Recovery(format!("{name} is invalid")));
}
Ok(())
}
fn conflict<T>() -> UpdateResult<T> {
Err(UpdateError::Recovery(
"recovery action conflicts with the persisted phase or evidence".to_string(),
))
}
fn reject_directory(path: &Path) -> UpdateResult<()> {
let metadata = fs::symlink_metadata(path).map_err(store_error)?;
if metadata.file_type().is_symlink() || !metadata.is_dir() {
return Err(UpdateError::Store(
"recovery root is not a regular directory".to_string(),
));
}
Ok(())
}
fn store_error(error: std::io::Error) -> UpdateError {
UpdateError::Store(error.to_string())
}