diff --git a/Cargo.lock b/Cargo.lock index fc6ce9071d..cffd446f82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5148,7 +5148,7 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "pocket-ic" version = "10.0.0" -source = "git+https://github.com/dfinity/ic?rev=575bcd0954e9d00066fd465223b755bda645edd6#575bcd0954e9d00066fd465223b755bda645edd6" +source = "git+https://github.com/dfinity/ic?rev=9276bb4c90c5adc5718cd144342e6b56c13a3ff6#9276bb4c90c5adc5718cd144342e6b56c13a3ff6" dependencies = [ "backoff", "base64 0.13.1", diff --git a/e2e/tests-dfx/canister_migration.bash b/e2e/tests-dfx/canister_migration.bash new file mode 100755 index 0000000000..e34a52e488 --- /dev/null +++ b/e2e/tests-dfx/canister_migration.bash @@ -0,0 +1,41 @@ +#!/usr/bin/env bats + +load ../utils/_ + +setup() { + standard_setup + dfx_new hello +} + +teardown() { + dfx_stop + standard_teardown +} + +@test "canister migrate canister id" { + dfx_start --system-canisters + install_asset counter + + # Update dfx.json: rename hello_backend -> source, and add target canister + jq '.canisters.source = .canisters.hello_backend | del(.canisters.hello_backend)' dfx.json | sponge dfx.json + jq '.canisters.target = { "main": "counter.mo", "type": "motoko" }' dfx.json | sponge dfx.json + + # Deploy the source to the application subnet. + dfx deploy source + + # Create the target canister on the fiduciary subnet. + dfx canister create target --subnet-type fiduciary + + dfx canister stop source + dfx canister stop target + + # Make sure the source has enough cycles to do the migration. + dfx ledger fabricate-cycles --canister source --cycles 10000000000000 + + # The migration will take a few minutes to complete. + assert_command dfx canister migrate-id source --replace target --yes + assert_contains "Migration succeeded" + + assert_command dfx canister status source + assert_command_fail dfx canister status target +} diff --git a/src/dfx/Cargo.toml b/src/dfx/Cargo.toml index 4204b5d29c..3a58cfb164 100644 --- a/src/dfx/Cargo.toml +++ b/src/dfx/Cargo.toml @@ -84,7 +84,7 @@ os_str_bytes = { version = "6.3.0", features = ["conversions"] } patch = "0.7.0" pem.workspace = true petgraph = "0.6.0" -pocket-ic = { git = "https://github.com/dfinity/ic", rev = "575bcd0954e9d00066fd465223b755bda645edd6" } +pocket-ic = { git = "https://github.com/dfinity/ic", rev = "9276bb4c90c5adc5718cd144342e6b56c13a3ff6" } rand = "0.8.5" regex = "1.5.5" reqwest = { workspace = true, features = ["blocking", "json"] } diff --git a/src/dfx/assets/dfx-asset-sources.json b/src/dfx/assets/dfx-asset-sources.json index 26b3b9737c..04f796b03f 100644 --- a/src/dfx/assets/dfx-asset-sources.json +++ b/src/dfx/assets/dfx-asset-sources.json @@ -1,5 +1,5 @@ { - "replica-rev": "575bcd0954e9d00066fd465223b755bda645edd6", + "replica-rev": "9276bb4c90c5adc5718cd144342e6b56c13a3ff6", "x86_64-darwin": { "motoko": { "url": "https://github.com/dfinity/motoko/releases/download/0.16.2/motoko-Darwin-x86_64-0.16.2.tar.gz", @@ -7,9 +7,9 @@ "version": "0.16.2" }, "pocket-ic": { - "url": "https://download.dfinity.systems/ic/575bcd0954e9d00066fd465223b755bda645edd6/binaries/x86_64-darwin/pocket-ic.gz", - "sha256": "aae66c74224421a9a8055b48d2189891eea12d8fa932ec5fb36a6c827e0be43a", - "rev": "575bcd0954e9d00066fd465223b755bda645edd6" + "url": "https://download.dfinity.systems/ic/9276bb4c90c5adc5718cd144342e6b56c13a3ff6/binaries/x86_64-darwin/pocket-ic.gz", + "sha256": "3af2af65b87d6a6b3c3bfb4ee08a42d840c29a881a652a1e63eeac496c029597", + "rev": "9276bb4c90c5adc5718cd144342e6b56c13a3ff6" } }, "arm64-darwin": { @@ -19,9 +19,9 @@ "version": "0.16.2" }, "pocket-ic": { - "url": "https://download.dfinity.systems/ic/575bcd0954e9d00066fd465223b755bda645edd6/binaries/arm64-darwin/pocket-ic.gz", - "sha256": "988020fde4cfba0abfd9957d4364dcab6d5c2b64aae19c786f33474a192ff11e", - "rev": "575bcd0954e9d00066fd465223b755bda645edd6" + "url": "https://download.dfinity.systems/ic/9276bb4c90c5adc5718cd144342e6b56c13a3ff6/binaries/arm64-darwin/pocket-ic.gz", + "sha256": "a9b8b29b88a47069babb18e524abdbd9f54a7a32ff878d93ba5c93230faae7de", + "rev": "9276bb4c90c5adc5718cd144342e6b56c13a3ff6" } }, "x86_64-linux": { @@ -31,9 +31,9 @@ "version": "0.16.2" }, "pocket-ic": { - "url": "https://download.dfinity.systems/ic/575bcd0954e9d00066fd465223b755bda645edd6/binaries/x86_64-linux/pocket-ic.gz", - "sha256": "99edb5b28c1e62ad3d2b86b3ad022c98ddc734671dad9abe1fc672bc0048d1e9", - "rev": "575bcd0954e9d00066fd465223b755bda645edd6" + "url": "https://download.dfinity.systems/ic/9276bb4c90c5adc5718cd144342e6b56c13a3ff6/binaries/x86_64-linux/pocket-ic.gz", + "sha256": "08e45801214ec1f0e95a483e39a007553785a5cb676c4144eb8a5eb2749bf67b", + "rev": "9276bb4c90c5adc5718cd144342e6b56c13a3ff6" } }, "arm64-linux": { @@ -43,9 +43,9 @@ "version": "0.16.2" }, "pocket-ic": { - "url": "https://download.dfinity.systems/ic/575bcd0954e9d00066fd465223b755bda645edd6/binaries/arm64-linux/pocket-ic.gz", - "sha256": "bd5aae42649a68e7febd715d7cadf4a78a6b9b5e2cd5fb6ae528750a463fdd77", - "rev": "575bcd0954e9d00066fd465223b755bda645edd6" + "url": "https://download.dfinity.systems/ic/9276bb4c90c5adc5718cd144342e6b56c13a3ff6/binaries/arm64-linux/pocket-ic.gz", + "sha256": "f4db572e52cda7d92a4b6a233c240df0fa9cbd1f1e91034a97e03de75a43e375", + "rev": "9276bb4c90c5adc5718cd144342e6b56c13a3ff6" } }, "common": { diff --git a/src/dfx/src/actors/pocketic.rs b/src/dfx/src/actors/pocketic.rs index 3c65ba1386..2351bf2d62 100644 --- a/src/dfx/src/actors/pocketic.rs +++ b/src/dfx/src/actors/pocketic.rs @@ -396,6 +396,7 @@ async fn initialize_pocketic( ii: Some(IcpFeaturesConfig::default()), nns_ui: Some(IcpFeaturesConfig::default()), bitcoin: None, + canister_migration: Some(IcpFeaturesConfig::default()), }) } else { None diff --git a/src/dfx/src/commands/canister/migrate_id.rs b/src/dfx/src/commands/canister/migrate_id.rs new file mode 100644 index 0000000000..cf7d1af695 --- /dev/null +++ b/src/dfx/src/commands/canister/migrate_id.rs @@ -0,0 +1,200 @@ +use crate::lib::environment::Environment; +use crate::lib::error::DfxResult; +use crate::lib::ic_attributes::CanisterSettings; +use crate::lib::operations::canister::{ + get_canister_status, list_canister_snapshots, update_settings, +}; +use crate::lib::operations::canister_migration::{ + MigrationStatus, NNS_MIGRATION_CANISTER_ID, migrate_canister, migration_status, +}; +use crate::lib::root_key::fetch_root_key_if_needed; +use crate::lib::subnet::get_subnet_for_canister; +use crate::util::ask_for_consent; +use anyhow::{Context, bail}; +use candid::Principal; +use clap::Parser; +use dfx_core::identity::CallSender; +use ic_management_canister_types::CanisterStatusType; +use num_traits::ToPrimitive; +use slog::{debug, error, info}; +use std::time::Duration; +use time::{OffsetDateTime, macros::format_description}; + +/// Migrate a canister ID from one subnet to another. +#[derive(Parser)] +#[command(override_usage = "dfx canister migrate-id [OPTIONS] --replace ")] +pub struct CanisterMigrateIdOpts { + /// Specifies the name or id of the canister to migrate. + canister: String, + + /// Specifies the name or id of the canister to replace. + #[arg(long)] + replace: String, + + /// Skips yes/no checks by answering 'yes'. Not recommended outside of CI. + #[arg(long, short)] + yes: bool, +} + +pub async fn exec( + env: &dyn Environment, + opts: CanisterMigrateIdOpts, + call_sender: &CallSender, +) -> DfxResult { + fetch_root_key_if_needed(env).await?; + + let log = env.get_logger(); + let agent = env.get_agent(); + let canister_id_store = env.get_canister_id_store()?; + + // Get the canister IDs. + let source_canister = opts.canister.as_str(); + let target_canister = opts.replace.as_str(); + let source_canister_id = Principal::from_text(source_canister) + .or_else(|_| canister_id_store.get(source_canister))?; + let target_canister_id = Principal::from_text(target_canister) + .or_else(|_| canister_id_store.get(target_canister))?; + + if source_canister_id == target_canister_id { + bail!("The canisters to migrate and replace are identical."); + } + + if !opts.yes { + ask_for_consent( + env, + &format!("Canister '{source_canister}' will be removed from its own subnet. Continue?"), + )?; + } + + let source_status = get_canister_status(env, source_canister_id, call_sender) + .await + .with_context(|| format!("Could not retrieve status of canister {source_canister}"))?; + let target_status = get_canister_status(env, target_canister_id, call_sender) + .await + .with_context(|| format!("Could not retrieve status of canister {target_canister}"))?; + + ensure_canister_stopped(source_status.status, source_canister)?; + ensure_canister_stopped(target_status.status, target_canister)?; + + // Check the cycles balance of source_canister. + let cycles = source_status + .cycles + .0 + .to_u128() + .expect("Unable to parse cycles"); + if cycles < 10_000_000_000_000 { + bail!("Canister '{source_canister}' has less than 10T cycles"); + } + if !opts.yes && cycles > 15_000_000_000_000 { + ask_for_consent( + env, + &format!( + "Canister '{source_canister}' has more than 15T cycles. The extra cycles will get burned during the migration. Continue?" + ), + )?; + } + + // Check that the target canister has no snapshots. + let snapshots = list_canister_snapshots(env, target_canister_id, call_sender).await?; + if !snapshots.is_empty() { + bail!( + "The canister '{}' whose canister ID will be replaced has snapshots", + target_canister + ); + } + + // Check that the two canisters are on different subnets. + let source_subnet = get_subnet_for_canister(agent, source_canister_id).await?; + let target_subnet = get_subnet_for_canister(agent, target_canister_id).await?; + if source_subnet == target_subnet { + bail!("The canisters '{source_canister}' and '{target_canister}' are on the same subnet"); + } + + // Add the NNS migration canister as a controller to the source canister. + let mut controllers = source_status.settings.controllers.clone(); + if !controllers.contains(&NNS_MIGRATION_CANISTER_ID) { + controllers.push(NNS_MIGRATION_CANISTER_ID); + let settings = CanisterSettings { + controllers: Some(controllers), + compute_allocation: None, + memory_allocation: None, + freezing_threshold: None, + reserved_cycles_limit: None, + wasm_memory_limit: None, + wasm_memory_threshold: None, + log_visibility: None, + environment_variables: None, + }; + update_settings(env, source_canister_id, settings, call_sender).await?; + } + + // Add the NNS migration canister as a controller to the target canister. + let mut controllers = target_status.settings.controllers.clone(); + if !controllers.contains(&NNS_MIGRATION_CANISTER_ID) { + controllers.push(NNS_MIGRATION_CANISTER_ID); + let settings = CanisterSettings { + controllers: Some(controllers), + compute_allocation: None, + memory_allocation: None, + freezing_threshold: None, + reserved_cycles_limit: None, + wasm_memory_limit: None, + wasm_memory_threshold: None, + log_visibility: None, + environment_variables: None, + }; + update_settings(env, target_canister_id, settings, call_sender).await?; + } + + // Migrate the from canister to the rename_to canister. + debug!(log, "Migrate '{source_canister}' to '{target_canister}'"); + migrate_canister(agent, source_canister_id, target_canister_id).await?; + + // Wait for migration to complete. + let spinner = env.new_spinner("Waiting for migration to complete...".into()); + loop { + let statuses = migration_status(agent, source_canister_id, target_canister_id).await?; + match statuses.first() { + Some(MigrationStatus::InProgress { status }) => { + spinner.set_message(format!("Migration in progress: {status}").into()); + } + Some(MigrationStatus::Succeeded { time }) => { + spinner.finish_and_clear(); + info!(log, "Migration succeeded at {}", format_time(time)); + break; + } + Some(MigrationStatus::Failed { reason, time }) => { + spinner.finish_and_clear(); + error!(log, "Migration failed at {}: {}", format_time(time), reason); + break; + } + None => (), + } + + tokio::time::sleep(Duration::from_secs(1)).await; + } + + canister_id_store.remove(log, target_canister)?; + + Ok(()) +} + +fn ensure_canister_stopped(status: CanisterStatusType, canister: &str) -> DfxResult { + match status { + CanisterStatusType::Stopped => Ok(()), + CanisterStatusType::Running => { + bail!("Canister {canister} is running. Run 'dfx canister stop' first"); + } + CanisterStatusType::Stopping => { + bail!("Canister {canister} is stopping. Wait a few seconds and try again"); + } + } +} + +fn format_time(time: &u64) -> String { + let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC"); + OffsetDateTime::from_unix_timestamp_nanos(*time as i128) + .unwrap() + .format(&format) + .unwrap() +} diff --git a/src/dfx/src/commands/canister/migration_status.rs b/src/dfx/src/commands/canister/migration_status.rs new file mode 100644 index 0000000000..ec606d50df --- /dev/null +++ b/src/dfx/src/commands/canister/migration_status.rs @@ -0,0 +1,135 @@ +use crate::lib::environment::Environment; +use crate::lib::error::DfxResult; +use crate::lib::operations::canister_migration::{MigrationStatus, migration_status}; +use crate::lib::root_key::fetch_root_key_if_needed; + +use candid::Principal; +use clap::Parser; +use slog::info; +use time::{OffsetDateTime, macros::format_description}; + +/// Show the status of a migration. +#[derive(Parser)] +#[command( + override_usage = "dfx canister migration-status [OPTIONS] --replace " +)] +pub struct CanisterMigrationStatusOpts { + /// Specifies the name or id of the canister to migrate. + canister: String, + + /// Specifies the name or id of the canister to replace. + #[arg(long)] + replace: String, +} + +pub async fn exec(env: &dyn Environment, opts: CanisterMigrationStatusOpts) -> DfxResult { + fetch_root_key_if_needed(env).await?; + + let log = env.get_logger(); + let agent = env.get_agent(); + let canister_id_store = env.get_canister_id_store()?; + + // Get the canister IDs. + let source_canister = opts.canister.as_str(); + let target_canister = opts.replace.as_str(); + let source_canister_id = Principal::from_text(source_canister) + .or_else(|_| canister_id_store.get(source_canister)) + .map_err(|_| { + anyhow::anyhow!( + "Cannot find canister '{source_canister}'. Please use canister id instead" + ) + })?; + let target_canister_id = Principal::from_text(target_canister) + .or_else(|_| canister_id_store.get(target_canister)) + .map_err(|_| { + anyhow::anyhow!( + "Cannot find canister '{target_canister}'. Please use canister id instead" + ) + })?; + + let statuses = migration_status(agent, source_canister_id, target_canister_id).await?; + + if statuses.is_empty() { + info!( + log, + "No migration status found for canister '{source_canister}' to '{target_canister}'" + ); + return Ok(()); + } + + // Print the statuses in a table with aligned columns. + let source_text = source_canister_id.to_text(); + let target_text = target_canister_id.to_text(); + let status_strings: Vec = statuses.iter().map(format_status).collect(); + + let header_source = "Canister"; + let header_target = "Canister To Be Replaced"; + let header_status = "Migration Status"; + + let source_width = header_source.len().max(source_text.len()); + let target_width = header_target.len().max(target_text.len()); + let status_width = header_status + .len() + .max(status_strings.iter().map(|s| s.len()).max().unwrap_or(0)); + + let sep_source = "-".repeat(source_width); + let sep_target = "-".repeat(target_width); + let sep_status = "-".repeat(status_width); + + info!( + log, + "| {: String { + match status { + MigrationStatus::InProgress { status } => { + format!("In progress: {status}") + } + MigrationStatus::Failed { reason, time } => { + format!("Failed: {reason} at {}", format_time(time)) + } + MigrationStatus::Succeeded { time } => { + format!("Succeeded at {}", format_time(time)) + } + } +} + +fn format_time(time: &u64) -> String { + let format = format_description!("[year]-[month]-[day] [hour]:[minute]:[second] UTC"); + OffsetDateTime::from_unix_timestamp_nanos(*time as i128) + .unwrap() + .format(&format) + .unwrap() +} diff --git a/src/dfx/src/commands/canister/mod.rs b/src/dfx/src/commands/canister/mod.rs index 03beb19828..ccc0520cd5 100644 --- a/src/dfx/src/commands/canister/mod.rs +++ b/src/dfx/src/commands/canister/mod.rs @@ -15,6 +15,8 @@ mod info; mod install; mod logs; mod metadata; +mod migrate_id; +mod migration_status; mod request_status; mod send; mod set_id; @@ -53,6 +55,8 @@ pub enum SubCommand { Info(info::InfoOpts), Install(install::CanisterInstallOpts), Metadata(metadata::CanisterMetadataOpts), + MigrateId(migrate_id::CanisterMigrateIdOpts), + MigrationStatus(migration_status::CanisterMigrationStatusOpts), RequestStatus(request_status::RequestStatusOpts), Send(send::CanisterSendOpts), SetId(set_id::CanisterSetIdOpts), @@ -88,6 +92,8 @@ pub fn exec(env: &dyn Environment, opts: CanisterOpts) -> DfxResult { SubCommand::Install(v) => install::exec(env, v, &call_sender()?).await, SubCommand::Info(v) => info::exec(env, v).await, SubCommand::Metadata(v) => metadata::exec(env, v).await, + SubCommand::MigrateId(v) => migrate_id::exec(env, v, &call_sender()?).await, + SubCommand::MigrationStatus(v) => migration_status::exec(env, v).await, SubCommand::RequestStatus(v) => request_status::exec(env, v).await, SubCommand::Send(v) => send::exec(env, v, &call_sender()?).await, SubCommand::SetId(v) => set_id::exec(env, v).await, diff --git a/src/dfx/src/lib/operations/canister_migration.rs b/src/dfx/src/lib/operations/canister_migration.rs new file mode 100644 index 0000000000..c15b6e6d81 --- /dev/null +++ b/src/dfx/src/lib/operations/canister_migration.rs @@ -0,0 +1,142 @@ +use crate::lib::error::DfxResult; +use candid::{CandidType, Principal}; +use ic_agent::Agent; +use ic_utils::Canister; +use serde::Deserialize; +use std::fmt; + +pub const NNS_MIGRATION_CANISTER_ID: Principal = + Principal::from_slice(&[0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x11, 0x01, 0x01]); +const MIGRATE_CANISTER_METHOD: &str = "migrate_canister"; +const MIGRATION_STATUS_METHOD: &str = "migration_status"; + +#[derive(Clone, CandidType, Deserialize)] +pub struct MigrateCanisterArgs { + pub source: Principal, + pub target: Principal, +} + +#[derive(Clone, Debug, CandidType, Deserialize)] +pub enum ValidationError { + MigrationsDisabled, + RateLimited, + MigrationInProgress { canister: Principal }, + CanisterNotFound { canister: Principal }, + SameSubnet, + CallerNotController { canister: Principal }, + NotController { canister: Principal }, + SourceNotStopped, + SourceNotReady, + TargetNotStopped, + TargetHasSnapshots, + SourceInsufficientCycles, + CallFailed { reason: String }, +} + +impl fmt::Display for ValidationError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ValidationError::MigrationsDisabled => write!(f, "MigrationsDisabled"), + ValidationError::RateLimited => write!(f, "RateLimited"), + ValidationError::MigrationInProgress { canister } => write!( + f, + "ValidationError::MigrationInProgress {{ canister: {canister} }}", + ), + ValidationError::CanisterNotFound { canister } => write!( + f, + "ValidationError::CanisterNotFound {{ canister: {canister} }}", + ), + ValidationError::SameSubnet => write!(f, "SameSubnet"), + ValidationError::CallerNotController { canister } => write!( + f, + "ValidationError::CallerNotController {{ canister: {canister} }}", + ), + ValidationError::NotController { canister } => write!( + f, + "ValidationError::NotController {{ canister: {canister} }}", + ), + ValidationError::SourceNotStopped => write!(f, "SourceNotStopped"), + ValidationError::SourceNotReady => write!(f, "SourceNotReady"), + ValidationError::TargetNotStopped => write!(f, "TargetNotStopped"), + ValidationError::TargetHasSnapshots => write!(f, "TargetHasSnapshots"), + ValidationError::SourceInsufficientCycles => write!(f, "SourceInsufficientCycles"), + ValidationError::CallFailed { reason } => { + write!(f, "ValidationError::CallFailed {{ reason: {reason} }}") + } + } + } +} + +#[derive(Clone, CandidType, Deserialize, Debug)] +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, "MigrationStatus::InProgress {{ status: {status} }}") + } + MigrationStatus::Failed { reason, time } => { + write!( + f, + "MigrationStatus::Failed {{ reason: {reason}, time: {time} }}", + ) + } + MigrationStatus::Succeeded { time } => { + write!(f, "MigrationStatus::Succeeded {{ time: {time} }}") + } + } + } +} + +pub async fn migrate_canister( + agent: &Agent, + from_canister: Principal, + to_canister: Principal, +) -> DfxResult { + let canister = Canister::builder() + .with_agent(agent) + .with_canister_id(NNS_MIGRATION_CANISTER_ID) + .build()?; + + let arg = MigrateCanisterArgs { + source: from_canister, + target: to_canister, + }; + + let _: () = canister + .update(MIGRATE_CANISTER_METHOD) + .with_arg(arg) + .build() + .await?; + + Ok(()) +} + +pub async fn migration_status( + agent: &Agent, + from_canister: Principal, + to_canister: Principal, +) -> DfxResult> { + let canister = Canister::builder() + .with_agent(agent) + .with_canister_id(NNS_MIGRATION_CANISTER_ID) + .build()?; + + let arg = MigrateCanisterArgs { + source: from_canister, + target: to_canister, + }; + + let (result,): (Vec,) = canister + .query(MIGRATION_STATUS_METHOD) + .with_arg(arg) + .build() + .await?; + + Ok(result) +} diff --git a/src/dfx/src/lib/operations/mod.rs b/src/dfx/src/lib/operations/mod.rs index 9c2838deee..d68b65d657 100644 --- a/src/dfx/src/lib/operations/mod.rs +++ b/src/dfx/src/lib/operations/mod.rs @@ -1,4 +1,5 @@ pub mod canister; +pub mod canister_migration; pub mod cmc; pub mod cycles_ledger; pub mod ledger;