1use crate::{
2 args::{flag_arg, parse_matches, path_option, string_option, value_arg},
3 version_text,
4};
5use canic_backup::{
6 snapshot::{
7 SnapshotDownloadConfig, SnapshotDownloadError, SnapshotDownloadResult, SnapshotDriver,
8 SnapshotDriverError, SnapshotLifecycleMode,
9 },
10 timestamp::current_timestamp_marker,
11};
12use canic_host::dfx::{Dfx, DfxCommandError, parse_snapshot_id, parse_snapshot_list_ids};
13use clap::Command as ClapCommand;
14use std::{
15 collections::BTreeSet,
16 ffi::OsString,
17 path::{Path, PathBuf},
18};
19use thiserror::Error as ThisError;
20
21#[derive(Debug, ThisError)]
26pub enum SnapshotCommandError {
27 #[error("{0}")]
28 Usage(&'static str),
29
30 #[error("missing required option {0}")]
31 MissingOption(&'static str),
32
33 #[error("unknown option {0}")]
34 UnknownOption(String),
35
36 #[error("dfx command failed: {command}\n{stderr}")]
37 DfxFailed { command: String, stderr: String },
38
39 #[error("could not parse snapshot id from dfx output: {0}")]
40 SnapshotIdUnavailable(String),
41
42 #[error(transparent)]
43 Io(#[from] std::io::Error),
44
45 #[error(transparent)]
46 SnapshotDownload(#[from] SnapshotDownloadError),
47}
48
49#[derive(Clone, Debug, Eq, PartialEq)]
54pub struct SnapshotDownloadOptions {
55 pub canister: String,
56 pub out: PathBuf,
57 pub root: Option<String>,
58 pub include_children: bool,
59 pub recursive: bool,
60 pub dry_run: bool,
61 pub lifecycle: SnapshotLifecycleMode,
62 pub network: Option<String>,
63 pub dfx: String,
64}
65
66impl SnapshotDownloadOptions {
67 pub fn parse<I>(args: I) -> Result<Self, SnapshotCommandError>
69 where
70 I: IntoIterator<Item = OsString>,
71 {
72 let matches = parse_matches(snapshot_download_command(), args)
73 .map_err(|_| SnapshotCommandError::Usage(usage()))?;
74 let recursive = matches.get_flag("recursive");
75 let include_children = matches.get_flag("include-children") || recursive;
76
77 Ok(Self {
78 canister: string_option(&matches, "canister")
79 .ok_or(SnapshotCommandError::MissingOption("--canister"))?,
80 out: path_option(&matches, "out")
81 .ok_or(SnapshotCommandError::MissingOption("--out"))?,
82 root: string_option(&matches, "root"),
83 include_children,
84 recursive,
85 dry_run: matches.get_flag("dry-run"),
86 lifecycle: SnapshotLifecycleMode::from_resume_flag(
87 matches.get_flag("resume-after-snapshot"),
88 ),
89 network: string_option(&matches, "network"),
90 dfx: string_option(&matches, "dfx").unwrap_or_else(|| "dfx".to_string()),
91 })
92 }
93}
94
95fn snapshot_download_command() -> ClapCommand {
97 ClapCommand::new("snapshot-download")
98 .disable_help_flag(true)
99 .arg(value_arg("canister").long("canister"))
100 .arg(value_arg("out").long("out"))
101 .arg(value_arg("root").long("root"))
102 .arg(flag_arg("include-children").long("include-children"))
103 .arg(flag_arg("recursive").long("recursive"))
104 .arg(flag_arg("dry-run").long("dry-run"))
105 .arg(flag_arg("resume-after-snapshot").long("resume-after-snapshot"))
106 .arg(value_arg("network").long("network"))
107 .arg(value_arg("dfx").long("dfx"))
108}
109
110pub fn run<I>(args: I) -> Result<(), SnapshotCommandError>
112where
113 I: IntoIterator<Item = OsString>,
114{
115 let mut args = args.into_iter();
116 let Some(command) = args.next().and_then(|arg| arg.into_string().ok()) else {
117 return Err(SnapshotCommandError::Usage(usage()));
118 };
119
120 match command.as_str() {
121 "download" => {
122 let options = SnapshotDownloadOptions::parse(args)?;
123 let result = download_snapshots(&options)?;
124 for command in result.planned_commands {
125 println!("{command}");
126 }
127 for artifact in result.artifacts {
128 println!(
129 "{} {} {}",
130 artifact.canister_id,
131 artifact.snapshot_id,
132 artifact.path.display()
133 );
134 }
135 Ok(())
136 }
137 "help" | "--help" | "-h" => {
138 println!("{}", usage());
139 Ok(())
140 }
141 "version" | "--version" | "-V" => {
142 println!("{}", version_text());
143 Ok(())
144 }
145 _ => Err(SnapshotCommandError::UnknownOption(command)),
146 }
147}
148
149pub fn download_snapshots(
151 options: &SnapshotDownloadOptions,
152) -> Result<SnapshotDownloadResult, SnapshotCommandError> {
153 let config = SnapshotDownloadConfig {
154 canister: options.canister.clone(),
155 out: options.out.clone(),
156 root: options.root.clone(),
157 include_children: options.include_children,
158 recursive: options.recursive,
159 dry_run: options.dry_run,
160 lifecycle: options.lifecycle,
161 backup_id: backup_id(options),
162 created_at: current_timestamp_marker(),
163 tool_name: "canic-cli".to_string(),
164 tool_version: env!("CARGO_PKG_VERSION").to_string(),
165 environment: options
166 .network
167 .clone()
168 .unwrap_or_else(|| "local".to_string()),
169 };
170 let mut driver = DfxSnapshotDriver { options };
171 canic_backup::snapshot::download_snapshots(&config, &mut driver)
172 .map_err(SnapshotCommandError::from)
173}
174
175struct DfxSnapshotDriver<'a> {
180 options: &'a SnapshotDownloadOptions,
181}
182
183impl SnapshotDriver for DfxSnapshotDriver<'_> {
184 fn registry_json(&mut self, root: &str) -> Result<String, SnapshotDriverError> {
186 call_subnet_registry(self.options, root).map_err(driver_error)
187 }
188
189 fn create_snapshot(&mut self, canister_id: &str) -> Result<String, SnapshotDriverError> {
191 create_snapshot(self.options, canister_id).map_err(driver_error)
192 }
193
194 fn stop_canister(&mut self, canister_id: &str) -> Result<(), SnapshotDriverError> {
196 stop_canister(self.options, canister_id).map_err(driver_error)
197 }
198
199 fn start_canister(&mut self, canister_id: &str) -> Result<(), SnapshotDriverError> {
201 start_canister(self.options, canister_id).map_err(driver_error)
202 }
203
204 fn download_snapshot(
206 &mut self,
207 canister_id: &str,
208 snapshot_id: &str,
209 artifact_path: &Path,
210 ) -> Result<(), SnapshotDriverError> {
211 download_snapshot(self.options, canister_id, snapshot_id, artifact_path)
212 .map_err(driver_error)
213 }
214
215 fn create_snapshot_command(&self, canister_id: &str) -> String {
217 create_snapshot_command_display(self.options, canister_id)
218 }
219
220 fn stop_canister_command(&self, canister_id: &str) -> String {
222 stop_canister_command_display(self.options, canister_id)
223 }
224
225 fn start_canister_command(&self, canister_id: &str) -> String {
227 start_canister_command_display(self.options, canister_id)
228 }
229
230 fn download_snapshot_command(
232 &self,
233 canister_id: &str,
234 snapshot_id: &str,
235 artifact_path: &Path,
236 ) -> String {
237 download_snapshot_command_display(self.options, canister_id, snapshot_id, artifact_path)
238 }
239}
240
241fn driver_error(error: SnapshotCommandError) -> SnapshotDriverError {
243 Box::new(error)
244}
245
246fn dfx(options: &SnapshotDownloadOptions) -> Dfx {
248 Dfx::new(&options.dfx, options.network.clone())
249}
250
251fn snapshot_dfx_error(error: DfxCommandError) -> SnapshotCommandError {
253 match error {
254 DfxCommandError::Io(err) => SnapshotCommandError::Io(err),
255 DfxCommandError::Failed { command, stderr } => {
256 SnapshotCommandError::DfxFailed { command, stderr }
257 }
258 }
259}
260
261fn call_subnet_registry(
263 options: &SnapshotDownloadOptions,
264 root: &str,
265) -> Result<String, SnapshotCommandError> {
266 dfx(options)
267 .canister_call_output(root, "canic_subnet_registry", Some("json"))
268 .map_err(snapshot_dfx_error)
269}
270
271fn create_snapshot(
273 options: &SnapshotDownloadOptions,
274 canister_id: &str,
275) -> Result<String, SnapshotCommandError> {
276 let before = list_snapshot_ids(options, canister_id)?;
277 let output = dfx(options)
278 .snapshot_create(canister_id)
279 .map_err(snapshot_dfx_error)?;
280 if let Some(snapshot_id) = parse_snapshot_id(&output) {
281 return Ok(snapshot_id);
282 }
283
284 let before = before.into_iter().collect::<BTreeSet<_>>();
285 let mut new_ids = list_snapshot_ids(options, canister_id)?
286 .into_iter()
287 .filter(|snapshot_id| !before.contains(snapshot_id))
288 .collect::<Vec<_>>();
289 if new_ids.len() == 1 {
290 Ok(new_ids.remove(0))
291 } else {
292 Err(SnapshotCommandError::SnapshotIdUnavailable(output))
293 }
294}
295
296fn list_snapshot_ids(
298 options: &SnapshotDownloadOptions,
299 canister_id: &str,
300) -> Result<Vec<String>, SnapshotCommandError> {
301 let output = dfx(options)
302 .snapshot_list(canister_id)
303 .map_err(snapshot_dfx_error)?;
304 Ok(parse_snapshot_list_ids(&output))
305}
306
307fn stop_canister(
309 options: &SnapshotDownloadOptions,
310 canister_id: &str,
311) -> Result<(), SnapshotCommandError> {
312 dfx(options)
313 .stop_canister(canister_id)
314 .map_err(snapshot_dfx_error)
315}
316
317fn start_canister(
319 options: &SnapshotDownloadOptions,
320 canister_id: &str,
321) -> Result<(), SnapshotCommandError> {
322 dfx(options)
323 .start_canister(canister_id)
324 .map_err(snapshot_dfx_error)
325}
326
327fn download_snapshot(
329 options: &SnapshotDownloadOptions,
330 canister_id: &str,
331 snapshot_id: &str,
332 artifact_path: &Path,
333) -> Result<(), SnapshotCommandError> {
334 dfx(options)
335 .snapshot_download(canister_id, snapshot_id, artifact_path)
336 .map_err(snapshot_dfx_error)
337}
338
339fn create_snapshot_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
341 dfx(options).snapshot_create_display(canister_id)
342}
343
344fn download_snapshot_command_display(
346 options: &SnapshotDownloadOptions,
347 canister_id: &str,
348 snapshot_id: &str,
349 artifact_path: &Path,
350) -> String {
351 dfx(options).snapshot_download_display(canister_id, snapshot_id, artifact_path)
352}
353
354fn stop_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
356 dfx(options).stop_canister_display(canister_id)
357}
358
359fn start_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
361 dfx(options).start_canister_display(canister_id)
362}
363
364fn backup_id(options: &SnapshotDownloadOptions) -> String {
366 options
367 .out
368 .file_name()
369 .and_then(|name| name.to_str())
370 .map_or_else(|| "snapshot-download".to_string(), str::to_string)
371}
372
373const fn usage() -> &'static str {
375 "usage: canic snapshot download --canister <id> --out <dir> [--root <id>] [--include-children] [--recursive] [--dry-run] [--resume-after-snapshot] [--network <name>]"
376}
377
378#[cfg(test)]
379mod tests {
380 use super::*;
381
382 const ROOT: &str = "aaaaa-aa";
383
384 #[test]
386 fn parses_download_options() {
387 let options = SnapshotDownloadOptions::parse([
388 OsString::from("--canister"),
389 OsString::from(ROOT),
390 OsString::from("--out"),
391 OsString::from("backups/test"),
392 OsString::from("--root"),
393 OsString::from(ROOT),
394 OsString::from("--recursive"),
395 OsString::from("--dry-run"),
396 OsString::from("--resume-after-snapshot"),
397 ])
398 .expect("parse options");
399
400 assert_eq!(options.canister, ROOT);
401 assert!(options.include_children);
402 assert!(options.recursive);
403 assert!(options.dry_run);
404 assert_eq!(options.root.as_deref(), Some(ROOT));
405 assert_eq!(options.lifecycle, SnapshotLifecycleMode::StopAndResume);
406 }
407}