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