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