Skip to main content

canic_cli/snapshot/
mod.rs

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};
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///
22/// SnapshotCommandError
23///
24
25#[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///
50/// SnapshotDownloadOptions
51///
52
53#[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    /// Parse snapshot download options from CLI arguments.
68    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
95// Build the snapshot download parser.
96fn 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
110/// Run a snapshot subcommand.
111pub 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
149/// Create and download snapshots for the selected canister set.
150pub 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
175///
176/// DfxSnapshotDriver
177///
178
179struct DfxSnapshotDriver<'a> {
180    options: &'a SnapshotDownloadOptions,
181}
182
183impl SnapshotDriver for DfxSnapshotDriver<'_> {
184    /// Load the root registry JSON via `dfx canister call`.
185    fn registry_json(&mut self, root: &str) -> Result<String, SnapshotDriverError> {
186        call_subnet_registry(self.options, root).map_err(driver_error)
187    }
188
189    /// Create a canister snapshot via DFX.
190    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    /// Stop a canister via DFX.
195    fn stop_canister(&mut self, canister_id: &str) -> Result<(), SnapshotDriverError> {
196        stop_canister(self.options, canister_id).map_err(driver_error)
197    }
198
199    /// Start a canister via DFX.
200    fn start_canister(&mut self, canister_id: &str) -> Result<(), SnapshotDriverError> {
201        start_canister(self.options, canister_id).map_err(driver_error)
202    }
203
204    /// Download a canister snapshot via DFX.
205    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    /// Render the planned create command for dry runs.
216    fn create_snapshot_command(&self, canister_id: &str) -> String {
217        create_snapshot_command_display(self.options, canister_id)
218    }
219
220    /// Render the planned stop command for dry runs.
221    fn stop_canister_command(&self, canister_id: &str) -> String {
222        stop_canister_command_display(self.options, canister_id)
223    }
224
225    /// Render the planned start command for dry runs.
226    fn start_canister_command(&self, canister_id: &str) -> String {
227        start_canister_command_display(self.options, canister_id)
228    }
229
230    /// Render the planned download command for dry runs.
231    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
241// Box a CLI command error for the backup snapshot driver boundary.
242fn driver_error(error: SnapshotCommandError) -> SnapshotDriverError {
243    Box::new(error)
244}
245
246// Build the shared host dfx context for snapshot commands.
247fn dfx(options: &SnapshotDownloadOptions) -> Dfx {
248    Dfx::new(&options.dfx, options.network.clone())
249}
250
251// Convert host dfx failures into the snapshot command's public error surface.
252fn 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
261// Run `dfx canister call <root> canic_subnet_registry --output json`.
262fn 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
271// Create one canister snapshot and parse the snapshot id from dfx output.
272fn 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
296// List the existing snapshot ids for one canister.
297fn 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
307// Stop a canister before taking a snapshot when explicitly requested.
308fn 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
317// Start a canister after snapshot capture when explicitly requested.
318fn 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
327// Download one canister snapshot into the target artifact directory.
328fn 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
339// Render one dry-run create command.
340fn create_snapshot_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
341    dfx(options).snapshot_create_display(canister_id)
342}
343
344// Render one dry-run download command.
345fn 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
354// Render one dry-run stop command.
355fn stop_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
356    dfx(options).stop_canister_display(canister_id)
357}
358
359// Render one dry-run start command.
360fn start_canister_command_display(options: &SnapshotDownloadOptions, canister_id: &str) -> String {
361    dfx(options).start_canister_display(canister_id)
362}
363
364// Parse a likely snapshot id from dfx output.
365fn parse_snapshot_id(output: &str) -> Option<String> {
366    output
367        .split(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ':' | ','))
368        .filter(|part| !part.is_empty())
369        .rev()
370        .find(|part| {
371            part.chars()
372                .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.'))
373        })
374        .map(str::to_string)
375}
376
377// Parse dfx snapshot list output into snapshot ids.
378fn parse_snapshot_list_ids(output: &str) -> Vec<String> {
379    output
380        .lines()
381        .filter_map(|line| {
382            line.split_once(':')
383                .map(|(snapshot_id, _)| snapshot_id.trim())
384        })
385        .filter(|snapshot_id| !snapshot_id.is_empty())
386        .map(str::to_string)
387        .collect()
388}
389
390// Build a stable backup id for this command's output directory.
391fn backup_id(options: &SnapshotDownloadOptions) -> String {
392    options
393        .out
394        .file_name()
395        .and_then(|name| name.to_str())
396        .map_or_else(|| "snapshot-download".to_string(), str::to_string)
397}
398
399// Return snapshot command usage text.
400const fn usage() -> &'static str {
401    "usage: canic snapshot download --canister <id> --out <dir> [--root <id>] [--include-children] [--recursive] [--dry-run] [--resume-after-snapshot] [--network <name>]"
402}
403
404#[cfg(test)]
405mod tests {
406    use super::*;
407
408    const ROOT: &str = "aaaaa-aa";
409
410    // Ensure snapshot ids can be extracted from common command output.
411    #[test]
412    fn parses_snapshot_id_from_output() {
413        let snapshot_id = parse_snapshot_id("Created snapshot: snap_abc-123\n");
414
415        assert_eq!(snapshot_id.as_deref(), Some("snap_abc-123"));
416    }
417
418    // Ensure dfx snapshot list output can be used when create is quiet.
419    #[test]
420    fn parses_snapshot_ids_from_list_output() {
421        let snapshot_ids = parse_snapshot_list_ids(
422            "0000000000000000ffffffffff9000050101: 213.76 MiB, taken at 2026-05-03 12:20:53 UTC\n",
423        );
424
425        assert_eq!(snapshot_ids, vec!["0000000000000000ffffffffff9000050101"]);
426    }
427
428    // Ensure option parsing covers the intended dry-run command.
429    #[test]
430    fn parses_download_options() {
431        let options = SnapshotDownloadOptions::parse([
432            OsString::from("--canister"),
433            OsString::from(ROOT),
434            OsString::from("--out"),
435            OsString::from("backups/test"),
436            OsString::from("--root"),
437            OsString::from(ROOT),
438            OsString::from("--recursive"),
439            OsString::from("--dry-run"),
440            OsString::from("--resume-after-snapshot"),
441        ])
442        .expect("parse options");
443
444        assert_eq!(options.canister, ROOT);
445        assert!(options.include_children);
446        assert!(options.recursive);
447        assert!(options.dry_run);
448        assert_eq!(options.root.as_deref(), Some(ROOT));
449        assert_eq!(options.lifecycle, SnapshotLifecycleMode::StopAndResume);
450    }
451}