Skip to content

Commit 1b89d74

Browse files
feat: Implement snapshot uploading/downloading (#330)
1 parent 6400c62 commit 1b89d74

File tree

17 files changed

+2154
-7
lines changed

17 files changed

+2154
-7
lines changed

.claude/CLAUDE.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -26,8 +26,8 @@ cargo test
2626
# Run tests for specific package
2727
cargo test -p icp-cli
2828

29-
# Run a specific test
30-
cargo test <test_name>
29+
# Run a specific test from <test_file>.rs
30+
cargo test --test <test_file> -- <test_name>
3131

3232
# Run with verbose output
3333
cargo test -- --nocapture
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
#!/bin/bash
22
set -euo pipefail
3-
sudo apt-get update && sudo apt-get install -y softhsm2
3+
sudo apt-get update && sudo apt-get install -y softhsm2 pipx
4+
pipx install mitmproxy
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,3 @@
11
#!/bin/bash
22
set -euo pipefail
3-
brew install softhsm
3+
brew install softhsm mitmproxy

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# Unreleased
22

3-
* feat: `icp canister snapshot` - create, delete, restore, list canister snapshots
3+
* feat: `icp canister snapshot` - create, delete, restore, list, download, and upload canister snapshots
44
* feat: `icp canister call` now supports `--proxy` flag to route calls through a proxy canister
55
* Use `--proxy <CANISTER_ID>` to forward the call through a proxy canister's `proxy` method
66
* Use `--cycles <AMOUNT>` to specify cycles to forward with the proxied call (defaults to 0)

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ publish = false
1818
anyhow = "1.0.100"
1919
async-dropper = { version = "0.3.0", features = ["tokio", "simple"] }
2020
async-trait = "0.1.88"
21+
backoff = { version = "0.4", features = ["tokio"] }
2122
bigdecimal = "0.4.10"
2223
bip32 = "0.5.0"
2324
bollard = "0.19.4"

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,6 @@ Contributions are welcome! See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for de
6161
### Prerequisites
6262

6363
- Rust 1.88.0+ ([rustup.rs](https://rustup.rs/))
64-
- `wasm-tools` — Install via `cargo install wasm-tools` (required for test suite)
65-
- Platform dependencies:
6664

6765
| Platform | Install |
6866
|---------------|----------------------------------------------------------------------------------------------------------|
@@ -72,6 +70,8 @@ Contributions are welcome! See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for de
7270
| Arch Linux | `sudo pacman -S base-devel openssl` |
7371
| Windows | VS build tools (see [Rustup's guide](https://rust-lang.github.io/rustup/installation/windows-msvc.html)) |
7472

73+
Tests additionally depend on `wasm-tools`, `mitmproxy`, and SoftHSM2.
74+
7575
### Build and Test
7676

7777
```bash

crates/icp-cli/Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ path = "src/main.rs"
1414
anstyle = "1.0.13"
1515
anyhow.workspace = true
1616
async-trait.workspace = true
17+
backoff.workspace = true
1718
bigdecimal.workspace = true
1819
bip32.workspace = true
1920
byte-unit.workspace = true
Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
use byte_unit::{Byte, UnitType};
2+
use clap::Args;
3+
use icp::context::Context;
4+
use icp::prelude::*;
5+
6+
use super::SnapshotId;
7+
use crate::commands::args;
8+
use crate::operations::misc::format_timestamp;
9+
use crate::operations::snapshot_transfer::{
10+
BlobType, SnapshotPaths, SnapshotTransferError, create_transfer_progress_bar,
11+
delete_download_progress, download_blob_to_file, download_wasm_chunk, load_download_progress,
12+
load_metadata, read_snapshot_metadata, save_metadata,
13+
};
14+
15+
#[derive(Debug, Args)]
16+
pub(crate) struct DownloadArgs {
17+
#[command(flatten)]
18+
pub(crate) cmd_args: args::CanisterCommandArgs,
19+
20+
/// The snapshot ID to download (hex-encoded)
21+
snapshot_id: SnapshotId,
22+
23+
/// Output directory for the snapshot files
24+
#[arg(long, short = 'o')]
25+
output: PathBuf,
26+
27+
/// Resume a previously interrupted download
28+
#[arg(long)]
29+
resume: bool,
30+
}
31+
32+
pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyhow::Error> {
33+
let selections = args.cmd_args.selections();
34+
35+
let agent = ctx
36+
.get_agent(
37+
&selections.identity,
38+
&selections.network,
39+
&selections.environment,
40+
)
41+
.await?;
42+
let cid = ctx
43+
.get_canister_id(
44+
&selections.canister,
45+
&selections.network,
46+
&selections.environment,
47+
)
48+
.await?;
49+
50+
let name = &args.cmd_args.canister;
51+
let snapshot_id = &args.snapshot_id.0;
52+
53+
// Open or create the snapshot directory with a lock
54+
let snapshot_dir = SnapshotPaths::new(args.output.clone())?;
55+
56+
snapshot_dir
57+
.with_write(async |paths| {
58+
// Ensure directories exist
59+
paths.ensure_dirs()?;
60+
61+
// Check if we should resume or start fresh
62+
let metadata = if args.resume && paths.metadata_path().exists() {
63+
ctx.term.write_line("Resuming previous download...")?;
64+
load_metadata(paths)?
65+
} else if !args.resume {
66+
// Check if directory has existing files (besides lock)
67+
let has_files = paths.metadata_path().exists()
68+
|| paths.wasm_module_path().exists()
69+
|| paths.wasm_memory_path().exists()
70+
|| paths.stable_memory_path().exists();
71+
72+
if has_files {
73+
return Err(SnapshotTransferError::DirectoryNotEmpty {
74+
path: args.output.clone(),
75+
}
76+
.into());
77+
}
78+
79+
// Fetch metadata from canister
80+
ctx.term.write_line(&format!(
81+
"Downloading snapshot {id} from canister {name} ({cid})",
82+
id = hex::encode(snapshot_id),
83+
))?;
84+
85+
let metadata = read_snapshot_metadata(&agent, cid, snapshot_id).await?;
86+
87+
ctx.term.write_line(&format!(
88+
" Timestamp: {}",
89+
format_timestamp(metadata.taken_at_timestamp)
90+
))?;
91+
92+
let total_size = metadata.wasm_module_size
93+
+ metadata.wasm_memory_size
94+
+ metadata.stable_memory_size;
95+
ctx.term.write_line(&format!(
96+
" Total size: {}",
97+
Byte::from_u64(total_size).get_appropriate_unit(UnitType::Binary)
98+
))?;
99+
100+
// Save metadata
101+
save_metadata(&metadata, paths)?;
102+
103+
metadata
104+
} else {
105+
return Err(SnapshotTransferError::NoExistingDownload {
106+
path: args.output.clone(),
107+
}
108+
.into());
109+
};
110+
111+
// Load download progress (handles gaps from previous interrupted downloads)
112+
let mut progress = load_download_progress(paths)?;
113+
114+
// Download WASM module
115+
if metadata.wasm_module_size > 0 {
116+
if !progress.wasm_module.is_complete(metadata.wasm_module_size) {
117+
let pb = create_transfer_progress_bar(metadata.wasm_module_size, "WASM module");
118+
download_blob_to_file(
119+
&agent,
120+
cid,
121+
snapshot_id,
122+
BlobType::WasmModule,
123+
metadata.wasm_module_size,
124+
paths,
125+
&mut progress,
126+
&pb,
127+
)
128+
.await?;
129+
pb.finish_with_message("done");
130+
} else {
131+
ctx.term.write_line("WASM module: already complete")?;
132+
}
133+
}
134+
135+
// Download WASM memory
136+
if metadata.wasm_memory_size > 0 {
137+
if !progress.wasm_memory.is_complete(metadata.wasm_memory_size) {
138+
let pb = create_transfer_progress_bar(metadata.wasm_memory_size, "WASM memory");
139+
download_blob_to_file(
140+
&agent,
141+
cid,
142+
snapshot_id,
143+
BlobType::WasmMemory,
144+
metadata.wasm_memory_size,
145+
paths,
146+
&mut progress,
147+
&pb,
148+
)
149+
.await?;
150+
pb.finish_with_message("done");
151+
} else {
152+
ctx.term.write_line("WASM memory: already complete")?;
153+
}
154+
}
155+
156+
// Download stable memory
157+
if metadata.stable_memory_size > 0 {
158+
if !progress
159+
.stable_memory
160+
.is_complete(metadata.stable_memory_size)
161+
{
162+
let pb =
163+
create_transfer_progress_bar(metadata.stable_memory_size, "Stable memory");
164+
download_blob_to_file(
165+
&agent,
166+
cid,
167+
snapshot_id,
168+
BlobType::StableMemory,
169+
metadata.stable_memory_size,
170+
paths,
171+
&mut progress,
172+
&pb,
173+
)
174+
.await?;
175+
pb.finish_with_message("done");
176+
} else {
177+
ctx.term.write_line("Stable memory: already complete")?;
178+
}
179+
} else {
180+
// Create empty stable memory file
181+
icp::fs::write(&paths.stable_memory_path(), &[])?;
182+
}
183+
184+
// Download WASM chunk store
185+
if !metadata.wasm_chunk_store.is_empty() {
186+
ctx.term.write_line(&format!(
187+
"Downloading {} WASM chunks...",
188+
metadata.wasm_chunk_store.len()
189+
))?;
190+
191+
for chunk_hash in &metadata.wasm_chunk_store {
192+
let chunk_path = paths.wasm_chunk_path(&chunk_hash.hash);
193+
if !chunk_path.exists() {
194+
download_wasm_chunk(&agent, cid, snapshot_id, chunk_hash, paths).await?;
195+
}
196+
}
197+
ctx.term.write_line("WASM chunks: done")?;
198+
}
199+
200+
// Clean up progress file on success
201+
delete_download_progress(paths)?;
202+
203+
ctx.term
204+
.write_line(&format!("Snapshot downloaded to {}", args.output))?;
205+
206+
Ok::<_, anyhow::Error>(())
207+
})
208+
.await??;
209+
210+
Ok(())
211+
}

crates/icp-cli/src/commands/canister/snapshot/mod.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,19 +4,25 @@ use clap::Subcommand;
44

55
pub(crate) mod create;
66
pub(crate) mod delete;
7+
pub(crate) mod download;
78
pub(crate) mod list;
89
pub(crate) mod restore;
10+
pub(crate) mod upload;
911

1012
#[derive(Subcommand, Debug)]
1113
pub(crate) enum Command {
1214
/// Create a snapshot of a canister's state
1315
Create(create::CreateArgs),
1416
/// Delete a canister snapshot
1517
Delete(delete::DeleteArgs),
18+
/// Download a snapshot to local disk
19+
Download(download::DownloadArgs),
1620
/// List all snapshots for a canister
1721
List(list::ListArgs),
1822
/// Restore a canister from a snapshot
1923
Restore(restore::RestoreArgs),
24+
/// Upload a snapshot from local disk
25+
Upload(upload::UploadArgs),
2026
}
2127

2228
/// A hex-encoded snapshot ID.

0 commit comments

Comments
 (0)