From 177bac25900f01df2bd72af03adc0e61daa0ca79 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 15:07:52 +0800 Subject: [PATCH 01/10] Add stubs for canister snapshot related sub-commands. --- crates/icp-cli/src/commands/canister/mod.rs | 5 ++ .../src/commands/canister/snapshot/create.rs | 42 +++++++++++++ .../src/commands/canister/snapshot/delete.rs | 42 +++++++++++++ .../commands/canister/snapshot/download.rs | 55 +++++++++++++++++ .../src/commands/canister/snapshot/list.rs | 38 ++++++++++++ .../src/commands/canister/snapshot/load.rs | 42 +++++++++++++ .../src/commands/canister/snapshot/mod.rs | 60 +++++++++++++++++++ .../src/commands/canister/snapshot/upload.rs | 55 +++++++++++++++++ crates/icp-cli/src/main.rs | 38 ++++++++++++ 9 files changed, 377 insertions(+) create mode 100644 crates/icp-cli/src/commands/canister/snapshot/create.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/delete.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/download.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/list.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/load.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/mod.rs create mode 100644 crates/icp-cli/src/commands/canister/snapshot/upload.rs diff --git a/crates/icp-cli/src/commands/canister/mod.rs b/crates/icp-cli/src/commands/canister/mod.rs index 3525fe7e..260af1c5 100644 --- a/crates/icp-cli/src/commands/canister/mod.rs +++ b/crates/icp-cli/src/commands/canister/mod.rs @@ -9,6 +9,7 @@ pub(crate) mod install; pub(crate) mod list; pub(crate) mod settings; pub(crate) mod show; +pub(crate) mod snapshot; pub(crate) mod start; pub(crate) mod status; pub(crate) mod stop; @@ -27,6 +28,10 @@ pub enum Command { Settings(settings::Command), Show(show::ShowArgs), + + #[command(subcommand)] + Snapshot(snapshot::Command), + Start(start::StartArgs), Status(status::StatusArgs), Stop(stop::StopArgs), diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs new file mode 100644 index 00000000..f70fac07 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -0,0 +1,42 @@ +use clap::Args; +use icp::identity; + +use crate::commands::canister::snapshot::SnapshotId; +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct CreateArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, + + /// If a snapshot ID is specified, this snapshot will replace it and reuse the ID. + replace: Option, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &CreateArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs new file mode 100644 index 00000000..32f10940 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -0,0 +1,42 @@ +use clap::Args; +use icp::identity; + +use crate::commands::canister::snapshot::SnapshotId; +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct DeleteArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, + + /// The ID of the snapshot to delete. + snapshot: SnapshotId, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &DeleteArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/download.rs b/crates/icp-cli/src/commands/canister/snapshot/download.rs new file mode 100644 index 00000000..43e8c4bb --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/download.rs @@ -0,0 +1,55 @@ +use clap::Args; +use icp::identity; +use std::path::PathBuf; + +use crate::commands::canister::snapshot::{SnapshotId, directory_parser}; +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct DownloadArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, + + /// The ID of the snapshot to download. + snapshot: SnapshotId, + + /// The directory to download the snapshot to. + #[arg(long, value_parser = directory_parser)] + dir: PathBuf, + + /// Whether to resume the download if the previous snapshot download failed. + #[arg(short, long, default_value = "false")] + resume: bool, + + /// The number of concurrent downloads to perform. + #[arg(long, default_value = "3")] + concurrency: usize, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &DownloadArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/list.rs b/crates/icp-cli/src/commands/canister/snapshot/list.rs new file mode 100644 index 00000000..1ca4ada4 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/list.rs @@ -0,0 +1,38 @@ +use clap::Args; +use icp::identity; + +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct ListArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &ListArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/load.rs b/crates/icp-cli/src/commands/canister/snapshot/load.rs new file mode 100644 index 00000000..4b834b01 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/load.rs @@ -0,0 +1,42 @@ +use clap::Args; +use icp::identity; + +use crate::commands::canister::snapshot::SnapshotId; +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct LoadArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, + + /// The ID of the snapshot to load. + snapshot: SnapshotId, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &LoadArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/mod.rs b/crates/icp-cli/src/commands/canister/snapshot/mod.rs new file mode 100644 index 00000000..c40d3fa3 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/mod.rs @@ -0,0 +1,60 @@ +use clap::Subcommand; +use ic_management_canister_types::UploadCanisterSnapshotMetadataResult; +use serde::{Deserialize, Serialize}; +use std::{ + fmt::{self, Display, Formatter}, + path::PathBuf, + str::FromStr, +}; + +pub(crate) mod create; +pub(crate) mod delete; +pub(crate) mod download; +pub(crate) mod list; +pub(crate) mod load; +pub(crate) mod upload; + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SnapshotId(Vec); + +impl Display for SnapshotId { + fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { + f.write_str(&hex::encode(&self.0)) + } +} + +impl FromStr for SnapshotId { + type Err = anyhow::Error; + fn from_str(s: &str) -> Result { + Ok(Self(hex::decode(s)?)) + } +} + +impl From for SnapshotId { + fn from(canister_snapshot_id: UploadCanisterSnapshotMetadataResult) -> Self { + SnapshotId(canister_snapshot_id.snapshot_id) + } +} + +fn directory_parser(path: &str) -> Result { + let path = PathBuf::from(path); + if path.is_dir() { + Ok(path) + } else { + Err(format!( + "Path '{}' does not exist or is not a directory.", + path.display() + )) + } +} + +#[derive(Subcommand, Debug)] +#[allow(clippy::large_enum_variant)] +pub enum Command { + Create(create::CreateArgs), + Delete(delete::DeleteArgs), + Download(download::DownloadArgs), + List(list::ListArgs), + Load(load::LoadArgs), + Upload(upload::UploadArgs), +} diff --git a/crates/icp-cli/src/commands/canister/snapshot/upload.rs b/crates/icp-cli/src/commands/canister/snapshot/upload.rs new file mode 100644 index 00000000..de1a3d07 --- /dev/null +++ b/crates/icp-cli/src/commands/canister/snapshot/upload.rs @@ -0,0 +1,55 @@ +use clap::Args; +use icp::identity; +use std::path::PathBuf; + +use crate::commands::canister::snapshot::{SnapshotId, directory_parser}; +use crate::commands::{Context, Mode}; +use crate::options::{EnvironmentOpt, IdentityOpt}; + +#[derive(Debug, Args)] +pub struct UploadArgs { + /// The name of the canister within the current project + name: String, + + #[command(flatten)] + identity: IdentityOpt, + + #[command(flatten)] + environment: EnvironmentOpt, + + /// If a snapshot ID is specified, this snapshot will replace it and reuse the ID. + replace: Option, + + /// The directory to upload the snapshot from. + #[arg(long, value_parser = directory_parser)] + dir: PathBuf, + + /// The snapshot ID to resume uploading to. + #[arg(short, long)] + resume: Option, + + /// The number of concurrent uploads to perform. + #[arg(long, default_value = "3")] + concurrency: usize, +} + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), +} + +pub async fn exec(ctx: &Context, _args: &UploadArgs) -> Result<(), CommandError> { + match &ctx.mode { + Mode::Global => { + unimplemented!("global mode is not implemented yet"); + } + + Mode::Project(_) => { + unimplemented!("project mode is not implemented yet"); + } + } +} diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index 9b9d80c5..7bdea939 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -312,6 +312,44 @@ async fn main() -> Result<(), Error> { .await? } + commands::canister::Command::Snapshot(cmd) => match cmd { + commands::canister::snapshot::Command::Create(args) => { + commands::canister::snapshot::create::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + + commands::canister::snapshot::Command::Delete(args) => { + commands::canister::snapshot::delete::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + + commands::canister::snapshot::Command::Download(args) => { + commands::canister::snapshot::download::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + + commands::canister::snapshot::Command::List(args) => { + commands::canister::snapshot::list::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + + commands::canister::snapshot::Command::Load(args) => { + commands::canister::snapshot::load::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + + commands::canister::snapshot::Command::Upload(args) => { + commands::canister::snapshot::upload::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + }, + commands::canister::Command::Start(args) => { commands::canister::start::exec(&ctx, &args) .instrument(trace_span) From 2a1a932a0675a62840f9d422df8660d4cc23bce2 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 15:15:53 +0800 Subject: [PATCH 02/10] Fixed linting by using icp::prelude::PathBuf instead of std::path::PathBuf. --- crates/icp-cli/src/commands/canister/snapshot/download.rs | 3 +-- crates/icp-cli/src/commands/canister/snapshot/mod.rs | 4 ++-- crates/icp-cli/src/commands/canister/snapshot/upload.rs | 3 +-- 3 files changed, 4 insertions(+), 6 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/snapshot/download.rs b/crates/icp-cli/src/commands/canister/snapshot/download.rs index 43e8c4bb..a277b9c6 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/download.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/download.rs @@ -1,6 +1,5 @@ use clap::Args; -use icp::identity; -use std::path::PathBuf; +use icp::{identity, prelude::PathBuf}; use crate::commands::canister::snapshot::{SnapshotId, directory_parser}; use crate::commands::{Context, Mode}; diff --git a/crates/icp-cli/src/commands/canister/snapshot/mod.rs b/crates/icp-cli/src/commands/canister/snapshot/mod.rs index c40d3fa3..abb85208 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/mod.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/mod.rs @@ -1,9 +1,9 @@ use clap::Subcommand; use ic_management_canister_types::UploadCanisterSnapshotMetadataResult; +use icp::prelude::PathBuf; use serde::{Deserialize, Serialize}; use std::{ fmt::{self, Display, Formatter}, - path::PathBuf, str::FromStr, }; @@ -43,7 +43,7 @@ fn directory_parser(path: &str) -> Result { } else { Err(format!( "Path '{}' does not exist or is not a directory.", - path.display() + path )) } } diff --git a/crates/icp-cli/src/commands/canister/snapshot/upload.rs b/crates/icp-cli/src/commands/canister/snapshot/upload.rs index de1a3d07..25a373ef 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/upload.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/upload.rs @@ -1,6 +1,5 @@ use clap::Args; -use icp::identity; -use std::path::PathBuf; +use icp::{identity, prelude::PathBuf}; use crate::commands::canister::snapshot::{SnapshotId, directory_parser}; use crate::commands::{Context, Mode}; From 462c8f4524b32d6c19f760615d581cdc2fd8878b Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 18:15:03 +0800 Subject: [PATCH 03/10] Implement canister snapshot create. --- .../src/commands/canister/snapshot/create.rs | 95 +++++++++++++++++-- 1 file changed, 89 insertions(+), 6 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index f70fac07..0d57f1bb 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -1,9 +1,13 @@ use clap::Args; -use icp::identity; +use ic_agent::AgentError; +use ic_management_canister_types::TakeCanisterSnapshotArgs; +use icp::{agent, identity, network}; -use crate::commands::canister::snapshot::SnapshotId; -use crate::commands::{Context, Mode}; -use crate::options::{EnvironmentOpt, IdentityOpt}; +use crate::{ + commands::{Context, Mode, canister::snapshot::SnapshotId}, + options::{EnvironmentOpt, IdentityOpt}, + store_id::{Key, LookupError as LookupIdError}, +}; #[derive(Debug, Args)] pub struct CreateArgs { @@ -27,16 +31,95 @@ pub enum CommandError { #[error(transparent)] Identity(#[from] identity::LoadError), + + #[error("project does not contain an environment named '{name}'")] + EnvironmentNotFound { name: String }, + + #[error(transparent)] + Access(#[from] network::AccessError), + + #[error(transparent)] + Agent(#[from] agent::CreateError), + + #[error("environment '{environment}' does not include canister '{canister}'")] + EnvironmentCanister { + environment: String, + canister: String, + }, + + #[error(transparent)] + Lookup(#[from] LookupIdError), + + #[error(transparent)] + Status(#[from] AgentError), } -pub async fn exec(ctx: &Context, _args: &CreateArgs) -> Result<(), CommandError> { +pub async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { unimplemented!("global mode is not implemented yet"); } Mode::Project(_) => { - unimplemented!("project mode is not implemented yet"); + // Load project + let p = ctx.project.load().await?; + + // Load identity + let id = ctx.identity.load(args.identity.clone().into()).await?; + + // Load target environment + let env = p.environments.get(args.environment.name()).ok_or( + CommandError::EnvironmentNotFound { + name: args.environment.name().to_owned(), + }, + )?; + + // Access network + let access = ctx.network.access(&env.network).await?; + + // Agent + let agent = ctx.agent.create(id, &access.url).await?; + + if let Some(k) = access.root_key { + agent.set_root_key(k); + } + + // Ensure canister is included in the environment + if !env.canisters.contains_key(&args.name) { + return Err(CommandError::EnvironmentCanister { + environment: env.name.to_owned(), + canister: args.name.to_owned(), + }); + } + + // Lookup the canister id + let cid = ctx.ids.lookup(&Key { + network: env.network.name.to_owned(), + environment: env.name.to_owned(), + canister: args.name.to_owned(), + })?; + + // Management Interface + let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + + // Create snapshot + let (snapshot,) = mgmt + .take_canister_snapshot( + &cid, + &TakeCanisterSnapshotArgs { + canister_id: cid, + replace_snapshot: args.replace.as_ref().map(|id| id.0.clone()), + }, + ) + .await?; + + eprintln!( + "Created a new snapshot of canister '{}'. Snapshot ID: '{}'", + args.name, + SnapshotId(snapshot.id) + ); } } + + Ok(()) } From da8062b96f19b1494bfd1a156e13fa8e7362f729 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 20:32:01 +0800 Subject: [PATCH 04/10] Implement canister snapshot list. --- Cargo.lock | 1 + crates/icp-cli/Cargo.toml | 1 + .../src/commands/canister/snapshot/list.rs | 103 +++++++++++++++++- 3 files changed, 100 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 795f8911..53f3793c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2537,6 +2537,7 @@ dependencies = [ "serial_test", "snafu", "thiserror 2.0.17", + "time", "tiny-bip39 2.0.0", "tokio", "tracing", diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index 5d1e06c2..ad44dd2f 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -47,6 +47,7 @@ serde_yaml.workspace = true serde.workspace = true snafu.workspace = true thiserror.workspace = true +time.workspace = true tiny-bip39.workspace = true tokio.workspace = true tracing-subscriber.workspace = true diff --git a/crates/icp-cli/src/commands/canister/snapshot/list.rs b/crates/icp-cli/src/commands/canister/snapshot/list.rs index 1ca4ada4..80697d4a 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/list.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/list.rs @@ -1,8 +1,15 @@ use clap::Args; -use icp::identity; +use ic_agent::AgentError; +use ic_management_canister_types::Snapshot; +use icp::{agent, identity, network}; +use indicatif::HumanBytes; +use time::{OffsetDateTime, macros::format_description}; -use crate::commands::{Context, Mode}; -use crate::options::{EnvironmentOpt, IdentityOpt}; +use crate::{ + commands::{Context, Mode, canister::snapshot::SnapshotId}, + options::{EnvironmentOpt, IdentityOpt}, + store_id::{Key, LookupError as LookupIdError}, +}; #[derive(Debug, Args)] pub struct ListArgs { @@ -23,16 +30,102 @@ pub enum CommandError { #[error(transparent)] Identity(#[from] identity::LoadError), + + #[error("project does not contain an environment named '{name}'")] + EnvironmentNotFound { name: String }, + + #[error(transparent)] + Access(#[from] network::AccessError), + + #[error(transparent)] + Agent(#[from] agent::CreateError), + + #[error("environment '{environment}' does not include canister '{canister}'")] + EnvironmentCanister { + environment: String, + canister: String, + }, + + #[error(transparent)] + Lookup(#[from] LookupIdError), + + #[error(transparent)] + Status(#[from] AgentError), } -pub async fn exec(ctx: &Context, _args: &ListArgs) -> Result<(), CommandError> { +pub async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { unimplemented!("global mode is not implemented yet"); } Mode::Project(_) => { - unimplemented!("project mode is not implemented yet"); + // Load project + let p = ctx.project.load().await?; + + // Load identity + let id = ctx.identity.load(args.identity.clone().into()).await?; + + // Load target environment + let env = p.environments.get(args.environment.name()).ok_or( + CommandError::EnvironmentNotFound { + name: args.environment.name().to_owned(), + }, + )?; + + // Access network + let access = ctx.network.access(&env.network).await?; + + // Agent + let agent = ctx.agent.create(id, &access.url).await?; + + if let Some(k) = access.root_key { + agent.set_root_key(k); + } + + // Ensure canister is included in the environment + if !env.canisters.contains_key(&args.name) { + return Err(CommandError::EnvironmentCanister { + environment: env.name.to_owned(), + canister: args.name.to_owned(), + }); + } + + // Lookup the canister id + let cid = ctx.ids.lookup(&Key { + network: env.network.name.to_owned(), + environment: env.name.to_owned(), + canister: args.name.to_owned(), + })?; + + // Management Interface + let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + + let (snapshots,) = mgmt.list_canister_snapshots(&cid).await?; + + if snapshots.is_empty() { + eprintln!("No snapshots found for canister '{}'", args.name); + } else { + for snapshot in snapshots { + print_snapshot(&snapshot); + } + } } } + + Ok(()) +} + +fn print_snapshot(snapshot: &Snapshot) { + let time_fmt = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC"); + + eprintln!( + "{}: {}, taken at {}", + SnapshotId(snapshot.id.clone()), + HumanBytes(snapshot.total_size), + OffsetDateTime::from_unix_timestamp_nanos(snapshot.taken_at_timestamp as i128) + .unwrap() + .format(time_fmt) + .unwrap() + ); } From 32d6cd8c2c07c65b37fd62c8d77d669256c93d67 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 20:49:10 +0800 Subject: [PATCH 05/10] Use arg long for replacing snapshot id. --- crates/icp-cli/src/commands/canister/snapshot/create.rs | 1 + crates/icp-cli/src/commands/canister/snapshot/upload.rs | 1 + 2 files changed, 2 insertions(+) diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index 0d57f1bb..0d63503a 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -21,6 +21,7 @@ pub struct CreateArgs { environment: EnvironmentOpt, /// If a snapshot ID is specified, this snapshot will replace it and reuse the ID. + #[arg(long)] replace: Option, } diff --git a/crates/icp-cli/src/commands/canister/snapshot/upload.rs b/crates/icp-cli/src/commands/canister/snapshot/upload.rs index 25a373ef..0744f6c7 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/upload.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/upload.rs @@ -17,6 +17,7 @@ pub struct UploadArgs { environment: EnvironmentOpt, /// If a snapshot ID is specified, this snapshot will replace it and reuse the ID. + #[arg(long)] replace: Option, /// The directory to upload the snapshot from. From 096036622f1b3b58fcd0b367580fdfa464d4a472 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Sat, 18 Oct 2025 21:24:49 +0800 Subject: [PATCH 06/10] Implement canister snapshot load and delete. --- .../src/commands/canister/snapshot/delete.rs | 93 ++++++++++++++++-- .../src/commands/canister/snapshot/load.rs | 94 +++++++++++++++++-- 2 files changed, 175 insertions(+), 12 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs index 32f10940..61ffeeec 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/delete.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -1,9 +1,13 @@ use clap::Args; -use icp::identity; +use ic_agent::AgentError; +use ic_management_canister_types::DeleteCanisterSnapshotArgs; +use icp::{agent, identity, network}; -use crate::commands::canister::snapshot::SnapshotId; -use crate::commands::{Context, Mode}; -use crate::options::{EnvironmentOpt, IdentityOpt}; +use crate::{ + commands::{Context, Mode, canister::snapshot::SnapshotId}, + options::{EnvironmentOpt, IdentityOpt}, + store_id::{Key, LookupError as LookupIdError}, +}; #[derive(Debug, Args)] pub struct DeleteArgs { @@ -27,16 +31,93 @@ pub enum CommandError { #[error(transparent)] Identity(#[from] identity::LoadError), + + #[error("project does not contain an environment named '{name}'")] + EnvironmentNotFound { name: String }, + + #[error(transparent)] + Access(#[from] network::AccessError), + + #[error(transparent)] + Agent(#[from] agent::CreateError), + + #[error("environment '{environment}' does not include canister '{canister}'")] + EnvironmentCanister { + environment: String, + canister: String, + }, + + #[error(transparent)] + Lookup(#[from] LookupIdError), + + #[error(transparent)] + Status(#[from] AgentError), } -pub async fn exec(ctx: &Context, _args: &DeleteArgs) -> Result<(), CommandError> { +pub async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { unimplemented!("global mode is not implemented yet"); } Mode::Project(_) => { - unimplemented!("project mode is not implemented yet"); + // Load project + let p = ctx.project.load().await?; + + // Load identity + let id = ctx.identity.load(args.identity.clone().into()).await?; + + // Load target environment + let env = p.environments.get(args.environment.name()).ok_or( + CommandError::EnvironmentNotFound { + name: args.environment.name().to_owned(), + }, + )?; + + // Access network + let access = ctx.network.access(&env.network).await?; + + // Agent + let agent = ctx.agent.create(id, &access.url).await?; + + if let Some(k) = access.root_key { + agent.set_root_key(k); + } + + // Ensure canister is included in the environment + if !env.canisters.contains_key(&args.name) { + return Err(CommandError::EnvironmentCanister { + environment: env.name.to_owned(), + canister: args.name.to_owned(), + }); + } + + // Lookup the canister id + let cid = ctx.ids.lookup(&Key { + network: env.network.name.to_owned(), + environment: env.name.to_owned(), + canister: args.name.to_owned(), + })?; + + // Management Interface + let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + + // Delete snapshot + mgmt.delete_canister_snapshot( + &cid, + &DeleteCanisterSnapshotArgs { + canister_id: cid, + snapshot_id: args.snapshot.0.clone(), + }, + ) + .await?; + + eprintln!( + "Deleted snapshot {} from canister '{}'", + args.snapshot, args.name, + ); } } + + Ok(()) } diff --git a/crates/icp-cli/src/commands/canister/snapshot/load.rs b/crates/icp-cli/src/commands/canister/snapshot/load.rs index 4b834b01..015af92c 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/load.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/load.rs @@ -1,9 +1,13 @@ use clap::Args; -use icp::identity; +use ic_agent::AgentError; +use ic_management_canister_types::LoadCanisterSnapshotArgs; +use icp::{agent, identity, network}; -use crate::commands::canister::snapshot::SnapshotId; -use crate::commands::{Context, Mode}; -use crate::options::{EnvironmentOpt, IdentityOpt}; +use crate::{ + commands::{Context, Mode, canister::snapshot::SnapshotId}, + options::{EnvironmentOpt, IdentityOpt}, + store_id::{Key, LookupError as LookupIdError}, +}; #[derive(Debug, Args)] pub struct LoadArgs { @@ -27,16 +31,94 @@ pub enum CommandError { #[error(transparent)] Identity(#[from] identity::LoadError), + + #[error("project does not contain an environment named '{name}'")] + EnvironmentNotFound { name: String }, + + #[error(transparent)] + Access(#[from] network::AccessError), + + #[error(transparent)] + Agent(#[from] agent::CreateError), + + #[error("environment '{environment}' does not include canister '{canister}'")] + EnvironmentCanister { + environment: String, + canister: String, + }, + + #[error(transparent)] + Lookup(#[from] LookupIdError), + + #[error(transparent)] + Status(#[from] AgentError), } -pub async fn exec(ctx: &Context, _args: &LoadArgs) -> Result<(), CommandError> { +pub async fn exec(ctx: &Context, args: &LoadArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { unimplemented!("global mode is not implemented yet"); } Mode::Project(_) => { - unimplemented!("project mode is not implemented yet"); + // Load project + let p = ctx.project.load().await?; + + // Load identity + let id = ctx.identity.load(args.identity.clone().into()).await?; + + // Load target environment + let env = p.environments.get(args.environment.name()).ok_or( + CommandError::EnvironmentNotFound { + name: args.environment.name().to_owned(), + }, + )?; + + // Access network + let access = ctx.network.access(&env.network).await?; + + // Agent + let agent = ctx.agent.create(id, &access.url).await?; + + if let Some(k) = access.root_key { + agent.set_root_key(k); + } + + // Ensure canister is included in the environment + if !env.canisters.contains_key(&args.name) { + return Err(CommandError::EnvironmentCanister { + environment: env.name.to_owned(), + canister: args.name.to_owned(), + }); + } + + // Lookup the canister id + let cid = ctx.ids.lookup(&Key { + network: env.network.name.to_owned(), + environment: env.name.to_owned(), + canister: args.name.to_owned(), + })?; + + // Management Interface + let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + + // Load snapshot + mgmt.load_canister_snapshot( + &cid, + &LoadCanisterSnapshotArgs { + canister_id: cid, + snapshot_id: args.snapshot.0.clone(), + sender_canister_version: None, + }, + ) + .await?; + + eprintln!( + "Loaded snapshot {} into canister '{}'", + args.snapshot, args.name, + ); } } + + Ok(()) } From 7d7089835bbb441ea6abbf25cb3057df9897bdba Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Mon, 20 Oct 2025 11:59:58 +0800 Subject: [PATCH 07/10] Remove the duplication of the CommandError --- .../src/commands/canister/snapshot/create.rs | 39 +++---------------- .../src/commands/canister/snapshot/delete.rs | 39 +++---------------- .../src/commands/canister/snapshot/list.rs | 39 +++---------------- .../src/commands/canister/snapshot/load.rs | 39 +++---------------- .../src/commands/canister/snapshot/mod.rs | 35 ++++++++++++++++- 5 files changed, 54 insertions(+), 137 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index 0d63503a..b748a15e 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -1,12 +1,13 @@ use clap::Args; -use ic_agent::AgentError; use ic_management_canister_types::TakeCanisterSnapshotArgs; -use icp::{agent, identity, network}; use crate::{ - commands::{Context, Mode, canister::snapshot::SnapshotId}, + commands::{ + Context, Mode, + canister::snapshot::{CommandError, SnapshotId}, + }, options::{EnvironmentOpt, IdentityOpt}, - store_id::{Key, LookupError as LookupIdError}, + store_id::Key, }; #[derive(Debug, Args)] @@ -25,36 +26,6 @@ pub struct CreateArgs { replace: Option, } -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error(transparent)] - Project(#[from] icp::LoadError), - - #[error(transparent)] - Identity(#[from] identity::LoadError), - - #[error("project does not contain an environment named '{name}'")] - EnvironmentNotFound { name: String }, - - #[error(transparent)] - Access(#[from] network::AccessError), - - #[error(transparent)] - Agent(#[from] agent::CreateError), - - #[error("environment '{environment}' does not include canister '{canister}'")] - EnvironmentCanister { - environment: String, - canister: String, - }, - - #[error(transparent)] - Lookup(#[from] LookupIdError), - - #[error(transparent)] - Status(#[from] AgentError), -} - pub async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs index 61ffeeec..4b787c76 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/delete.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -1,12 +1,13 @@ use clap::Args; -use ic_agent::AgentError; use ic_management_canister_types::DeleteCanisterSnapshotArgs; -use icp::{agent, identity, network}; use crate::{ - commands::{Context, Mode, canister::snapshot::SnapshotId}, + commands::{ + Context, Mode, + canister::snapshot::{CommandError, SnapshotId}, + }, options::{EnvironmentOpt, IdentityOpt}, - store_id::{Key, LookupError as LookupIdError}, + store_id::Key, }; #[derive(Debug, Args)] @@ -24,36 +25,6 @@ pub struct DeleteArgs { snapshot: SnapshotId, } -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error(transparent)] - Project(#[from] icp::LoadError), - - #[error(transparent)] - Identity(#[from] identity::LoadError), - - #[error("project does not contain an environment named '{name}'")] - EnvironmentNotFound { name: String }, - - #[error(transparent)] - Access(#[from] network::AccessError), - - #[error(transparent)] - Agent(#[from] agent::CreateError), - - #[error("environment '{environment}' does not include canister '{canister}'")] - EnvironmentCanister { - environment: String, - canister: String, - }, - - #[error(transparent)] - Lookup(#[from] LookupIdError), - - #[error(transparent)] - Status(#[from] AgentError), -} - pub async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { diff --git a/crates/icp-cli/src/commands/canister/snapshot/list.rs b/crates/icp-cli/src/commands/canister/snapshot/list.rs index 80697d4a..934d99a7 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/list.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/list.rs @@ -1,14 +1,15 @@ use clap::Args; -use ic_agent::AgentError; use ic_management_canister_types::Snapshot; -use icp::{agent, identity, network}; use indicatif::HumanBytes; use time::{OffsetDateTime, macros::format_description}; use crate::{ - commands::{Context, Mode, canister::snapshot::SnapshotId}, + commands::{ + Context, Mode, + canister::snapshot::{CommandError, SnapshotId}, + }, options::{EnvironmentOpt, IdentityOpt}, - store_id::{Key, LookupError as LookupIdError}, + store_id::Key, }; #[derive(Debug, Args)] @@ -23,36 +24,6 @@ pub struct ListArgs { environment: EnvironmentOpt, } -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error(transparent)] - Project(#[from] icp::LoadError), - - #[error(transparent)] - Identity(#[from] identity::LoadError), - - #[error("project does not contain an environment named '{name}'")] - EnvironmentNotFound { name: String }, - - #[error(transparent)] - Access(#[from] network::AccessError), - - #[error(transparent)] - Agent(#[from] agent::CreateError), - - #[error("environment '{environment}' does not include canister '{canister}'")] - EnvironmentCanister { - environment: String, - canister: String, - }, - - #[error(transparent)] - Lookup(#[from] LookupIdError), - - #[error(transparent)] - Status(#[from] AgentError), -} - pub async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { diff --git a/crates/icp-cli/src/commands/canister/snapshot/load.rs b/crates/icp-cli/src/commands/canister/snapshot/load.rs index 015af92c..a77ef9dd 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/load.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/load.rs @@ -1,12 +1,13 @@ use clap::Args; -use ic_agent::AgentError; use ic_management_canister_types::LoadCanisterSnapshotArgs; -use icp::{agent, identity, network}; use crate::{ - commands::{Context, Mode, canister::snapshot::SnapshotId}, + commands::{ + Context, Mode, + canister::snapshot::{CommandError, SnapshotId}, + }, options::{EnvironmentOpt, IdentityOpt}, - store_id::{Key, LookupError as LookupIdError}, + store_id::Key, }; #[derive(Debug, Args)] @@ -24,36 +25,6 @@ pub struct LoadArgs { snapshot: SnapshotId, } -#[derive(Debug, thiserror::Error)] -pub enum CommandError { - #[error(transparent)] - Project(#[from] icp::LoadError), - - #[error(transparent)] - Identity(#[from] identity::LoadError), - - #[error("project does not contain an environment named '{name}'")] - EnvironmentNotFound { name: String }, - - #[error(transparent)] - Access(#[from] network::AccessError), - - #[error(transparent)] - Agent(#[from] agent::CreateError), - - #[error("environment '{environment}' does not include canister '{canister}'")] - EnvironmentCanister { - environment: String, - canister: String, - }, - - #[error(transparent)] - Lookup(#[from] LookupIdError), - - #[error(transparent)] - Status(#[from] AgentError), -} - pub async fn exec(ctx: &Context, args: &LoadArgs) -> Result<(), CommandError> { match &ctx.mode { Mode::Global => { diff --git a/crates/icp-cli/src/commands/canister/snapshot/mod.rs b/crates/icp-cli/src/commands/canister/snapshot/mod.rs index abb85208..233e458c 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/mod.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/mod.rs @@ -1,12 +1,15 @@ use clap::Subcommand; +use ic_agent::AgentError; use ic_management_canister_types::UploadCanisterSnapshotMetadataResult; -use icp::prelude::PathBuf; +use icp::{agent, identity, network, prelude::PathBuf}; use serde::{Deserialize, Serialize}; use std::{ fmt::{self, Display, Formatter}, str::FromStr, }; +use crate::store_id::LookupError as LookupIdError; + pub(crate) mod create; pub(crate) mod delete; pub(crate) mod download; @@ -58,3 +61,33 @@ pub enum Command { Load(load::LoadArgs), Upload(upload::UploadArgs), } + +#[derive(Debug, thiserror::Error)] +pub enum CommandError { + #[error(transparent)] + Project(#[from] icp::LoadError), + + #[error(transparent)] + Identity(#[from] identity::LoadError), + + #[error("project does not contain an environment named '{name}'")] + EnvironmentNotFound { name: String }, + + #[error(transparent)] + Access(#[from] network::AccessError), + + #[error(transparent)] + Agent(#[from] agent::CreateError), + + #[error("environment '{environment}' does not include canister '{canister}'")] + EnvironmentCanister { + environment: String, + canister: String, + }, + + #[error(transparent)] + Lookup(#[from] LookupIdError), + + #[error(transparent)] + Status(#[from] AgentError), +} From 4f2d1bea91fdd0c469e0baeaecaefe67fa18a60c Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Mon, 20 Oct 2025 15:42:54 +0800 Subject: [PATCH 08/10] Ensure canister is stopped before taking/loading snapshot. --- .../src/commands/canister/snapshot/create.rs | 6 +++++- .../src/commands/canister/snapshot/load.rs | 6 +++++- .../src/commands/canister/snapshot/mod.rs | 17 ++++++++++++++++- 3 files changed, 26 insertions(+), 3 deletions(-) diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index b748a15e..2691d74e 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -4,7 +4,7 @@ use ic_management_canister_types::TakeCanisterSnapshotArgs; use crate::{ commands::{ Context, Mode, - canister::snapshot::{CommandError, SnapshotId}, + canister::snapshot::{CommandError, SnapshotId, ensure_canister_stopped}, }, options::{EnvironmentOpt, IdentityOpt}, store_id::Key, @@ -74,6 +74,10 @@ pub async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), CommandError> // Management Interface let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + // Ensure canister is stopped + let (status,) = mgmt.canister_status(&cid).await?; + ensure_canister_stopped(status.status, &args.name)?; + // Create snapshot let (snapshot,) = mgmt .take_canister_snapshot( diff --git a/crates/icp-cli/src/commands/canister/snapshot/load.rs b/crates/icp-cli/src/commands/canister/snapshot/load.rs index a77ef9dd..f85951c0 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/load.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/load.rs @@ -4,7 +4,7 @@ use ic_management_canister_types::LoadCanisterSnapshotArgs; use crate::{ commands::{ Context, Mode, - canister::snapshot::{CommandError, SnapshotId}, + canister::snapshot::{CommandError, SnapshotId, ensure_canister_stopped}, }, options::{EnvironmentOpt, IdentityOpt}, store_id::Key, @@ -73,6 +73,10 @@ pub async fn exec(ctx: &Context, args: &LoadArgs) -> Result<(), CommandError> { // Management Interface let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); + // Ensure canister is stopped + let (status,) = mgmt.canister_status(&cid).await?; + ensure_canister_stopped(status.status, &args.name)?; + // Load snapshot mgmt.load_canister_snapshot( &cid, diff --git a/crates/icp-cli/src/commands/canister/snapshot/mod.rs b/crates/icp-cli/src/commands/canister/snapshot/mod.rs index 233e458c..bcbd77d8 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/mod.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/mod.rs @@ -1,6 +1,6 @@ use clap::Subcommand; use ic_agent::AgentError; -use ic_management_canister_types::UploadCanisterSnapshotMetadataResult; +use ic_management_canister_types::{CanisterStatusType, UploadCanisterSnapshotMetadataResult}; use icp::{agent, identity, network, prelude::PathBuf}; use serde::{Deserialize, Serialize}; use std::{ @@ -39,6 +39,18 @@ impl From for SnapshotId { } } +fn ensure_canister_stopped(status: CanisterStatusType, canister: &str) -> Result<(), CommandError> { + match status { + CanisterStatusType::Stopped => Ok(()), + CanisterStatusType::Running => Err(CommandError::CanisterNotStopped(format!( + "Canister {canister} is running. Run `dfx canister stop` to stop it first" + ))), + CanisterStatusType::Stopping => Err(CommandError::CanisterNotStopped(format!( + "Canister {canister} is stopping but is not yet stopped. Wait a few seconds and try again" + ))), + } +} + fn directory_parser(path: &str) -> Result { let path = PathBuf::from(path); if path.is_dir() { @@ -90,4 +102,7 @@ pub enum CommandError { #[error(transparent)] Status(#[from] AgentError), + + #[error("{0}")] + CanisterNotStopped(String), } From 08c38bb1536f25ac78e401d183f97a8bde0c7eaa Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Mon, 20 Oct 2025 17:05:27 +0800 Subject: [PATCH 09/10] Added tests for canister snapshot. --- Cargo.lock | 1 + crates/icp-cli/Cargo.toml | 1 + .../src/commands/canister/snapshot/mod.rs | 2 +- .../icp-cli/tests/canister_snapshot_tests.rs | 220 ++++++++++++++++++ 4 files changed, 223 insertions(+), 1 deletion(-) create mode 100644 crates/icp-cli/tests/canister_snapshot_tests.rs diff --git a/Cargo.lock b/Cargo.lock index 53f3793c..7d181743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2529,6 +2529,7 @@ dependencies = [ "pocket-ic", "predicates", "rand 0.9.2", + "regex", "reqwest", "sec1 0.7.3", "serde", diff --git a/crates/icp-cli/Cargo.toml b/crates/icp-cli/Cargo.toml index ad44dd2f..32639312 100644 --- a/crates/icp-cli/Cargo.toml +++ b/crates/icp-cli/Cargo.toml @@ -60,6 +60,7 @@ indoc.workspace = true nix = { version = "0.30.1", features = ["process", "signal"] } pocket-ic.workspace = true predicates = "3" +regex = "1" rand.workspace = true serde_yaml.workspace = true serial_test = { version = "3.2.0", features = ["file_locks"] } diff --git a/crates/icp-cli/src/commands/canister/snapshot/mod.rs b/crates/icp-cli/src/commands/canister/snapshot/mod.rs index bcbd77d8..8d6535c9 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/mod.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/mod.rs @@ -43,7 +43,7 @@ fn ensure_canister_stopped(status: CanisterStatusType, canister: &str) -> Result match status { CanisterStatusType::Stopped => Ok(()), CanisterStatusType::Running => Err(CommandError::CanisterNotStopped(format!( - "Canister {canister} is running. Run `dfx canister stop` to stop it first" + "Canister {canister} is running. Run 'icp canister stop' to stop it first" ))), CanisterStatusType::Stopping => Err(CommandError::CanisterNotStopped(format!( "Canister {canister} is stopping but is not yet stopped. Wait a few seconds and try again" diff --git a/crates/icp-cli/tests/canister_snapshot_tests.rs b/crates/icp-cli/tests/canister_snapshot_tests.rs new file mode 100644 index 00000000..3a189c11 --- /dev/null +++ b/crates/icp-cli/tests/canister_snapshot_tests.rs @@ -0,0 +1,220 @@ +use indoc::formatdoc; +use predicates::{ + prelude::PredicateBooleanExt, + str::{contains, starts_with}, +}; +use regex::Regex; + +use crate::common::{ENVIRONMENT_RANDOM_PORT, NETWORK_RANDOM_PORT, TestContext, clients}; +use icp::{fs::write_string, prelude::*}; + +mod common; + +#[test] +fn canister_snapshot() { + let ctx = TestContext::new(); + + // Setup project + let project_dir = ctx.create_project_dir("icp"); + + // Use vendored WASM + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + // Project manifest + let pm = formatdoc! {r#" + canister: + name: my-canister + build: + steps: + - type: script + command: cp {wasm} "$ICP_WASM_OUTPUT_PATH" + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string( + &project_dir.join("icp.yaml"), // path + &pm, // contents + ) + .expect("failed to write project manifest"); + + // Start network + let _g = ctx.start_network_in(&project_dir, "my-network"); + ctx.ping_until_healthy(&project_dir, "my-network"); + + // Deploy project + clients::icp(&ctx, &project_dir, Some("my-environment".to_string())).mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--subnet-id", + common::SUBNET_ID, + "--environment", + "my-environment", + ]) + .assert() + .success(); + + // Query status + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(starts_with("Canister Status Report:").and(contains("Status: Running"))); + + // List canister snapshots + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "list", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(starts_with("No snapshots found")); + + // Failed to create canister snapshot as the canister is running. + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "create", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .failure() + .stderr(starts_with("Error: Canister my-canister is running.")); + + // Failed to load canister snapshot as the canister is running. + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "load", + "my-canister", + "0000000000000003ffffffffffc000000101", // A faked snapshot id. + "--environment", + "my-environment", + ]) + .assert() + .failure() + .stderr(starts_with("Error: Canister my-canister is running.")); + + // Stop canister. + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success(); + + // Create canister snapshot and parse the snapshot ID. + let result = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "create", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success(); + + let result_str = std::str::from_utf8(&result.get_output().stderr).unwrap(); + assert!(result_str.starts_with("Created a new snapshot of canister")); + + let re = Regex::new(r"Snapshot ID: '([0-9a-fA-F]+)'").unwrap(); + let caps = re + .captures(result_str) + .expect("snapshot id not found in stderr"); + let snapshot_id = &caps[1]; + assert!(!snapshot_id.is_empty(), "snapshot id is empty"); + + // List canister snapshots + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "list", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(contains(snapshot_id)); + + // Load canister snapshot + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "load", + "my-canister", + snapshot_id, + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(starts_with(format!("Loaded snapshot {}", snapshot_id))); + + // Delete canister snapshot + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "delete", + "my-canister", + snapshot_id, + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(starts_with(format!("Deleted snapshot {}", snapshot_id))); + + // List canister snapshots + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "list", + "my-canister", + "--environment", + "my-environment", + ]) + .assert() + .success() + .stderr(starts_with("No snapshots found")); +} From 1ba9e78e7c2a3fb02554fcac3b1250843d5b6688 Mon Sep 17 00:00:00 2001 From: Vincent Zhang Date: Mon, 20 Oct 2025 17:05:50 +0800 Subject: [PATCH 10/10] Updated document --- docs/cli-reference.md | 136 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/docs/cli-reference.md b/docs/cli-reference.md index 7608adc5..b3e88757 100644 --- a/docs/cli-reference.md +++ b/docs/cli-reference.md @@ -17,6 +17,13 @@ This document contains the help content for the `icp-cli` command-line program. * [`icp-cli canister settings show`↴](#icp-cli-canister-settings-show) * [`icp-cli canister settings update`↴](#icp-cli-canister-settings-update) * [`icp-cli canister show`↴](#icp-cli-canister-show) +* [`icp-cli canister snapshot`↴](#icp-cli-canister-snapshot) +* [`icp-cli canister snapshot create`↴](#icp-cli-canister-snapshot-create) +* [`icp-cli canister snapshot delete`↴](#icp-cli-canister-snapshot-delete) +* [`icp-cli canister snapshot download`↴](#icp-cli-canister-snapshot-download) +* [`icp-cli canister snapshot list`↴](#icp-cli-canister-snapshot-list) +* [`icp-cli canister snapshot load`↴](#icp-cli-canister-snapshot-load) +* [`icp-cli canister snapshot upload`↴](#icp-cli-canister-snapshot-upload) * [`icp-cli canister start`↴](#icp-cli-canister-start) * [`icp-cli canister status`↴](#icp-cli-canister-status) * [`icp-cli canister stop`↴](#icp-cli-canister-stop) @@ -101,6 +108,7 @@ Perform canister operations against a network * `list` — * `settings` — * `show` — +* `snapshot` — * `start` — * `status` — * `stop` — @@ -290,6 +298,134 @@ Perform canister operations against a network +## `icp-cli canister snapshot` + +**Usage:** `icp-cli canister snapshot ` + +###### **Subcommands:** + +* `create` — +* `delete` — +* `download` — +* `list` — +* `load` — +* `upload` — + + + +## `icp-cli canister snapshot create` + +**Usage:** `icp-cli canister snapshot create [OPTIONS] ` + +###### **Arguments:** + +* `` — The name of the canister within the current project + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic +* `--replace ` — If a snapshot ID is specified, this snapshot will replace it and reuse the ID + + + +## `icp-cli canister snapshot delete` + +**Usage:** `icp-cli canister snapshot delete [OPTIONS] ` + +###### **Arguments:** + +* `` — The name of the canister within the current project +* `` — The ID of the snapshot to delete + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic + + + +## `icp-cli canister snapshot download` + +**Usage:** `icp-cli canister snapshot download [OPTIONS] --dir ` + +###### **Arguments:** + +* `` — The name of the canister within the current project +* `` — The ID of the snapshot to download + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic +* `--dir ` — The directory to download the snapshot to +* `-r`, `--resume` — Whether to resume the download if the previous snapshot download failed + + Default value: `false` +* `--concurrency ` — The number of concurrent downloads to perform + + Default value: `3` + + + +## `icp-cli canister snapshot list` + +**Usage:** `icp-cli canister snapshot list [OPTIONS] ` + +###### **Arguments:** + +* `` — The name of the canister within the current project + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic + + + +## `icp-cli canister snapshot load` + +**Usage:** `icp-cli canister snapshot load [OPTIONS] ` + +###### **Arguments:** + +* `` — The name of the canister within the current project +* `` — The ID of the snapshot to load + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic + + + +## `icp-cli canister snapshot upload` + +**Usage:** `icp-cli canister snapshot upload [OPTIONS] --dir ` + +###### **Arguments:** + +* `` — The name of the canister within the current project + +###### **Options:** + +* `--identity ` — The user identity to run this command as +* `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--ic` — Shorthand for --environment=ic +* `--replace ` — If a snapshot ID is specified, this snapshot will replace it and reuse the ID +* `--dir ` — The directory to upload the snapshot from +* `-r`, `--resume ` — The snapshot ID to resume uploading to +* `--concurrency ` — The number of concurrent uploads to perform + + Default value: `3` + + + ## `icp-cli canister start` **Usage:** `icp-cli canister start [OPTIONS] `