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