Skip to main content

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

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