Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ cargo test
# Run tests for specific package
cargo test -p icp-cli

# Run a specific test
cargo test <test_name>
# Run a specific test from <test_file>.rs
cargo test --test <test_file> -- <test_name>

# Run with verbose output
cargo test -- --nocapture
Expand Down
3 changes: 2 additions & 1 deletion .github/scripts/provision-linux-test.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
#!/bin/bash
set -euo pipefail
sudo apt-get update && sudo apt-get install -y softhsm2
sudo apt-get update && sudo apt-get install -y softhsm2 pipx
pipx install mitmproxy
2 changes: 1 addition & 1 deletion .github/scripts/provision-macos-test.sh
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
#!/bin/bash
set -euo pipefail
brew install softhsm
brew install softhsm mitmproxy
2 changes: 1 addition & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Unreleased

* feat: `icp canister snapshot` - create, delete, restore, list canister snapshots
* feat: `icp canister snapshot` - create, delete, restore, list, download, and upload canister snapshots
* feat: `icp canister call` now supports `--proxy` flag to route calls through a proxy canister
* Use `--proxy <CANISTER_ID>` to forward the call through a proxy canister's `proxy` method
* Use `--cycles <AMOUNT>` to specify cycles to forward with the proxied call (defaults to 0)
Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ publish = false
anyhow = "1.0.100"
async-dropper = { version = "0.3.0", features = ["tokio", "simple"] }
async-trait = "0.1.88"
backoff = { version = "0.4", features = ["tokio"] }
bigdecimal = "0.4.10"
bip32 = "0.5.0"
bollard = "0.19.4"
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,6 @@ Contributions are welcome! See [CONTRIBUTING.md](.github/CONTRIBUTING.md) for de
### Prerequisites

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

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

Tests additionally depend on `wasm-tools`, `mitmproxy`, and SoftHSM2.

### Build and Test

