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