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