```bash
Expand Down
1 change: 1 addition & 0 deletions crates/icp-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ path = "src/main.rs"
anstyle = "1.0.13"
anyhow.workspace = true
async-trait.workspace = true
backoff.workspace = true
bigdecimal.workspace = true
bip32.workspace = true
byte-unit.workspace = true
Expand Down
211 changes: 211 additions & 0 deletions crates/icp-cli/src/commands/canister/snapshot/download.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
use byte_unit::{Byte, UnitType};
use clap::Args;
use icp::context::Context;
use icp::prelude::*;

use super::SnapshotId;
use crate::commands::args;
use crate::operations::misc::format_timestamp;
use crate::operations::snapshot_transfer::{
BlobType, SnapshotPaths, SnapshotTransferError, create_transfer_progress_bar,
delete_download_progress, download_blob_to_file, download_wasm_chunk, load_download_progress,
load_metadata, read_snapshot_metadata, save_metadata,
};

#[derive(Debug, Args)]
pub(crate) struct DownloadArgs {
#[command(flatten)]
pub(crate) cmd_args: args::CanisterCommandArgs,

/// The snapshot ID to download (hex-encoded)
snapshot_id: SnapshotId,

/// Output directory for the snapshot files
#[arg(long, short = 'o')]
output: PathBuf,

/// Resume a previously interrupted download
#[arg(long)]
resume: bool,
}

pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyhow::Error> {
let selections = args.cmd_args.selections();

let agent = ctx
.get_agent(
&selections.identity,
&selections.network,
&selections.environment,
)
.await?;
let cid = ctx
.get_canister_id(
&selections.canister,
&selections.network,
&selections.environment,
)
.await?;

let name = &args.cmd_args.canister;
let snapshot_id = &args.snapshot_id.0;

// Open or create the snapshot directory with a lock
let snapshot_dir = SnapshotPaths::new(args.output.clone())?;

snapshot_dir
.with_write(async |paths| {
// Ensure directories exist
paths.ensure_dirs()?;

// Check if we should resume or start fresh
let metadata = if args.resume && paths.metadata_path().exists() {
ctx.term.write_line("Resuming previous download...")?;
load_metadata(paths)?
} else if !args.resume {
// Check if directory has existing files (besides lock)
let has_files = paths.metadata_path().exists()
|| paths.wasm_module_path().exists()
|| paths.wasm_memory_path().exists()
|| paths.stable_memory_path().exists();

if has_files {
return Err(SnapshotTransferError::DirectoryNotEmpty {
path: args.output.clone(),
}
.into());
}

// Fetch metadata from canister
ctx.term.write_line(&format!(
"Downloading snapshot {id} from canister {name} ({cid})",
id = hex::encode(snapshot_id),
))?;

let metadata = read_snapshot_metadata(&agent, cid, snapshot_id).await?;

ctx.term.write_line(&format!(
" Timestamp: {}",
format_timestamp(metadata.taken_at_timestamp)
))?;

let total_size = metadata.wasm_module_size
+ metadata.wasm_memory_size
+ metadata.stable_memory_size;
ctx.term.write_line(&format!(
" Total size: {}",
Byte::from_u64(total_size).get_appropriate_unit(UnitType::Binary)
))?;

// Save metadata
save_metadata(&metadata, paths)?;

metadata
} else {
return Err(SnapshotTransferError::NoExistingDownload {
path: args.output.clone(),
}
.into());
};

// Load download progress (handles gaps from previous interrupted downloads)
let mut progress = load_download_progress(paths)?;

// Download WASM module
if metadata.wasm_module_size > 0 {
if !progress.wasm_module.is_complete(metadata.wasm_module_size) {
let pb = create_transfer_progress_bar(metadata.wasm_module_size, "WASM module");
download_blob_to_file(
&agent,
cid,
snapshot_id,
BlobType::WasmModule,
metadata.wasm_module_size,
paths,
&mut progress,
&pb,
)
.await?;
pb.finish_with_message("done");
} else {
ctx.term.write_line("WASM module: already complete")?;
}
}

// Download WASM memory
if metadata.wasm_memory_size > 0 {
if !progress.wasm_memory.is_complete(metadata.wasm_memory_size) {
let pb = create_transfer_progress_bar(metadata.wasm_memory_size, "WASM memory");
download_blob_to_file(
&agent,
cid,
snapshot_id,
BlobType::WasmMemory,
metadata.wasm_memory_size,
paths,
&mut progress,
&pb,
)
.await?;
pb.finish_with_message("done");
} else {
ctx.term.write_line("WASM memory: already complete")?;
}
}

// Download stable memory
if metadata.stable_memory_size > 0 {
if !progress
.stable_memory
.is_complete(metadata.stable_memory_size)
{
let pb =
create_transfer_progress_bar(metadata.stable_memory_size, "Stable memory");
download_blob_to_file(
&agent,
cid,
snapshot_id,
BlobType::StableMemory,
metadata.stable_memory_size,
paths,
&mut progress,
&pb,
)
.await?;
pb.finish_with_message("done");
} else {
ctx.term.write_line("Stable memory: already complete")?;
}
} else {
// Create empty stable memory file
icp::fs::write(&paths.stable_memory_path(), &[])?;
}

// Download WASM chunk store
if !metadata.wasm_chunk_store.is_empty() {
ctx.term.write_line(&format!(
"Downloading {} WASM chunks...",
metadata.wasm_chunk_store.len()
))?;

for chunk_hash in &metadata.wasm_chunk_store {
let chunk_path = paths.wasm_chunk_path(&chunk_hash.hash);
if !chunk_path.exists() {
download_wasm_chunk(&agent, cid, snapshot_id, chunk_hash, paths).await?;
}
}
ctx.term.write_line("WASM chunks: done")?;
}

// Clean up progress file on success
delete_download_progress(paths)?;

ctx.term
.write_line(&format!("Snapshot downloaded to {}", args.output))?;

Ok::<_, anyhow::Error>(())
})
.await??;

Ok(())
}
6 changes: 6 additions & 0 deletions crates/icp-cli/src/commands/canister/snapshot/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,19 +4,25 @@ use clap::Subcommand;

pub(crate) mod create;
pub(crate) mod delete;
pub(crate) mod download;
pub(crate) mod list;
pub(crate) mod restore;
pub(crate) mod upload;

#[derive(Subcommand, Debug)]
pub(crate) enum Command {
/// Create a snapshot of a canister's state
Create(create::CreateArgs),
/// Delete a canister snapshot
Delete(delete::DeleteArgs),
/// Download a snapshot to local disk
Download(download::DownloadArgs),
/// List all snapshots for a canister
List(list::ListArgs),
/// Restore a canister from a snapshot
Restore(restore::RestoreArgs),
/// Upload a snapshot from local disk
Upload(upload::UploadArgs),
}

/// A hex-encoded snapshot ID.
Expand Down
Loading
Loading