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 network: 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            network: 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        match operation.operation {
122            RestoreApplyOperationKind::StopCanister => Some(Self {
123                program: config.program.clone(),
124                args: icp_canister_args(
125                    config,
126                    vec!["stop".to_string(), operation.target_canister.clone()],
127                ),
128                mutates: true,
129                requires_stopped_canister: false,
130                note: "stops the target canister before snapshot restore".to_string(),
131            }),
132            RestoreApplyOperationKind::StartCanister => Some(Self {
133                program: config.program.clone(),
134                args: icp_canister_args(
135                    config,
136                    vec!["start".to_string(), operation.target_canister.clone()],
137                ),
138                mutates: true,
139                requires_stopped_canister: false,
140                note: "starts the target canister after snapshot restore".to_string(),
141            }),
142            RestoreApplyOperationKind::UploadSnapshot => {
143                let artifact_path = upload_artifact_command_path(operation, journal)?;
144                Some(Self {
145                    program: config.program.clone(),
146                    args: icp_canister_args(
147                        config,
148                        vec![
149                            "snapshot".to_string(),
150                            "upload".to_string(),
151                            operation.target_canister.clone(),
152                            "--input".to_string(),
153                            artifact_path,
154                            "--json".to_string(),
155                        ],
156                    ),
157                    mutates: true,
158                    requires_stopped_canister: false,
159                    note: "uploads the downloaded snapshot artifact to the target canister"
160                        .to_string(),
161                })
162            }
163            RestoreApplyOperationKind::LoadSnapshot => {
164                let snapshot_id = journal.uploaded_snapshot_id_for_load(operation)?;
165                Some(Self {
166                    program: config.program.clone(),
167                    args: icp_canister_args(
168                        config,
169                        vec![
170                            "snapshot".to_string(),
171                            "restore".to_string(),
172                            operation.target_canister.clone(),
173                            snapshot_id.to_string(),
174                        ],
175                    ),
176                    mutates: true,
177                    requires_stopped_canister: true,
178                    note: "loads the uploaded snapshot into the target canister".to_string(),
179                })
180            }
181            RestoreApplyOperationKind::VerifyMember
182            | RestoreApplyOperationKind::VerifyDeployment => {
183                match operation.verification_kind.as_deref() {
184                    Some(VERIFICATION_KIND_STATUS) => Some(Self {
185                        program: config.program.clone(),
186                        args: icp_canister_args(
187                            config,
188                            vec![
189                                VERIFICATION_KIND_STATUS.to_string(),
190                                operation.target_canister.clone(),
191                                "--json".to_string(),
192                            ],
193                        ),
194                        mutates: false,
195                        requires_stopped_canister: false,
196                        note: verification_command_note(
197                            &operation.operation,
198                            "checks target canister status",
199                            "checks target deployment root canister status",
200                        )
201                        .to_string(),
202                    }),
203                    Some(_) | None => None,
204                }
205            }
206        }
207    }
208}
209
210const fn verification_command_note(
211    operation: &RestoreApplyOperationKind,
212    member_note: &'static str,
213    deployment_note: &'static str,
214) -> &'static str {
215    match operation {
216        RestoreApplyOperationKind::VerifyDeployment => deployment_note,
217        RestoreApplyOperationKind::StopCanister
218        | RestoreApplyOperationKind::StartCanister
219        | RestoreApplyOperationKind::UploadSnapshot
220        | RestoreApplyOperationKind::LoadSnapshot
221        | RestoreApplyOperationKind::VerifyMember => member_note,
222    }
223}
224
225fn icp_canister_args(config: &RestoreApplyCommandConfig, mut tail: Vec<String>) -> Vec<String> {
226    let mut args = vec!["canister".to_string()];
227    args.append(&mut tail);
228    if let Some(network) = &config.network {
229        args.push("-n".to_string());
230        args.push(network.clone());
231    }
232    args
233}
234
235fn upload_artifact_command_path(
236    operation: &RestoreApplyJournalOperation,
237    journal: &RestoreApplyJournal,
238) -> Option<String> {
239    let artifact_path = operation.artifact_path.as_ref()?;
240    let backup_root = journal.backup_root.as_ref()?;
241    resolve_backup_artifact_path(Path::new(backup_root), artifact_path)
242        .map(|path| path.to_string_lossy().to_string())
243}