From a49286a29de842a4a61919039d82c5f6d80095b3 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Mon, 2 Feb 2026 08:06:16 -0800 Subject: [PATCH 1/5] Implement canister migration --- .github/workflows/test.yml | 40 --- CHANGELOG.md | 2 + Cargo.lock | 155 ++++------ Cargo.toml | 12 +- crates/icp-canister-interfaces/src/lib.rs | 1 + .../src/nns_migration.rs | 138 +++++++++ .../src/commands/canister/migrate_id.rs | 291 ++++++++++++++++++ crates/icp-cli/src/commands/canister/mod.rs | 4 + crates/icp-cli/src/main.rs | 6 + .../src/operations/canister_migration.rs | 171 ++++++++++ crates/icp-cli/src/operations/mod.rs | 1 + .../src/operations/snapshot_transfer.rs | 15 +- .../icp-cli/tests/canister_snapshot_tests.rs | 245 +++++++++++++++ crates/icp-cli/tests/common/context.rs | 69 ++++- crates/icp-cli/tests/common/mod.rs | 2 + crates/icp/src/agent.rs | 13 +- 16 files changed, 1016 insertions(+), 149 deletions(-) create mode 100644 crates/icp-canister-interfaces/src/nns_migration.rs create mode 100644 crates/icp-cli/src/commands/canister/migrate_id.rs create mode 100644 crates/icp-cli/src/operations/canister_migration.rs diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 347d13ec..89033c93 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -140,46 +140,6 @@ jobs: which wasm-tools wasm-tools --version - - name: install network launcher - env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - if: ${{ !contains(matrix.os, 'windows') }} - run: | - VERSION=v11.0.0 - OS="" - ARCH="" - - # Map runner.os → release suffix - if [[ "${{ runner.os }}" == "Linux" ]]; then - OS="linux" - elif [[ "${{ runner.os }}" == "macOS" ]]; then - OS="darwin" - else - echo "Unsupported OS: ${{ runner.os }}" - exit 1 - fi - - # Detect architecture - case "$(uname -m)" in - x86_64) ARCH="x86_64" ;; - arm64|aarch64) ARCH="arm64" ;; - *) - echo "Unsupported architecture: $(uname -m)" - exit 1 - ;; - esac - PERCENT_VERSION=$(printf '%s' "$VERSION" |jq -sRr @uri) - URL="https://github.com/dfinity/icp-cli-network-launcher/releases/download/${PERCENT_VERSION}/icp-cli-network-launcher-${ARCH}-${OS}-${PERCENT_VERSION}.tar.gz" - DEST="/opt/icp-cli-network-launcher" - - echo "Downloading $URL" - curl -L "$URL" -H "Authorization: Bearer ${GITHUB_TOKEN}" -o icp-cli-network-launcher.tar.gz - tar -xzf icp-cli-network-launcher.tar.gz - sudo mv icp-cli-network-launcher-${ARCH}-${OS}-${VERSION} "$DEST" - - echo "Installed network launcher to $DEST" - echo "ICP_CLI_NETWORK_LAUNCHER_PATH=$DEST/icp-cli-network-launcher" >> "$GITHUB_ENV" - - name: Run ${{ matrix.test }} # the macos runners do not support Docker run: cargo test --test ${{ matrix.test }} -- ${{ contains(matrix.os, 'macos') && '--skip :docker:' || '' }} diff --git a/CHANGELOG.md b/CHANGELOG.md index b2737a1a..f72af005 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* feat: `icp cansister migrate-id` - initiate canister ID migration across subnets + # v0.1.0 * feat: `icp canister snapshot` - create, delete, restore, list, download, and upload canister snapshots diff --git a/Cargo.lock b/Cargo.lock index f44cc19f..fc13f009 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -460,12 +460,6 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" -[[package]] -name = "base64" -version = "0.13.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" - [[package]] name = "base64" version = "0.22.1" @@ -648,7 +642,7 @@ version = "0.19.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a52479c9237eb04047ddb94788c41ca0d26eaff8b697ecfbb4c32f7fdc3b1b" dependencies = [ - "base64 0.22.1", + "base64", "bollard-stubs", "bytes", "futures-core", @@ -817,15 +811,15 @@ checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" [[package]] name = "cached" -version = "0.52.0" +version = "0.56.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8466736fe5dbcaf8b8ee24f9bbefe43c884dc3e9ff7178da70f55bffca1133c" +checksum = "801927ee168e17809ab8901d9f01f700cd7d8d6a6527997fee44e4b0327a253c" dependencies = [ "ahash 0.8.12", - "hashbrown 0.14.5", - "instant", + "hashbrown 0.15.5", "once_cell", - "thiserror 1.0.69", + "thiserror 2.0.18", + "web-time", ] [[package]] @@ -1948,6 +1942,12 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + [[package]] name = "foreign-types" version = "0.3.2" @@ -2527,20 +2527,15 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.14.5" +version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "ahash 0.8.12", "allocator-api2", + "equivalent", + "foldhash", ] -[[package]] -name = "hashbrown" -version = "0.15.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" - [[package]] name = "hashbrown" version = "0.16.1" @@ -2741,7 +2736,7 @@ version = "0.1.19" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "727805d60e7938b76b826a6ef209eb70eaa1812794f9424d4a4e2d740662df5f" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -2800,12 +2795,12 @@ dependencies = [ [[package]] name = "ic-agent" -version = "0.44.3" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5337598ec943bab711e0288319761abb5a7a7087ac226b03472441a90f88e0c" +checksum = "20a6173286a80fc478462fc45de42faf37a79b0109a489743aeffb3e4a2fc772" dependencies = [ "arc-swap", - "async-channel 1.9.0", + "async-channel 2.5.0", "async-lock", "async-trait", "async-watch", @@ -2823,13 +2818,13 @@ dependencies = [ "http-body", "http-body-util", "ic-certification", - "ic-ed25519 0.2.0", + "ic-ed25519", "ic-transport-types", "ic-verify-bls-signature", "k256", "leb128", "p256", - "pem 3.0.6", + "pem", "pkcs8", "rand 0.8.5", "rangemap", @@ -2851,8 +2846,7 @@ dependencies = [ [[package]] name = "ic-asset" version = "0.26.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38feac9384f1297aa4905239ef123bb765b33e04261dba502327c2708185b050" +source = "git+https://github.com/dfinity/sdk?rev=32c21b06cf6bed4797fb58f2511859a1d6dfd5ef#32c21b06cf6bed4797fb58f2511859a1d6dfd5ef" dependencies = [ "backoff", "brotli", @@ -2882,16 +2876,17 @@ dependencies = [ [[package]] name = "ic-cdk" -version = "0.18.7" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4efb278f5d3ef033b3eed7f01f1096eaf67701896aa5ef69f5eddf5a84833dc0" +checksum = "818d6d5416a8f0212e1b132703b0da51e36c55f2b96677e96f2bbe7702e1bd85" dependencies = [ "candid", "ic-cdk-executor", "ic-cdk-macros", "ic-error-types", - "ic-management-canister-types 0.3.3", + "ic-management-canister-types", "ic0", + "pin-project-lite", "serde", "serde_bytes", "slotmap", @@ -2900,19 +2895,20 @@ dependencies = [ [[package]] name = "ic-cdk-executor" -version = "1.0.2" +version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99f4ee8930fd2e491177e2eb7fff53ee1c407c13b9582bdc7d6920cf83109a2d" +checksum = "33716b730ded33690b8a704bff3533fda87d229e58046823647d28816e9bcee7" dependencies = [ "ic0", "slotmap", + "smallvec", ] [[package]] name = "ic-cdk-macros" -version = "0.18.7" +version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7eb14c5d691cc9d72bb95459b4761e3a4b3444b85a63d17555d5ddd782969a1e" +checksum = "66dad91a214945cb3605bc9ef6901b87e2ac41e3624284c2cabba49d43aa4f43" dependencies = [ "candid", "darling", @@ -2933,21 +2929,6 @@ dependencies = [ "sha2 0.10.9", ] -[[package]] -name = "ic-ed25519" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0a381e86a9d559c816a7ff4419e56f5d37f96357258fb63b0cb7026db0f729b" -dependencies = [ - "curve25519-dalek", - "ed25519-dalek", - "hkdf", - "pem 1.1.1", - "rand 0.8.5", - "thiserror 2.0.18", - "zeroize", -] - [[package]] name = "ic-ed25519" version = "0.5.0" @@ -2959,7 +2940,7 @@ dependencies = [ "hex-literal", "hkdf", "ic_principal", - "pem 3.0.6", + "pem", "rand 0.8.5", "thiserror 2.0.18", "zeroize", @@ -2978,9 +2959,9 @@ dependencies = [ [[package]] name = "ic-identity-hsm" -version = "0.44.3" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d221fdcf1d36f102cce74adba1e743b4cdf4e2b9aca237fe5ae38f1dcd192389" +checksum = "1cadfa7095085405ceaadc8aa7714e313cb778d1b98292dbfe23cd087b345b35" dependencies = [ "hex", "ic-agent", @@ -2992,9 +2973,9 @@ dependencies = [ [[package]] name = "ic-ledger-types" -version = "0.15.0" +version = "0.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "feb52826a353b583012628af6da762b52672350686c3275234febfadeca965ea" +checksum = "60ab0da348a638e01beb5bcc6c0b92e51efbe351950ff99c125d53fff77899d5" dependencies = [ "candid", "crc32fast", @@ -3007,20 +2988,9 @@ dependencies = [ [[package]] name = "ic-management-canister-types" -version = "0.3.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea7e5b8a0f7c3b320d9450ac950547db4f24a31601b5d398f9680b64427455d2" -dependencies = [ - "candid", - "serde", - "serde_bytes", -] - -[[package]] -name = "ic-management-canister-types" -version = "0.4.1" +version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b5a939a84adbc30769d47e5de72c7b9f3713284e3ac87df4eb24795677a8df8" +checksum = "3149217e24186df3f13dc45eee14cdb3e5cad07d0b2b67bd53555c1c55462957" dependencies = [ "candid", "serde", @@ -3038,9 +3008,9 @@ dependencies = [ [[package]] name = "ic-transport-types" -version = "0.44.3" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d5fec6355d0a542bfe484eb36343b6570047394124918cb8b435e22241c65427" +checksum = "4a775244756a5d97ff19b08071a946a4b4896904e35deb036bf215e80f2e703d" dependencies = [ "candid", "hex", @@ -3056,31 +3026,31 @@ dependencies = [ [[package]] name = "ic-utils" -version = "0.44.3" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc35f1cf9c5053b108b24fbf24eed544dc37647a95b183e2e1c7dfb93da23d32" +checksum = "30c22aaa2924df0321705dc01d408c8b75d1e1deb40b65defd2ff04008a720be" dependencies = [ "async-trait", "candid", "futures-util", "ic-agent", - "ic-management-canister-types 0.4.1", + "ic-management-canister-types", "once_cell", "semver", "serde", "serde_bytes", "sha2 0.10.9", - "strum 0.26.3", - "strum_macros 0.26.4", + "strum 0.27.2", + "strum_macros 0.27.2", "thiserror 2.0.18", "time", ] [[package]] name = "ic-verify-bls-signature" -version = "0.5.0" +version = "0.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d420b25c0091059f6c3c23a21427a81915e6e0aca3b79e0d403ed767f286a3b9" +checksum = "cd6c4261586eb473fe1219de63186a98e554985d5fd6f3488036c8fb82452e27" dependencies = [ "hex", "ic_bls12_381", @@ -3150,10 +3120,10 @@ dependencies = [ "hex", "ic-agent", "ic-asset", - "ic-ed25519 0.5.0", + "ic-ed25519", "ic-identity-hsm", "ic-ledger-types", - "ic-management-canister-types 0.4.1", + "ic-management-canister-types", "ic-utils", "icp-canister-interfaces", "icrc-ledger-types", @@ -3164,7 +3134,7 @@ dependencies = [ "keyring", "notify", "p256", - "pem 3.0.6", + "pem", "pkcs8", "rand 0.9.2", "reqwest", @@ -3228,9 +3198,9 @@ dependencies = [ "hex", "httptest", "ic-agent", - "ic-ed25519 0.5.0", + "ic-ed25519", "ic-ledger-types", - "ic-management-canister-types 0.4.1", + "ic-management-canister-types", "ic-utils", "icp", "icp-canister-interfaces", @@ -3245,7 +3215,7 @@ dependencies = [ "num-integer", "num-traits", "p256", - "pem 3.0.6", + "pem", "phf", "pkcs8", "predicates", @@ -3660,7 +3630,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d46662859bc5f60a145b75f4632fbadc84e829e45df6c5de74cfc8e05acb96b5" dependencies = [ "ahash 0.8.12", - "base64 0.22.1", + "base64", "bytecount", "email_address", "fancy-regex", @@ -4561,22 +4531,13 @@ dependencies = [ "hmac", ] -[[package]] -name = "pem" -version = "1.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a8835c273a76a90455d7344889b0964598e3316e2a79ede8e36f16bdcf2228b8" -dependencies = [ - "base64 0.13.1", -] - [[package]] name = "pem" version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" dependencies = [ - "base64 0.22.1", + "base64", "serde_core", ] @@ -5246,7 +5207,7 @@ version = "0.12.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" dependencies = [ - "base64 0.22.1", + "base64", "bytes", "futures-channel", "futures-core", @@ -5832,7 +5793,7 @@ version = "3.16.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4fa237f2807440d238e0364a218270b98f767a00d3dada77b1c53ae88940e2e7" dependencies = [ - "base64 0.22.1", + "base64", "chrono", "hex", "indexmap 1.9.3", diff --git a/Cargo.toml b/Cargo.toml index 081f51ea..262770f1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,15 +43,15 @@ glob = "0.3.2" handlebars = "6.3.2" hex = "0.4.3" httptest = "0.16.3" -ic-agent = { version = "0.44.1" } -ic-asset = { version = "0.26.0" } +ic-agent = { version = "0.45.0" } +ic-asset = { git = "https://github.com/dfinity/sdk", rev = "32c21b06cf6bed4797fb58f2511859a1d6dfd5ef" } ic-ed25519 = "0.5.0" -ic-ledger-types = "0.15.0" -ic-management-canister-types = { version = "0.4.1" } -ic-utils = { version = "0.44.1" } +ic-ledger-types = "0.16.0" +ic-management-canister-types = { version = "0.5.0" } +ic-utils = { version = "0.45.0" } icp = { path = "crates/icp" } icp-canister-interfaces = { path = "crates/icp-canister-interfaces" } -ic-identity-hsm = "0.44.1" +ic-identity-hsm = "0.45.0" icrc-ledger-types = "0.1.10" indicatif = "0.18.0" indoc = "2.0.6" diff --git a/crates/icp-canister-interfaces/src/lib.rs b/crates/icp-canister-interfaces/src/lib.rs index 550da7e1..9a95c416 100644 --- a/crates/icp-canister-interfaces/src/lib.rs +++ b/crates/icp-canister-interfaces/src/lib.rs @@ -4,6 +4,7 @@ pub mod cycles_minting_canister; pub mod governance; pub mod icp_ledger; pub mod internet_identity; +pub mod nns_migration; pub mod nns_root; pub mod proxy; pub mod registry; diff --git a/crates/icp-canister-interfaces/src/nns_migration.rs b/crates/icp-canister-interfaces/src/nns_migration.rs new file mode 100644 index 00000000..5f40ffc0 --- /dev/null +++ b/crates/icp-canister-interfaces/src/nns_migration.rs @@ -0,0 +1,138 @@ +use candid::{CandidType, Deserialize, Principal, Reserved}; +use std::fmt; + +/// The NNS migration canister ID. +pub const NNS_MIGRATION_CID: &str = "sbzkb-zqaaa-aaaaa-aaaiq-cai"; + +/// The NNS migration canister principal. +pub const NNS_MIGRATION_PRINCIPAL: Principal = + Principal::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01]); + +/// Arguments for the `migrate_canister` method. +#[derive(Clone, CandidType, Deserialize, Debug, PartialEq, Eq)] +pub struct MigrateCanisterArgs { + pub migrated_canister_id: Principal, + pub replaced_canister_id: Principal, +} + +/// Validation errors returned by the NNS migration canister. +#[derive(Clone, Debug, CandidType, Deserialize, PartialEq, Eq)] +pub enum ValidationError { + MigrationsDisabled(Reserved), + RateLimited(Reserved), + ValidationInProgress { canister: Principal }, + MigrationInProgress { canister: Principal }, + CanisterNotFound { canister: Principal }, + SameSubnet(Reserved), + CallerNotController { canister: Principal }, + NotController { canister: Principal }, + MigratedCanisterNotStopped(Reserved), + MigratedCanisterNotReady(Reserved), + ReplacedCanisterNotStopped(Reserved), + ReplacedCanisterHasSnapshots(Reserved), + MigratedCanisterInsufficientCycles(Reserved), + CallFailed { reason: String }, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ValidationError::MigrationsDisabled(_) => { + write!(f, "Canister migrations are disabled at the moment") + } + ValidationError::RateLimited(_) => { + write!( + f, + "Canister migration has been rate-limited. Try again later" + ) + } + ValidationError::ValidationInProgress { canister } => { + write!( + f, + "Validation for canister {canister} is already in progress" + ) + } + ValidationError::MigrationInProgress { canister } => { + write!( + f, + "Canister migration for canister {canister} is already in progress" + ) + } + ValidationError::CanisterNotFound { canister } => { + write!(f, "The canister {canister} does not exist") + } + ValidationError::SameSubnet(_) => { + write!(f, "Both canisters are on the same subnet") + } + ValidationError::CallerNotController { canister } => { + write!( + f, + "The canister {canister} is not controlled by the calling identity" + ) + } + ValidationError::NotController { canister } => { + write!( + f, + "The NNS migration canister ({NNS_MIGRATION_PRINCIPAL}) is not a controller of canister {canister}" + ) + } + ValidationError::MigratedCanisterNotStopped(_) => { + write!(f, "The migrated canister is not stopped") + } + ValidationError::MigratedCanisterNotReady(_) => { + write!( + f, + "The migrated canister is not ready for migration. Try again later" + ) + } + ValidationError::ReplacedCanisterNotStopped(_) => { + write!(f, "The replaced canister is not stopped") + } + ValidationError::ReplacedCanisterHasSnapshots(_) => { + write!(f, "The replaced canister has snapshots") + } + ValidationError::MigratedCanisterInsufficientCycles(_) => { + write!( + f, + "The migrated canister does not have enough cycles for migration. Top up with at least 10T cycles" + ) + } + ValidationError::CallFailed { reason } => { + write!(f, "Internal IC error: a call failed due to {reason}") + } + } + } +} + +impl std::error::Error for ValidationError {} + +/// Result type for the `migrate_canister` method. +pub type MigrateCanisterResult = Result<(), Option>; + +/// Migration status returned by the `migration_status` query. +#[derive(Clone, CandidType, Deserialize, Debug, PartialEq, Eq)] +pub enum MigrationStatus { + InProgress { status: String }, + Failed { reason: String, time: u64 }, + Succeeded { time: u64 }, +} + +impl fmt::Display for MigrationStatus { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + MigrationStatus::InProgress { status } => write!(f, "In progress: {status}"), + MigrationStatus::Failed { reason, .. } => write!(f, "Failed: {reason}"), + MigrationStatus::Succeeded { .. } => write!(f, "Succeeded"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn nns_migration_cid_and_principal_match() { + assert_eq!(NNS_MIGRATION_CID, NNS_MIGRATION_PRINCIPAL.to_text()); + } +} diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs new file mode 100644 index 00000000..000da1be --- /dev/null +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -0,0 +1,291 @@ +use std::time::Duration; + +use anyhow::bail; +use clap::Args; +use dialoguer::Confirm; +use ic_management_canister_types::CanisterStatusType; +use ic_utils::interfaces::ManagementCanister; +use icp::context::Context; +use icp_canister_interfaces::nns_migration::{MigrationStatus, NNS_MIGRATION_PRINCIPAL}; +use indicatif::{ProgressBar, ProgressStyle}; +use num_traits::ToPrimitive; + +use crate::commands::args::{self, Canister}; +use crate::operations::canister_migration::{ + get_subnet_for_canister, migrate_canister, migration_status, +}; +use crate::operations::misc::format_timestamp; +use icp::context::CanisterSelection; + +/// Minimum cycles required for migration (10T). +const MIN_CYCLES_FOR_MIGRATION: u128 = 10_000_000_000_000; +/// Cycles threshold for warning (15T). +const WARN_CYCLES_THRESHOLD: u128 = 15_000_000_000_000; + +#[derive(Debug, Args)] +pub(crate) struct MigrateIdArgs { + #[command(flatten)] + pub(crate) cmd_args: args::CanisterCommandArgs, + + /// The canister to replace with the source canister's ID + #[arg(long)] + replace: String, + + /// Skip confirmation prompts + #[arg(long, short)] + yes: bool, + + /// Resume watching an already-initiated migration (skips validation and initiation) + #[arg(long)] + resume_watch: bool, + + /// Exit as soon as the migrated canister is deleted (don't wait for full completion) + #[arg(long)] + skip_watch: bool, +} + +pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyhow::Error> { + let selections = args.cmd_args.selections(); + + let agent = ctx + .get_agent( + &selections.identity, + &selections.network, + &selections.environment, + ) + .await?; + + let source_cid = ctx + .get_canister_id( + &selections.canister, + &selections.network, + &selections.environment, + ) + .await?; + + // Resolve target canister - parse as principal or name + let target_canister: Canister = args.replace.as_str().into(); + let target_selection: CanisterSelection = target_canister.clone().into(); + let target_cid = ctx + .get_canister_id( + &target_selection, + &selections.network, + &selections.environment, + ) + .await?; + + let source_name = args.cmd_args.canister.to_string(); + let target_name = target_canister.to_string(); + + if source_cid == target_cid { + bail!("The source and target canisters are identical"); + } + + // If --resume-watch is set, skip all validation and migration initiation + if !args.resume_watch { + if !args.yes { + ctx.term.write_line(&format!( + "This will migrate canister '{source_name}' ({source_cid}) to replace '{target_name}' ({target_cid})." + ))?; + ctx.term.write_line( + "The target canister will be deleted and the source canister will take over its ID.", + )?; + + let confirmed = Confirm::new() + .with_prompt("Do you want to proceed?") + .default(false) + .interact()?; + + if !confirmed { + bail!("Operation cancelled by user"); + } + } + + let mgmt = ManagementCanister::create(&agent); + + // Fetch status of both canisters + let (source_status,) = mgmt.canister_status(&source_cid).await?; + let (target_status,) = mgmt.canister_status(&target_cid).await?; + + // Check both are stopped + ensure_canister_stopped(source_status.status, &source_name)?; + ensure_canister_stopped(target_status.status, &target_name)?; + + // Check source canister is ready for migration + if !source_status.ready_for_migration { + bail!( + "Canister '{source_name}' is not ready for migration. Wait a few seconds and try again" + ); + } + + // Check cycles balance + let cycles = source_status + .cycles + .0 + .to_u128() + .expect("unable to parse cycles"); + + if cycles < MIN_CYCLES_FOR_MIGRATION { + bail!( + "Canister '{source_name}' has less than 10T cycles ({cycles} cycles). Top up before migrating" + ); + } + + if !args.yes && cycles > WARN_CYCLES_THRESHOLD { + ctx.term.write_line(&format!( + "Warning: Canister '{source_name}' has more than 15T cycles ({cycles} cycles)." + ))?; + ctx.term + .write_line("The extra cycles will get burned during the migration.")?; + + let confirmed = Confirm::new() + .with_prompt("Do you want to proceed?") + .default(false) + .interact()?; + + if !confirmed { + bail!("Operation cancelled by user"); + } + } + + // Check target canister has no snapshots + let (snapshots,) = mgmt.list_canister_snapshots(&target_cid).await?; + if !snapshots.is_empty() { + bail!( + "The target canister '{target_name}' ({target_cid}) has {} snapshot(s). \ + Delete them before migration with `icp canister snapshot delete`", + snapshots.len() + ); + } + + // Check canisters are on different subnets + let source_subnet = get_subnet_for_canister(&agent, source_cid).await?; + let target_subnet = get_subnet_for_canister(&agent, target_cid).await?; + + if source_subnet == target_subnet { + bail!( + "The canisters '{source_name}' and '{target_name}' are on the same subnet ({source_subnet}). \ + Canister ID migration requires canisters on different subnets" + ); + } + + ctx.term.write_line(&format!( + "Migrating canister '{source_name}' ({source_cid}) to replace '{target_name}' ({target_cid})" + ))?; + ctx.term + .write_line(&format!(" Source subnet: {source_subnet}"))?; + ctx.term + .write_line(&format!(" Target subnet: {target_subnet}"))?; + + // Add NNS migration canister as controller to both canisters if not already + let source_controllers = source_status.settings.controllers; + if !source_controllers.contains(&NNS_MIGRATION_PRINCIPAL) { + ctx.term.write_line(&format!( + "Adding NNS migration canister as controller of '{source_name}'..." + ))?; + let mut new_controllers = source_controllers; + new_controllers.push(NNS_MIGRATION_PRINCIPAL); + let mut builder = mgmt.update_settings(&source_cid); + for controller in new_controllers { + builder = builder.with_controller(controller); + } + builder.await?; + } + + let target_controllers = target_status.settings.controllers; + if !target_controllers.contains(&NNS_MIGRATION_PRINCIPAL) { + ctx.term.write_line(&format!( + "Adding NNS migration canister as controller of '{target_name}'..." + ))?; + let mut new_controllers = target_controllers; + new_controllers.push(NNS_MIGRATION_PRINCIPAL); + let mut builder = mgmt.update_settings(&target_cid); + for controller in new_controllers { + builder = builder.with_controller(controller); + } + builder.await?; + } + + // Initiate migration + ctx.term.write_line("Initiating canister ID migration...")?; + migrate_canister(&agent, source_cid, target_cid).await?; + } else { + ctx.term.write_line(&format!( + "Resuming watch for migration of '{source_name}' ({source_cid}) to '{target_name}' ({target_cid})" + ))?; + } + + // Create spinner for polling + let spinner = ProgressBar::new_spinner(); + spinner.set_style( + ProgressStyle::with_template("{spinner:.blue} {msg}") + .expect("invalid style template") + .tick_strings(&["✶", "✸", "✹", "✺", "✹", "✷", "✔"]), + ); + spinner.enable_steady_tick(Duration::from_millis(120)); + spinner.set_message("Waiting for migration to complete..."); + + // Poll for completion + loop { + match migration_status(&agent, source_cid, target_cid).await { + Ok(Some(MigrationStatus::InProgress { status })) => { + spinner.set_message(format!("Migration in progress: {status}")); + + // If --skip-watch is set and we've reached MigratedCanisterDeleted, exit early + if args.skip_watch && status == "MigratedCanisterDeleted" { + spinner.finish_with_message(format!( + "Migration in progress: {status} (exiting early due to --skip-watch)" + )); + ctx.term.write_line(&format!( + "The source canister '{source_name}' has been deleted. Migration will continue in the background." + ))?; + ctx.term.write_line(&format!( + "Use `icp canister migrate-id {source_name} --replace {target_name} --resume-watch` to monitor completion." + ))?; + return Ok(()); + } + } + Ok(Some(MigrationStatus::Succeeded { time })) => { + spinner.finish_with_message(format!( + "Migration succeeded at {}", + format_timestamp(time) + )); + break; + } + Ok(Some(MigrationStatus::Failed { reason, time })) => { + spinner.finish_with_message(format!( + "Migration failed at {}: {}", + format_timestamp(time), + reason + )); + bail!("Migration failed at {}: {}", format_timestamp(time), reason); + } + Ok(None) => { + // No status yet, keep polling + } + Err(e) => { + spinner.set_message(format!("Warning: Could not fetch status: {e}")); + } + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + ctx.term.write_line(&format!( + "Canister '{source_name}' ({source_cid}) has been successfully migrated to the new subnet, replacing {target_cid}" + ))?; + + Ok(()) +} + +fn ensure_canister_stopped(status: CanisterStatusType, name: &str) -> Result<(), anyhow::Error> { + match status { + CanisterStatusType::Stopped => Ok(()), + CanisterStatusType::Running => { + bail!("Canister '{name}' is running. Run `icp canister stop {name}` first") + } + CanisterStatusType::Stopping => { + bail!("Canister '{name}' is stopping. Wait a few seconds and try again") + } + } +} diff --git a/crates/icp-cli/src/commands/canister/mod.rs b/crates/icp-cli/src/commands/canister/mod.rs index c37cffe7..cfed7560 100644 --- a/crates/icp-cli/src/commands/canister/mod.rs +++ b/crates/icp-cli/src/commands/canister/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod delete; pub(crate) mod install; pub(crate) mod list; pub(crate) mod metadata; +pub(crate) mod migrate_id; pub(crate) mod settings; pub(crate) mod snapshot; pub(crate) mod start; @@ -34,6 +35,9 @@ pub(crate) enum Command { /// Read a metadata section from a canister Metadata(metadata::MetadataArgs), + /// Migrate a canister ID from one subnet to another + MigrateId(migrate_id::MigrateIdArgs), + /// Commands to manage canister settings #[command(subcommand)] Settings(settings::Command), diff --git a/crates/icp-cli/src/main.rs b/crates/icp-cli/src/main.rs index bb1b7b5f..d764a9bb 100644 --- a/crates/icp-cli/src/main.rs +++ b/crates/icp-cli/src/main.rs @@ -220,6 +220,12 @@ async fn main() -> Result<(), Error> { .await? } + commands::canister::Command::MigrateId(args) => { + commands::canister::migrate_id::exec(&ctx, &args) + .instrument(trace_span) + .await? + } + commands::canister::Command::Settings(cmd) => match cmd { commands::canister::settings::Command::Show(args) => { commands::canister::settings::show::exec(&ctx, &args) diff --git a/crates/icp-cli/src/operations/canister_migration.rs b/crates/icp-cli/src/operations/canister_migration.rs new file mode 100644 index 00000000..3c1c18f3 --- /dev/null +++ b/crates/icp-cli/src/operations/canister_migration.rs @@ -0,0 +1,171 @@ +use backoff::{ExponentialBackoff, backoff::Backoff}; +use candid::Principal; +use ic_agent::{Agent, AgentError}; +use ic_utils::Canister; +use icp_canister_interfaces::nns_migration::{ + MigrateCanisterArgs, MigrateCanisterResult, MigrationStatus, NNS_MIGRATION_PRINCIPAL, + ValidationError, +}; +use icp_canister_interfaces::registry::{ + GetSubnetForCanisterRequest, GetSubnetForCanisterResult, GetSubnetForCanisterSuccess, + REGISTRY_PRINCIPAL, +}; +use snafu::{ResultExt, Snafu}; + +const MIGRATE_CANISTER_METHOD: &str = "migrate_canister"; +const MIGRATION_STATUS_METHOD: &str = "migration_status"; +const GET_SUBNET_FOR_CANISTER_METHOD: &str = "get_subnet_for_canister"; + +#[derive(Debug, Snafu)] +pub enum CanisterMigrationError { + #[snafu(display("Failed to call NNS migration canister"))] + CallMigrationCanister { source: AgentError }, + + #[snafu(display("Failed to query migration status"))] + QueryMigrationStatus { source: AgentError }, + + #[snafu(display("Validation failed: {source}"))] + ValidationFailed { source: ValidationError }, + + #[snafu(display("Validation failed with unknown error"))] + ValidationFailedUnknown, + + #[snafu(display("Failed to query registry canister"))] + QueryRegistryCanister { source: AgentError }, + + #[snafu(display("Failed to determine subnet for canister {canister_id}: {reason}"))] + SubnetLookupFailed { + canister_id: Principal, + reason: String, + }, +} + +/// Initiate a canister ID migration via the NNS migration canister. +/// +/// This transfers the canister ID from `migrated_canister` to the subnet where +/// `replaced_canister` resides. The `replaced_canister` will be deleted and its +/// canister ID taken over by the migrated canister. +/// +/// Prerequisites: +/// - Both canisters must be stopped +/// - The NNS migration canister must be a controller of both canisters +/// - The canisters must be on different subnets +/// - The migrated canister must have at least 10T cycles +/// - The replaced canister must have no snapshots +pub async fn migrate_canister( + agent: &Agent, + migrated_canister: Principal, + replaced_canister: Principal, +) -> Result<(), CanisterMigrationError> { + let canister = Canister::builder() + .with_agent(agent) + .with_canister_id(NNS_MIGRATION_PRINCIPAL) + .build() + .expect("failed to build canister"); + + let arg = MigrateCanisterArgs { + migrated_canister_id: migrated_canister, + replaced_canister_id: replaced_canister, + }; + + let (result,): (MigrateCanisterResult,) = canister + .update(MIGRATE_CANISTER_METHOD) + .with_arg(arg) + .build() + .await + .context(CallMigrationCanisterSnafu)?; + + match result { + Ok(()) => Ok(()), + Err(None) => Err(CanisterMigrationError::ValidationFailedUnknown), + Err(Some(err)) => Err(CanisterMigrationError::ValidationFailed { source: err }), + } +} + +/// Query the migration status for a canister migration. +pub async fn migration_status( + agent: &Agent, + migrated_canister: Principal, + replaced_canister: Principal, +) -> Result, CanisterMigrationError> { + let canister = Canister::builder() + .with_agent(agent) + .with_canister_id(NNS_MIGRATION_PRINCIPAL) + .build() + .expect("failed to build canister"); + + let arg = MigrateCanisterArgs { + migrated_canister_id: migrated_canister, + replaced_canister_id: replaced_canister, + }; + + let (result,): (Option,) = canister + .query(MIGRATION_STATUS_METHOD) + .with_arg(arg) + .build() + .await + .context(QueryMigrationStatusSnafu)?; + + Ok(result) +} + +/// Get the subnet ID for a canister by querying the registry canister. +pub async fn get_subnet_for_canister( + agent: &Agent, + canister_id: Principal, +) -> Result { + let registry_canister = Canister::builder() + .with_agent(agent) + .with_canister_id(REGISTRY_PRINCIPAL) + .build() + .expect("failed to build canister"); + + let mut backoff = ExponentialBackoff::default(); + + loop { + let arg = GetSubnetForCanisterRequest { + principal: Some(canister_id), + }; + + let result: Result<(GetSubnetForCanisterResult,), AgentError> = registry_canister + .query(GET_SUBNET_FOR_CANISTER_METHOD) + .with_arg(arg) + .build() + .await; + + match result { + Ok((Ok(GetSubnetForCanisterSuccess { + subnet_id: Some(subnet_id), + }),)) => return Ok(subnet_id), + Ok((Ok(GetSubnetForCanisterSuccess { subnet_id: None }),)) => { + return Err(CanisterMigrationError::SubnetLookupFailed { + canister_id, + reason: "no subnet found".to_string(), + }); + } + Ok((Err(text),)) => { + return Err(CanisterMigrationError::SubnetLookupFailed { + canister_id, + reason: text, + }); + } + Err(agent_err) if is_retryable(&agent_err) => { + if let Some(duration) = backoff.next_backoff() { + tokio::time::sleep(duration).await; + continue; + } + return Err(CanisterMigrationError::QueryRegistryCanister { source: agent_err }); + } + Err(agent_err) => { + return Err(CanisterMigrationError::QueryRegistryCanister { source: agent_err }); + } + } + } +} + +fn is_retryable(error: &AgentError) -> bool { + matches!( + error, + AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) + ) +} diff --git a/crates/icp-cli/src/operations/mod.rs b/crates/icp-cli/src/operations/mod.rs index 6d6daf9e..82f9c7ec 100644 --- a/crates/icp-cli/src/operations/mod.rs +++ b/crates/icp-cli/src/operations/mod.rs @@ -1,5 +1,6 @@ pub(crate) mod binding_env_vars; pub(crate) mod build; +pub(crate) mod canister_migration; pub(crate) mod create; pub(crate) mod install; pub(crate) mod settings; diff --git a/crates/icp-cli/src/operations/snapshot_transfer.rs b/crates/icp-cli/src/operations/snapshot_transfer.rs index 015e683a..7413b705 100644 --- a/crates/icp-cli/src/operations/snapshot_transfer.rs +++ b/crates/icp-cli/src/operations/snapshot_transfer.rs @@ -240,6 +240,9 @@ pub enum SnapshotTransferError { source: std::io::Error, path: PathBuf, }, + + #[snafu(display("Invalid snapshot metadata: {reason}"))] + InvalidSnapshotMetadata { reason: String }, } /// Tracks upload progress for resumable uploads. @@ -455,11 +458,21 @@ pub async fn upload_snapshot_metadata( ) -> Result { let mgmt = ManagementCanister::create(agent); + // Convert Option to SnapshotMetadataGlobal, failing on None + let globals = metadata + .globals + .iter() + .cloned() + .collect::>>() + .ok_or(SnapshotTransferError::InvalidSnapshotMetadata { + reason: "snapshot metadata contains unparseable globals".to_string(), + })?; + let args = UploadCanisterSnapshotMetadataArgs { canister_id, replace_snapshot: replace_snapshot.map(|s| s.to_vec()), wasm_module_size: metadata.wasm_module_size, - globals: metadata.globals.clone(), + globals, wasm_memory_size: metadata.wasm_memory_size, stable_memory_size: metadata.stable_memory_size, certified_data: metadata.certified_data.clone(), diff --git a/crates/icp-cli/tests/canister_snapshot_tests.rs b/crates/icp-cli/tests/canister_snapshot_tests.rs index f3eca4b0..5a7b50e8 100644 --- a/crates/icp-cli/tests/canister_snapshot_tests.rs +++ b/crates/icp-cli/tests/canister_snapshot_tests.rs @@ -1244,3 +1244,248 @@ async fn canister_snapshot_upload_resume() { "upload progress file should be cleaned up after success" ); } + +/// Tests canister ID migration between subnets using migrate-id command +#[cfg(unix)] // moc +#[tokio::test] +async fn canister_migrate_id() { + use std::time::Duration; + + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + + ctx.copy_asset_dir("echo_init_arg_canister", &project_dir); + + // Network with NNS enabled and 2 application subnets for cross-subnet migration + let pm = formatdoc! {r#" + canisters: + - name: source-canister + recipe: + type: "@dfinity/motoko" + configuration: + main: main.mo + args: "" + init_args: "(opt 42 : opt nat8)" + + - name: target-canister + recipe: + type: "@dfinity/motoko" + configuration: + main: main.mo + args: "" + init_args: "(opt 99 : opt nat8)" + + networks: + - name: random-network + mode: managed + nns: true + gateway: + port: 0 + subnets: [application, application] + + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + // Get available subnets from CMC + let cmc = clients::cmc(&ctx); + let subnets = cmc.get_default_subnets().await; + assert!( + subnets.len() >= 2, + "Expected at least 2 subnets, got {}", + subnets.len() + ); + + let subnet_a = subnets[0]; + let subnet_b = subnets[1]; + + // Mint enough cycles for both canisters + clients::icp(&ctx, &project_dir, Some("random-environment".to_string())) + .mint_cycles(50 * TRILLION); + + // Deploy source canister to first subnet + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "source-canister", + "--subnet", + &subnet_a.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Deploy target canister to second subnet + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "target-canister", + "--subnet", + &subnet_b.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Get canister IDs + let icp_client = clients::icp(&ctx, &project_dir, Some("random-environment".to_string())); + let source_cid = icp_client.get_canister_id("source-canister"); + let target_cid = icp_client.get_canister_id("target-canister"); + + // Verify they're on different subnets + let registry = clients::registry(&ctx); + let source_subnet = registry.get_subnet_for_canister(source_cid).await; + let target_subnet = registry.get_subnet_for_canister(target_cid).await; + assert_ne!( + source_subnet, target_subnet, + "Canisters should be on different subnets for migration test" + ); + + // Verify source canister has value 42 + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "source-canister", + "get", + "()", + ]) + .assert() + .success() + .stdout(contains("\"42\"")); + + // Stop both canisters + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "source-canister", + "--environment", + "random-environment", + ]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "target-canister", + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Top up source canister to ensure it has enough cycles for migration (15T) + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "top-up", + "source-canister", + "--amount", + "15t", + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Run migrate-id command. This takes six minutes so we will cut it early and then time-travel. + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "migrate-id", + "source-canister", + "--replace", + "target-canister", + "--yes", + "--environment", + "random-environment", + "--skip-watch", + ]) + .assert() + .success() + .stdout(contains("Migration will continue in the background")); + + ctx.pocketic_time_fastforward(Duration::from_secs(360)) + .await; + + // Continue polling and use the new time + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "migrate-id", + "source-canister", + "--replace", + "target-canister", + "--yes", + "--environment", + "random-environment", + "--resume-watch", + ]) + .assert() + .success() + .stdout(contains(format!( + "Canister 'source-canister' ({source_cid}) has been successfully migrated" + ))); + + // After migration, the target canister should now have the source's ID + // We need to call it using the source's canister ID but it should have target's state + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "start", + &source_cid.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // The canister at source_cid should now return 99 (the target's value) + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + &source_cid.to_string(), + "get", + "()", + ]) + .assert() + .success() + .stdout(contains("\"99\"")); + + // The original target canister ID should no longer exist + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + &target_cid.to_string(), + "--environment", + "random-environment", + ]) + .assert() + .failure(); +} diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index 890cd628..47b40017 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -1,9 +1,18 @@ -use std::{cell::OnceCell, env, ffi::OsString, fs}; +use std::{ + cell::{Cell, OnceCell}, + env, + ffi::OsString, + fs, + time::Duration, +}; use assert_cmd::{Command, cargo::cargo_bin_cmd}; use camino_tempfile::{Utf8TempDir as TempDir, tempdir}; use ic_agent::Agent; use icp::prelude::*; +use reqwest::Client; +use serde_json::json; +use time::UtcDateTime; use url::Url; use crate::common::{ChildGuard, PATH_SEPARATOR, TestNetwork, softhsm::SoftHsmContext}; @@ -15,6 +24,8 @@ pub(crate) struct TestContext { mock_cred_dir: PathBuf, os_path: OsString, gateway_url: OnceCell, + config_url: OnceCell>, + time_offset: Cell>, root_key: OnceCell>, softhsm: OnceCell, } @@ -52,8 +63,10 @@ impl TestContext { mock_cred_dir, os_path, gateway_url: OnceCell::new(), + config_url: OnceCell::new(), root_key: OnceCell::new(), softhsm: OnceCell::new(), + time_offset: Cell::new(None), } } @@ -92,6 +105,13 @@ impl TestContext { cmd.env("SOFTHSM2_CONF", &hsm.config_path); } + if let Some(offset) = self.time_offset.get() { + cmd.env( + "ICP_CLI_TEST_ADVANCE_TIME_MS", + offset.as_millis().to_string(), + ); + } + cmd } @@ -233,6 +253,15 @@ impl TestContext { .unwrap(), ) .expect("Gateway URL should not be already initialized"); + self.config_url + .set(network_descriptor.pocketic_config_port.and_then(|port| { + network_descriptor.pocketic_instance_id.map(|instance| { + format!("http://localhost:{port}/instances/{instance}/") + .parse() + .expect("Failed to parse PocketIC config URL") + }) + })) + .expect("Config URL should not be already initialized"); child_guard } @@ -302,6 +331,14 @@ impl TestContext { .and_then(|p| p.as_u64()) .expect("network descriptor does not contain gateway port") as u16; + let pocketic_config_port: Option = network_descriptor + .get("pocketic-config-port") + .and_then(|p| p.as_u64()) + .map(|p| p as u16); + let pocketic_instance_id: Option = network_descriptor + .get("pocketic-instance-id") + .and_then(|p| p.as_u64()) + .map(|p| p as usize); let root_key = network_descriptor .get("root-key") @@ -313,6 +350,8 @@ impl TestContext { TestNetwork { gateway_port, root_key, + pocketic_config_port, + pocketic_instance_id, } } @@ -350,6 +389,34 @@ impl TestContext { agent } + pub(crate) async fn pocketic_time_fastforward(&self, duration: Duration) { + let now = UtcDateTime::now(); + Client::new() + .post( + self.config_url + .get() + .expect("network must have been initialized") + .as_ref() + .expect("network must use pocket-ic") + .join("update/set_time") + .unwrap(), + ) + .json(&json!({ "nanos_since_epoch": (now + duration).unix_timestamp_nanos() })) + .send() + .await + .expect("failed to update time") + .error_for_status() + .expect("failed to update time"); + self.time_offset.set(Some(duration)); + } + + pub(crate) fn pocketic_config_url(&self) -> Option<&Url> { + self.config_url + .get() + .expect("network must have been initialized") + .as_ref() + } + pub(crate) fn docker_pull_network(&self) { let platform = if cfg!(target_arch = "aarch64") { "linux/arm64" diff --git a/crates/icp-cli/tests/common/mod.rs b/crates/icp-cli/tests/common/mod.rs index 75c76788..7ec02d52 100644 --- a/crates/icp-cli/tests/common/mod.rs +++ b/crates/icp-cli/tests/common/mod.rs @@ -77,6 +77,8 @@ pub(crate) fn spawn_test_server(method: &str, path: &str, body: &[u8]) -> httpte // after starting the network. pub(crate) struct TestNetwork { pub(crate) gateway_port: u16, + pub(crate) pocketic_config_port: Option, + pub(crate) pocketic_instance_id: Option, pub(crate) root_key: Vec, } diff --git a/crates/icp/src/agent.rs b/crates/icp/src/agent.rs index ae5ccdf6..68d47d4a 100644 --- a/crates/icp/src/agent.rs +++ b/crates/icp/src/agent.rs @@ -22,10 +22,15 @@ pub struct Creator; #[async_trait] impl Create for Creator { async fn create(&self, id: Arc, url: &str) -> Result { - let b = Agent::builder() - .with_url(url) - .with_arc_identity(id) - .with_ingress_expiry(Duration::from_secs(4 * MINUTE)); + let mut b = Agent::builder().with_url(url).with_arc_identity(id); + let default_ingress_expiry = Duration::from_secs(4 * MINUTE); + if let Ok(ms) = std::env::var("ICP_CLI_TEST_ADVANCE_TIME_MS") { + b = b.with_ingress_expiry( + default_ingress_expiry + Duration::from_millis(ms.parse::().unwrap()), + ); + } else { + b = b.with_ingress_expiry(default_ingress_expiry); + } Ok(b.build().context(AgentSnafu)?) } From 4c32b272b463f30b0bf49d62647395fc17e0a95b Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 5 Feb 2026 08:48:18 -0800 Subject: [PATCH 2/5] Clean up --- .../src/nns_migration.rs | 34 +++++++++--------- .../src/commands/canister/migrate_id.rs | 36 +++++++++++-------- 2 files changed, 39 insertions(+), 31 deletions(-) diff --git a/crates/icp-canister-interfaces/src/nns_migration.rs b/crates/icp-canister-interfaces/src/nns_migration.rs index 5f40ffc0..a9b52f93 100644 --- a/crates/icp-canister-interfaces/src/nns_migration.rs +++ b/crates/icp-canister-interfaces/src/nns_migration.rs @@ -1,4 +1,4 @@ -use candid::{CandidType, Deserialize, Principal, Reserved}; +use candid::{CandidType, Deserialize, Principal}; use std::fmt; /// The NNS migration canister ID. @@ -18,29 +18,29 @@ pub struct MigrateCanisterArgs { /// Validation errors returned by the NNS migration canister. #[derive(Clone, Debug, CandidType, Deserialize, PartialEq, Eq)] pub enum ValidationError { - MigrationsDisabled(Reserved), - RateLimited(Reserved), + MigrationsDisabled, + RateLimited, ValidationInProgress { canister: Principal }, MigrationInProgress { canister: Principal }, CanisterNotFound { canister: Principal }, - SameSubnet(Reserved), + SameSubnet, CallerNotController { canister: Principal }, NotController { canister: Principal }, - MigratedCanisterNotStopped(Reserved), - MigratedCanisterNotReady(Reserved), - ReplacedCanisterNotStopped(Reserved), - ReplacedCanisterHasSnapshots(Reserved), - MigratedCanisterInsufficientCycles(Reserved), + MigratedCanisterNotStopped, + MigratedCanisterNotReady, + ReplacedCanisterNotStopped, + ReplacedCanisterHasSnapshots, + MigratedCanisterInsufficientCycles, CallFailed { reason: String }, } impl fmt::Display for ValidationError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ValidationError::MigrationsDisabled(_) => { + ValidationError::MigrationsDisabled => { write!(f, "Canister migrations are disabled at the moment") } - ValidationError::RateLimited(_) => { + ValidationError::RateLimited => { write!( f, "Canister migration has been rate-limited. Try again later" @@ -61,7 +61,7 @@ impl fmt::Display for ValidationError { ValidationError::CanisterNotFound { canister } => { write!(f, "The canister {canister} does not exist") } - ValidationError::SameSubnet(_) => { + ValidationError::SameSubnet => { write!(f, "Both canisters are on the same subnet") } ValidationError::CallerNotController { canister } => { @@ -76,22 +76,22 @@ impl fmt::Display for ValidationError { "The NNS migration canister ({NNS_MIGRATION_PRINCIPAL}) is not a controller of canister {canister}" ) } - ValidationError::MigratedCanisterNotStopped(_) => { + ValidationError::MigratedCanisterNotStopped => { write!(f, "The migrated canister is not stopped") } - ValidationError::MigratedCanisterNotReady(_) => { + ValidationError::MigratedCanisterNotReady => { write!( f, "The migrated canister is not ready for migration. Try again later" ) } - ValidationError::ReplacedCanisterNotStopped(_) => { + ValidationError::ReplacedCanisterNotStopped => { write!(f, "The replaced canister is not stopped") } - ValidationError::ReplacedCanisterHasSnapshots(_) => { + ValidationError::ReplacedCanisterHasSnapshots => { write!(f, "The replaced canister has snapshots") } - ValidationError::MigratedCanisterInsufficientCycles(_) => { + ValidationError::MigratedCanisterInsufficientCycles => { write!( f, "The migrated canister does not have enough cycles for migration. Top up with at least 10T cycles" diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs index 000da1be..24690e08 100644 --- a/crates/icp-cli/src/commands/canister/migrate_id.rs +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -1,3 +1,4 @@ +use std::io::{IsTerminal, stderr}; use std::time::Duration; use anyhow::bail; @@ -83,7 +84,8 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh // If --resume-watch is set, skip all validation and migration initiation if !args.resume_watch { - if !args.yes { + let can_confirm = stderr().is_terminal(); + if !args.yes && can_confirm { ctx.term.write_line(&format!( "This will migrate canister '{source_name}' ({source_cid}) to replace '{target_name}' ({target_cid})." ))?; @@ -132,19 +134,25 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh } if !args.yes && cycles > WARN_CYCLES_THRESHOLD { - ctx.term.write_line(&format!( - "Warning: Canister '{source_name}' has more than 15T cycles ({cycles} cycles)." - ))?; - ctx.term - .write_line("The extra cycles will get burned during the migration.")?; - - let confirmed = Confirm::new() - .with_prompt("Do you want to proceed?") - .default(false) - .interact()?; - - if !confirmed { - bail!("Operation cancelled by user"); + if can_confirm { + ctx.term.write_line(&format!( + "Warning: Canister '{source_name}' has more than 15T cycles ({cycles} cycles)." + ))?; + ctx.term + .write_line("The extra cycles will get burned during the migration.")?; + + let confirmed = Confirm::new() + .with_prompt("Do you want to proceed?") + .default(false) + .interact()?; + + if !confirmed { + bail!("Operation cancelled by user"); + } + } else { + bail!( + "Canister '{source_name}' has more than 15T cycles ({cycles} cycles). The extra cycles will get burned during the migration. Rerun with --yes to proceed anyway" + ); } } From 536150ae97e9cb5e7be85b3603a31cb5cb9c8e18 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 5 Feb 2026 10:02:57 -0800 Subject: [PATCH 3/5] use secrets.GITHUB_TOKEN to avoid ratelimit --- .github/workflows/test.yml | 2 ++ crates/icp/src/network/managed/cache.rs | 8 +++++--- 2 files changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 89033c93..81e7281d 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -143,6 +143,8 @@ jobs: - name: Run ${{ matrix.test }} # the macos runners do not support Docker run: cargo test --test ${{ matrix.test }} -- ${{ contains(matrix.os, 'macos') && '--skip :docker:' || '' }} + env: + ICP_CLI_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} aggregate: name: test:required diff --git a/crates/icp/src/network/managed/cache.rs b/crates/icp/src/network/managed/cache.rs index dd70b217..522634e7 100644 --- a/crates/icp/src/network/managed/cache.rs +++ b/crates/icp/src/network/managed/cache.rs @@ -40,9 +40,11 @@ pub enum ReadCacheError { pub async fn get_latest_launcher_version(client: &Client) -> Result { let url = "https://api.github.com/repos/dfinity/icp-cli-network-launcher/releases/latest"; - let response: serde_json::Value = client - .get(url) - .header("User-Agent", "icp-cli") + let mut req = client.get(url).header("User-Agent", "icp-cli"); + if let Ok(token) = std::env::var("ICP_CLI_GITHUB_TOKEN") { + req = req.bearer_auth(token); + } + let response: serde_json::Value = req .send() .await .context(LatestVersionFetchSnafu)? From 57bbf51f40e6c59291efe030a6c50a23a2d37b93 Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Thu, 5 Feb 2026 10:07:06 -0800 Subject: [PATCH 4/5] md --- docs/reference/cli.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/docs/reference/cli.md b/docs/reference/cli.md index f5cc8515..5a265b69 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -13,6 +13,7 @@ This document contains the help content for the `icp` command-line program. * [`icp canister install`↴](#icp-canister-install) * [`icp canister list`↴](#icp-canister-list) * [`icp canister metadata`↴](#icp-canister-metadata) +* [`icp canister migrate-id`↴](#icp-canister-migrate-id) * [`icp canister settings`↴](#icp-canister-settings) * [`icp canister settings show`↴](#icp-canister-settings-show) * [`icp canister settings update`↴](#icp-canister-settings-update) @@ -120,6 +121,7 @@ Perform canister operations against a network * `install` — Install a built WASM to a canister on a network * `list` — List the canisters in an environment * `metadata` — Read a metadata section from a canister +* `migrate-id` — Migrate a canister ID from one subnet to another * `settings` — Commands to manage canister settings * `snapshot` — Commands to manage canister snapshots * `start` — Start a canister on a network @@ -274,6 +276,28 @@ Read a metadata section from a canister +## `icp canister migrate-id` + +Migrate a canister ID from one subnet to another + +**Usage:** `icp canister migrate-id [OPTIONS] --replace ` + +###### **Arguments:** + +* `` — Name or principal of canister to target When using a name an environment must be specified + +###### **Options:** + +* `-n`, `--network ` — Name of the network to target, conflicts with environment argument +* `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used +* `--identity ` — The user identity to run this command as +* `--replace ` — The canister to replace with the source canister's ID +* `-y`, `--yes` — Skip confirmation prompts +* `--resume-watch` — Resume watching an already-initiated migration (skips validation and initiation) +* `--skip-watch` — Exit as soon as the migrated canister is deleted (don't wait for full completion) + + + ## `icp canister settings` Commands to manage canister settings From d1cb4438ad108b6cc7ca43cab58502a89c53074e Mon Sep 17 00:00:00 2001 From: Adam Spofford Date: Fri, 6 Feb 2026 05:52:31 -0800 Subject: [PATCH 5/5] feedback --- CHANGELOG.md | 2 +- Cargo.lock | 5 +++-- Cargo.toml | 2 +- crates/icp-cli/src/commands/canister/migrate_id.rs | 8 +++++++- crates/icp/src/agent.rs | 6 +++++- 5 files changed, 17 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f72af005..6c26ae78 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -* feat: `icp cansister migrate-id` - initiate canister ID migration across subnets +* feat: `icp canister migrate-id` - initiate canister ID migration across subnets # v0.1.0 diff --git a/Cargo.lock b/Cargo.lock index fc13f009..0a6a0cec 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2845,8 +2845,9 @@ dependencies = [ [[package]] name = "ic-asset" -version = "0.26.0" -source = "git+https://github.com/dfinity/sdk?rev=32c21b06cf6bed4797fb58f2511859a1d6dfd5ef#32c21b06cf6bed4797fb58f2511859a1d6dfd5ef" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cc3c51dd0182931fe12cd9b4b4553d2ff8e42f279a3008f3ceae6680ad6a013" dependencies = [ "backoff", "brotli", diff --git a/Cargo.toml b/Cargo.toml index 262770f1..4a6b33ea 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -44,7 +44,7 @@ handlebars = "6.3.2" hex = "0.4.3" httptest = "0.16.3" ic-agent = { version = "0.45.0" } -ic-asset = { git = "https://github.com/dfinity/sdk", rev = "32c21b06cf6bed4797fb58f2511859a1d6dfd5ef" } +ic-asset = "0.27.0" ic-ed25519 = "0.5.0" ic-ledger-types = "0.16.0" ic-management-canister-types = { version = "0.5.0" } diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs index 24690e08..ffa831f0 100644 --- a/crates/icp-cli/src/commands/canister/migrate_id.rs +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -1,5 +1,5 @@ use std::io::{IsTerminal, stderr}; -use std::time::Duration; +use std::time::{Duration, Instant}; use anyhow::bail; use clap::Args; @@ -234,6 +234,7 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh spinner.set_message("Waiting for migration to complete..."); // Poll for completion + let start = Instant::now(); loop { match migration_status(&agent, source_cid, target_cid).await { Ok(Some(MigrationStatus::InProgress { status })) => { @@ -277,6 +278,11 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh } tokio::time::sleep(Duration::from_secs(1)).await; + if Instant::now().duration_since(start) > Duration::from_secs(720) { + bail!( + "Timed out waiting for canister migration to complete (12 minutes). Rerun with --resume-watch to continue waiting" + ); + } } ctx.term.write_line(&format!( diff --git a/crates/icp/src/agent.rs b/crates/icp/src/agent.rs index 68d47d4a..d662aacd 100644 --- a/crates/icp/src/agent.rs +++ b/crates/icp/src/agent.rs @@ -26,7 +26,11 @@ impl Create for Creator { let default_ingress_expiry = Duration::from_secs(4 * MINUTE); if let Ok(ms) = std::env::var("ICP_CLI_TEST_ADVANCE_TIME_MS") { b = b.with_ingress_expiry( - default_ingress_expiry + Duration::from_millis(ms.parse::().unwrap()), + default_ingress_expiry + + Duration::from_millis( + ms.parse::() + .expect("ICP_CLI_TEST_ADVANCE_TIME_MS must be set to an int"), + ), ); } else { b = b.with_ingress_expiry(default_ingress_expiry);