Skip to main content

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

1//! Module: restore::apply::journal::commands
2//!
3//! Responsibility: render restore apply journal operations into command previews.
4//! Does not own: command execution, receipt persistence, or journal transitions.
5//! Boundary: produces no-execute runner command payloads from journal state.
6
7use crate::{
8    manifest::VERIFICATION_KIND_STATUS,
9    persistence::resolve_backup_artifact_path,
10    restore::{RestoreApplyJournal, RestoreApplyJournalOperation, RestoreApplyOperationKind},
11};
12
13use std::path::Path;
14
15use serde::{Deserialize, Serialize};
16
17///
18/// RestoreApplyCommandPreview
19///
20/// No-execute preview of the next restore apply command.
21/// Owned by restore apply journaling and returned to operators before execution.
22///
23
24#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
25#[serde(deny_unknown_fields)]
26#[expect(
27    clippy::struct_excessive_bools,
28    reason = "runner preview exposes machine-readable availability and safety flags"
29)]
30pub struct RestoreApplyCommandPreview {
31    pub response_version: u16,
32    pub backup_id: String,
33    pub ready: bool,
34    pub complete: bool,
35    pub operation_available: bool,
36    pub command_available: bool,
37    pub blocked_reasons: Vec<String>,
38    pub operation: Option<RestoreApplyJournalOperation>,
39    pub command: Option<RestoreApplyRunnerCommand>,
40}
41
42impl RestoreApplyCommandPreview {
43    /// Build a no-execute runner command preview from a restore apply journal.
44    #[must_use]
45    pub fn from_journal(journal: &RestoreApplyJournal) -> Self {
46        Self::from_journal_with_config(journal, &RestoreApplyCommandConfig::default())
47    }
48
49    /// Build a configured no-execute runner command preview from a journal.
50    #[must_use]
51    pub fn from_journal_with_config(
52        journal: &RestoreApplyJournal,
53        config: &RestoreApplyCommandConfig,
54    ) -> Self {
55        let operation = journal.next_transition_operation().cloned();
56        let command = operation.as_ref().and_then(|operation| {
57            RestoreApplyRunnerCommand::from_operation(operation, journal, config)
58        });
59
60        Self {
61            response_version: 1,
62            backup_id: journal.backup_id.clone(),
63            ready: journal.ready,
64            complete: journal.is_complete(),
65            operation_available: operation.is_some(),
66            command_available: command.is_some(),
67            blocked_reasons: journal.blocked_reasons.clone(),
68            operation,
69            command,
70        }
71    }
72}
73
74///
75/// RestoreApplyCommandConfig
76///
77/// Command rendering configuration for restore apply previews.
78/// Owned by restore apply journaling and supplied by native runner callers.
79///
80
81#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
82#[serde(deny_unknown_fields)]
83pub struct RestoreApplyCommandConfig {
84    pub program: String,
85    pub environment: Option<String>,
86}
87
88impl Default for RestoreApplyCommandConfig {
89    /// Build the default restore apply command preview configuration.
90    fn default() -> Self {
91        Self {
92            program: "icp".to_string(),
93            environment: None,
94        }
95    }
96}
97
98///
99/// RestoreApplyRunnerCommand
100///
101/// Rendered restore runner command and safety metadata.
102/// Owned by restore apply journaling and embedded in previews and receipts.
103///
104
105#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
106#[serde(deny_unknown_fields)]
107pub struct RestoreApplyRunnerCommand {
108    pub program: String,
109    pub args: Vec<String>,
110    pub mutates: bool,
111    pub requires_stopped_canister: bool,
112    pub note: String,
113}
114
115impl RestoreApplyRunnerCommand {
116    fn from_operation(
117        operation: &RestoreApplyJournalOperation,
118        journal: &RestoreApplyJournal,
119        config: &RestoreApplyCommandConfig,
120    ) -> Option<Self> {
121        Self::from_operation_with_artifact_path(operation, journal, config, None)
122    }
123
124    pub(in crate::restore) fn from_operation_with_artifact_path(
125        operation: &RestoreApplyJournalOperation,
126        journal: &RestoreApplyJournal,
127        config: &RestoreApplyCommandConfig,
128        staged_artifact_path: Option<&Path>,
129    ) -> Option<Self> {
130        match operation.operation {
131            RestoreApplyOperationKind::StopCanister => Some(Self {
132                program: config.program.clone(),
133                args: icp_canister_args(
134                    config,
135                    vec!["stop".to_string(), operation.target_canister.clone()],
136                ),
137                mutates: true,
138                requires_stopped_canister: false,
139                note: "stops the target canister before snapshot restore".to_string(),
140            }),
141            RestoreApplyOperationKind::StartCanister => Some(Self {
142                program: config.program.clone(),
143                args: icp_canister_args(
144                    config,
145                    vec!["start".to_string(), operation.target_canister.clone()],
146                ),
147                mutates: true,
148                requires_stopped_canister: false,
149                note: "starts the target canister after snapshot restore".to_string(),
150            }),
151            RestoreApplyOperationKind::UploadSnapshot => {
152                let artifact_path = staged_artifact_path.map_or_else(
153                    || upload_artifact_command_path(operation, journal),
154                    |path| Some(path.to_string_lossy().to_string()),
155                )?;
156                Some(Self {
157                    program: config.program.clone(),
158                    args: icp_canister_args(
159                        config,
160                        vec![
161                            "snapshot".to_string(),
162                            "upload".to_string(),
163                            operation.target_canister.clone(),
164                            "--input".to_string(),
165                            artifact_path,
166                            "--json".to_string(),
167                        ],
168                    ),
169                    mutates: true,
170                    requires_stopped_canister: false,
171                    note: "uploads a private checksum-verified snapshot artifact copy to the target canister"
172                        .to_string(),
173                })
174            }
175            RestoreApplyOperationKind::LoadSnapshot => {
176                let snapshot_id = journal.uploaded_snapshot_id_for_load(operation)?;
177                Some(Self {
178                    program: config.program.clone(),
179                    args: icp_canister_args(
180                        config,
181                        vec![
182                            "snapshot".to_string(),
183                            "restore".to_string(),
184                            operation.target_canister.clone(),
185                            snapshot_id.to_string(),
186                        ],
187                    ),
188                    mutates: true,
189                    requires_stopped_canister: true,
190                    note: "loads the uploaded snapshot into the target canister".to_string(),
191                })
192            }
193            RestoreApplyOperationKind::VerifyMember
194            | RestoreApplyOperationKind::VerifyDeployment => {
195                match operation.verification_kind.as_deref() {
196                    Some(VERIFICATION_KIND_STATUS) => Some(Self {
197                        program: config.program.clone(),
198                        args: icp_canister_args(
199                            config,
200                            vec![
201                                VERIFICATION_KIND_STATUS.to_string(),
202                                operation.target_canister.clone(),
203                                "--json".to_string(),
204                            ],
205                        ),
206                        mutates: false,
207                        requires_stopped_canister: false,
208                        note: verification_command_note(
209                            &operation.operation,
210                            "checks target canister status",
211                            "checks target deployment root canister status",
212                        )
213                        .to_string(),
214                    }),
215                    Some(_) | None => None,
216                }
217            }
218        }
219    }
220}
221
222const fn verification_command_note(
223    operation: &RestoreApplyOperationKind,
224    member_note: &'static str,
225    deployment_note: &'static str,
226) -> &'static str {
227    match operation {
228        RestoreApplyOperationKind::VerifyDeployment => deployment_note,
229        RestoreApplyOperationKind::StopCanister
230        | RestoreApplyOperationKind::StartCanister
231        | RestoreApplyOperationKind::UploadSnapshot
232        | RestoreApplyOperationKind::LoadSnapshot
233        | RestoreApplyOperationKind::VerifyMember => member_note,
234    }
235}
236
237fn icp_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
238    let mut args = vec!["canister".to_string()];
239    args.append(&mut tail);
240    if let Some(environment) = &config.environment {
241        args.push("-e".to_string());
242        args.push(environment.clone());
243    }
244    args
245}
246
247fn upload_artifact_command_path(
248    operation: &RestoreApplyJournalOperation,
249    journal: &RestoreApplyJournal,
250) -> Option<String> {
251    let artifact_path = operation.artifact_path.as_ref()?;
252    let backup_root = journal.backup_root.as_ref()?;
253    resolve_backup_artifact_path(Path::new(backup_root), artifact_path)
254        .map(|path| path.to_string_lossy().to_string())
255}