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        validate_apply_journal_nonempty(
160            "operation_receipts[].updated_at",
161            self.updated_at.as_deref().unwrap_or_default(),
162        )?;
163        let command =
164            Self::validate_present("operation_receipts[].command", self.command.as_ref())?;
165        validate_apply_journal_nonempty("operation_receipts[].command.program", &command.program)?;
166        validate_apply_journal_nonempty("operation_receipts[].command.note", &command.note)?;
167        if command.args.is_empty() {
168            return Err(RestoreApplyJournalError::MissingField(
169                "operation_receipts[].command.args",
170            ));
171        }
172        validate_apply_journal_nonempty(
173            "operation_receipts[].status",
174            self.status.as_deref().unwrap_or_default(),
175        )?;
176        Self::validate_present("operation_receipts[].stdout", self.stdout.as_ref())?;
177        Self::validate_present("operation_receipts[].stderr", self.stderr.as_ref())?;
178        if self.operation == RestoreApplyOperationKind::UploadSnapshot {
179            validate_apply_journal_nonempty(
180                "operation_receipts[].source_snapshot_id",
181                self.source_snapshot_id.as_deref().unwrap_or_default(),
182            )?;
183            validate_apply_journal_nonempty(
184                "operation_receipts[].artifact_path",
185                self.artifact_path.as_deref().unwrap_or_default(),
186            )?;
187            if self.outcome == RestoreApplyOperationReceiptOutcome::CommandCompleted {
188                validate_apply_journal_nonempty(
189                    "operation_receipts[].uploaded_snapshot_id",
190                    self.uploaded_snapshot_id.as_deref().unwrap_or_default(),
191                )?;
192            }
193        }
194        if self.outcome == RestoreApplyOperationReceiptOutcome::CommandFailed {
195            validate_apply_journal_nonempty(
196                "operation_receipts[].failure_reason",
197                self.failure_reason.as_deref().unwrap_or_default(),
198            )?;
199        }
200
201        Ok(())
202    }
203
204    fn validate_present<'a, T>(
205        field: &'static str,
206        value: Option<&'a T>,
207    ) -> Result<&'a T, RestoreApplyJournalError> {
208        value.ok_or(RestoreApplyJournalError::MissingField(field))
209    }
210}
211
212///
213/// RestoreApplyOperationReceiptOutcome
214///
215
216#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
217#[serde(rename_all = "kebab-case")]
218pub enum RestoreApplyOperationReceiptOutcome {
219    #[default]
220    CommandCompleted,
221    CommandFailed,
222}
223
224///
225/// RestoreApplyOperationReceiptDetails
226///
227
228#[derive(Default)]
229struct RestoreApplyOperationReceiptDetails {
230    attempt: usize,
231    updated_at: Option<String>,
232    command: Option<RestoreApplyRunnerCommand>,
233    status: Option<String>,
234    stdout: Option<RestoreApplyCommandOutput>,
235    stderr: Option<RestoreApplyCommandOutput>,
236    failure_reason: Option<String>,
237    uploaded_snapshot_id: Option<String>,
238}
239
240///
241/// RestoreApplyCommandOutput
242///
243
244#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
245pub struct RestoreApplyCommandOutput {
246    pub text: String,
247    pub truncated: bool,
248    pub original_bytes: usize,
249}
250
251impl RestoreApplyCommandOutput {
252    /// Build a bounded UTF-8-ish command output payload for durable receipts.
253    #[must_use]
254    pub(in crate::restore) fn from_bytes(bytes: &[u8], limit: usize) -> Self {
255        let original_bytes = bytes.len();
256        let start = original_bytes.saturating_sub(limit);
257        Self {
258            text: String::from_utf8_lossy(&bytes[start..]).to_string(),
259            truncated: start > 0,
260            original_bytes,
261        }
262    }
263}
264
265///
266/// RestoreApplyCommandOutputPair
267///
268
269#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
270pub(in crate::restore) struct RestoreApplyCommandOutputPair {
271    pub stdout: RestoreApplyCommandOutput,
272    pub stderr: RestoreApplyCommandOutput,
273}
274
275impl RestoreApplyCommandOutputPair {
276    /// Build bounded stdout/stderr command output payloads.
277    #[must_use]
278    pub(in crate::restore) fn from_bytes(stdout: &[u8], stderr: &[u8], limit: usize) -> Self {
279        Self {
280            stdout: RestoreApplyCommandOutput::from_bytes(stdout, limit),
281            stderr: RestoreApplyCommandOutput::from_bytes(stderr, limit),
282        }
283    }
284}