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