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