Skip to main content

canic_backup/restore/apply/journal/receipts/
mod.rs

1//! Module: restore::apply::journal::receipts
2//!
3//! Responsibility: define and validate durable restore apply command receipts.
4//! Does not own: command rendering, process execution, or journal state transitions.
5//! Boundary: records command outcomes against restore apply journal operations.
6
7use super::{
8    RestoreApplyJournal, RestoreApplyJournalError, RestoreApplyJournalOperation,
9    RestoreApplyOperationKind, RestoreApplyRunnerCommand, validate_apply_journal_nonempty,
10};
11
12use serde::{Deserialize, Serialize};
13
14///
15/// RestoreApplyOperationReceipt
16///
17/// Durable command receipt for one restore apply operation attempt.
18/// Owned by restore apply journaling and consumed by resume and audit reports.
19///
20
21#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
22#[serde(deny_unknown_fields)]
23pub struct RestoreApplyOperationReceipt {
24    pub sequence: usize,
25    pub operation: RestoreApplyOperationKind,
26    pub outcome: RestoreApplyOperationReceiptOutcome,
27    pub source_canister: String,
28    pub target_canister: String,
29    pub attempt: usize,
30    #[serde(skip_serializing_if = "Option::is_none")]
31    pub updated_at: Option<String>,
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub command: Option<RestoreApplyRunnerCommand>,
34    #[serde(skip_serializing_if = "Option::is_none")]
35    pub status: Option<String>,
36    #[serde(skip_serializing_if = "Option::is_none")]
37    pub stdout: Option<RestoreApplyCommandOutput>,
38    #[serde(skip_serializing_if = "Option::is_none")]
39    pub stderr: Option<RestoreApplyCommandOutput>,
40    #[serde(skip_serializing_if = "Option::is_none")]
41    pub failure_reason: Option<String>,
42    #[serde(skip_serializing_if = "Option::is_none")]
43    pub source_snapshot_id: Option<String>,
44    #[serde(skip_serializing_if = "Option::is_none")]
45    pub artifact_path: Option<String>,
46    #[serde(skip_serializing_if = "Option::is_none")]
47    pub uploaded_snapshot_id: Option<String>,
48}
49
50impl RestoreApplyOperationReceipt {
51    /// Build a durable completed-command receipt for the apply journal.
52    #[must_use]
53    pub(in crate::restore) fn command_completed(
54        operation: &RestoreApplyJournalOperation,
55        command: RestoreApplyRunnerCommand,
56        status: String,
57        updated_at: Option<String>,
58        output: RestoreApplyCommandOutputPair,
59        attempt: usize,
60        uploaded_snapshot_id: Option<String>,
61    ) -> Self {
62        Self::from_operation(
63            operation,
64            operation.operation.clone(),
65            RestoreApplyOperationReceiptOutcome::CommandCompleted,
66            RestoreApplyOperationReceiptDetails {
67                attempt,
68                updated_at,
69                command: Some(command),
70                status: Some(status),
71                stdout: Some(output.stdout),
72                stderr: Some(output.stderr),
73                uploaded_snapshot_id,
74                failure_reason: None,
75            },
76        )
77    }
78
79    /// Build a durable failed-command receipt for the apply journal.
80    #[must_use]
81    pub(in crate::restore) fn command_failed(
82        operation: &RestoreApplyJournalOperation,
83        command: RestoreApplyRunnerCommand,
84        status: String,
85        updated_at: Option<String>,
86        output: RestoreApplyCommandOutputPair,
87        attempt: usize,
88        failure_reason: String,
89    ) -> Self {
90        Self::from_operation(
91            operation,
92            operation.operation.clone(),
93            RestoreApplyOperationReceiptOutcome::CommandFailed,
94            RestoreApplyOperationReceiptDetails {
95                attempt,
96                updated_at,
97                command: Some(command),
98                status: Some(status),
99                stdout: Some(output.stdout),
100                stderr: Some(output.stderr),
101                failure_reason: Some(failure_reason),
102                uploaded_snapshot_id: None,
103            },
104        )
105    }
106
107    fn from_operation(
108        operation: &RestoreApplyJournalOperation,
109        operation_kind: RestoreApplyOperationKind,
110        outcome: RestoreApplyOperationReceiptOutcome,
111        details: RestoreApplyOperationReceiptDetails,
112    ) -> Self {
113        Self {
114            sequence: operation.sequence,
115            operation: operation_kind,
116            outcome,
117            source_canister: operation.source_canister.clone(),
118            target_canister: operation.target_canister.clone(),
119            attempt: details.attempt,
120            updated_at: details.updated_at,
121            command: details.command,
122            status: details.status,
123            stdout: details.stdout,
124            stderr: details.stderr,
125            failure_reason: details.failure_reason,
126            source_snapshot_id: operation.snapshot_id.clone(),
127            artifact_path: operation.artifact_path.clone(),
128            uploaded_snapshot_id: details.uploaded_snapshot_id,
129        }
130    }
131
132    pub(super) fn matches_load_operation(&self, load: &RestoreApplyJournalOperation) -> bool {
133        self.operation == RestoreApplyOperationKind::UploadSnapshot
134            && self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted
135            && load.operation == RestoreApplyOperationKind::LoadSnapshot
136            && self.source_canister == load.source_canister
137            && self.target_canister == load.target_canister
138            && self.source_snapshot_id == load.snapshot_id
139            && self.artifact_path == load.artifact_path
140            && self
141                .uploaded_snapshot_id
142                .as_ref()
143                .is_some_and(|id| !id.trim().is_empty())
144    }
145
146    pub(super) fn validate_against(
147        &self,
148        journal: &RestoreApplyJournal,
149    ) -> Result<(), RestoreApplyJournalError> {
150        let operation = journal
151            .operations
152            .iter()
153            .find(|operation| operation.sequence == self.sequence)
154            .ok_or(RestoreApplyJournalError::OperationReceiptOperationNotFound(
155                self.sequence,
156            ))?;
157        if operation.operation != self.operation
158            || operation.source_canister != self.source_canister
159            || operation.target_canister != self.target_canister
160        {
161            return Err(RestoreApplyJournalError::OperationReceiptMismatch {
162                sequence: self.sequence,
163            });
164        }
165        validate_apply_journal_nonempty(
166            "operation_receipts[].updated_at",
167            self.updated_at.as_deref().unwrap_or_default(),
168        )?;
169        let command =
170            Self::validate_present("operation_receipts[].command", self.command.as_ref())?;
171        validate_apply_journal_nonempty("operation_receipts[].command.program", &command.program)?;
172        validate_apply_journal_nonempty("operation_receipts[].command.note", &command.note)?;
173        if command.args.is_empty() {
174            return Err(RestoreApplyJournalError::MissingField(
175                "operation_receipts[].command.args",
176            ));
177        }
178        validate_apply_journal_nonempty(
179            "operation_receipts[].status",
180            self.status.as_deref().unwrap_or_default(),
181        )?;
182        Self::validate_present("operation_receipts[].stdout", self.stdout.as_ref())?;
183        Self::validate_present("operation_receipts[].stderr", self.stderr.as_ref())?;
184        if self.operation == RestoreApplyOperationKind::UploadSnapshot {
185            validate_apply_journal_nonempty(
186                "operation_receipts[].source_snapshot_id",
187                self.source_snapshot_id.as_deref().unwrap_or_default(),
188            )?;
189            validate_apply_journal_nonempty(
190                "operation_receipts[].artifact_path",
191                self.artifact_path.as_deref().unwrap_or_default(),
192            )?;
193            if self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted {
194                validate_apply_journal_nonempty(
195                    "operation_receipts[].uploaded_snapshot_id",
196                    self.uploaded_snapshot_id.as_deref().unwrap_or_default(),
197                )?;
198            }
199        }
200        if self.outcome == RestoreApplyOperationReceiptOutcome::CommandFailed {
201            validate_apply_journal_nonempty(
202                "operation_receipts[].failure_reason",
203                self.failure_reason.as_deref().unwrap_or_default(),
204            )?;
205        }
206
207        Ok(())
208    }
209
210    fn validate_present<'a, T>(
211        field: &'static str,
212        value: Option<&'a T>,
213    ) -> Result<&'a T, RestoreApplyJournalError> {
214        value.ok_or(RestoreApplyJournalError::MissingField(field))
215    }
216}
217
218///
219/// RestoreApplyOperationReceiptOutcome
220///
221/// Command outcome stored in a restore apply operation receipt.
222/// Owned by restore apply journaling and serialized with command receipts.
223///
224
225#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
226#[serde(rename_all = "kebab-case")]
227pub enum RestoreApplyOperationReceiptOutcome {
228    CommandCompleted,
229    CommandFailed,
230}
231
232///
233/// RestoreApplyOperationReceiptDetails
234///
235/// Internal receipt construction input shared by completed and failed commands.
236/// Owned by restore apply journaling and not serialized directly.
237///
238
239#[derive(Default)]
240struct RestoreApplyOperationReceiptDetails {
241    attempt: usize,
242    updated_at: Option<String>,
243    command: Option<RestoreApplyRunnerCommand>,
244    status: Option<String>,
245    stdout: Option<RestoreApplyCommandOutput>,
246    stderr: Option<RestoreApplyCommandOutput>,
247    failure_reason: Option<String>,
248    uploaded_snapshot_id: Option<String>,
249}
250
251///
252/// RestoreApplyCommandOutput
253///
254/// Bounded command output captured for durable restore receipts.
255/// Owned by restore apply journaling and embedded in operation receipts.
256///
257
258#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
259#[serde(deny_unknown_fields)]
260pub struct RestoreApplyCommandOutput {
261    pub text: String,
262    pub truncated: bool,
263    pub original_bytes: usize,
264}
265
266impl RestoreApplyCommandOutput {
267    /// Build a bounded UTF-8-ish command output payload for durable receipts.
268    #[must_use]
269    pub(in crate::restore) fn from_bytes(bytes: &[u8], limit: usize) -> Self {
270        let original_bytes = bytes.len();
271        let start = original_bytes.saturating_sub(limit);
272        Self {
273            text: String::from_utf8_lossy(&bytes[start..]).to_string(),
274            truncated: start > 0,
275            original_bytes,
276        }
277    }
278}
279
280///
281/// RestoreApplyCommandOutputPair
282///
283
284#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
285#[serde(deny_unknown_fields)]
286pub(in crate::restore) struct RestoreApplyCommandOutputPair {
287    pub stdout: RestoreApplyCommandOutput,
288    pub stderr: RestoreApplyCommandOutput,
289}
290
291impl RestoreApplyCommandOutputPair {
292    /// Build bounded stdout/stderr command output payloads.
293    #[must_use]
294    pub(in crate::restore) fn from_bytes(stdout: &[u8], stderr: &[u8], limit: usize) -> Self {
295        Self {
296            stdout: RestoreApplyCommandOutput::from_bytes(stdout, limit),
297            stderr: RestoreApplyCommandOutput::from_bytes(stderr, limit),
298        }
299    }
300}