diff --git a/CHANGELOG.md b/CHANGELOG.md index 38a96e7a..742ed55d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Unreleased -* feat: `icp canister create --proxy` to create canisters via a proxy canister +* feat: Add `--proxy` to `icp canister` subcommands and `icp deploy` to route management canister calls through a proxy canister # v0.2.2 diff --git a/crates/icp-cli/src/commands/canister/call.rs b/crates/icp-cli/src/commands/canister/call.rs index 8ef699c9..50df8d83 100644 --- a/crates/icp-cli/src/commands/canister/call.rs +++ b/crates/icp-cli/src/commands/canister/call.rs @@ -230,6 +230,7 @@ pub(crate) async fn exec(ctx: &Context, args: &CallArgs) -> Result<(), anyhow::E &method, arg_bytes, args.proxy, + None, args.cycles.get(), ) .await? diff --git a/crates/icp-cli/src/commands/canister/delete.rs b/crates/icp-cli/src/commands/canister/delete.rs index a82baa54..de32f003 100644 --- a/crates/icp-cli/src/commands/canister/delete.rs +++ b/crates/icp-cli/src/commands/canister/delete.rs @@ -1,13 +1,19 @@ +use candid::Principal; use clap::Args; +use ic_management_canister_types::CanisterIdRecord; use icp::context::{CanisterSelection, Context}; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Delete a canister from a network #[derive(Debug, Args)] pub(crate) struct DeleteArgs { #[command(flatten)] pub(crate) cmd_args: args::CanisterCommandArgs, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub(crate) proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow::Error> { @@ -28,11 +34,8 @@ pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow: ) .await?; - // Management Interface - let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); - - // Instruct management canister to delete canister - mgmt.delete_canister(&cid).await?; + proxy_management::delete_canister(&agent, args.proxy, CanisterIdRecord { canister_id: cid }) + .await?; // Remove canister ID from the id store if it was referenced by name if let CanisterSelection::Named(canister_name) = &selections.canister { diff --git a/crates/icp-cli/src/commands/canister/install.rs b/crates/icp-cli/src/commands/canister/install.rs index 7c80dcc4..2b45f713 100644 --- a/crates/icp-cli/src/commands/canister/install.rs +++ b/crates/icp-cli/src/commands/canister/install.rs @@ -1,9 +1,10 @@ use std::io::IsTerminal; use anyhow::{Context as _, anyhow, bail}; +use candid::Principal; use clap::Args; use dialoguer::Confirm; -use ic_utils::interfaces::management_canister::builders::CanisterInstallMode; +use ic_management_canister_types::CanisterInstallMode; use icp::context::{CanisterSelection, Context}; use icp::manifest::InitArgsFormat; use icp::prelude::*; @@ -47,6 +48,10 @@ pub(crate) struct InstallArgs { #[command(flatten)] pub(crate) cmd_args: args::CanisterCommandArgs, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub(crate) proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &InstallArgs) -> Result<(), anyhow::Error> { @@ -121,9 +126,14 @@ pub(crate) async fn exec(ctx: &Context, args: &InstallArgs) -> Result<(), anyhow .transpose()?; let canister_display = args.cmd_args.canister.to_string(); - let (install_mode, status) = - resolve_install_mode_and_status(&agent, &canister_display, &canister_id, &args.mode) - .await?; + let (install_mode, status) = resolve_install_mode_and_status( + &agent, + args.proxy, + &canister_display, + &canister_id, + &args.mode, + ) + .await?; // Candid interface compatibility check for upgrades if !args.yes && matches!(install_mode, CanisterInstallMode::Upgrade(_)) { @@ -156,6 +166,7 @@ pub(crate) async fn exec(ctx: &Context, args: &InstallArgs) -> Result<(), anyhow install_canister( &agent, + args.proxy, &canister_id, &canister_display, &wasm, diff --git a/crates/icp-cli/src/commands/canister/logs.rs b/crates/icp-cli/src/commands/canister/logs.rs index 8af176d9..78c66ac4 100644 --- a/crates/icp-cli/src/commands/canister/logs.rs +++ b/crates/icp-cli/src/commands/canister/logs.rs @@ -1,11 +1,10 @@ use std::io::stdout; use anyhow::{Context as _, anyhow}; +use candid::Principal; use clap::Args; -use ic_utils::interfaces::ManagementCanister; -use ic_utils::interfaces::management_canister::{ - CanisterLogFilter, CanisterLogRecord, FetchCanisterLogsArgs, FetchCanisterLogsResult, -}; +use ic_agent::Agent; +use ic_management_canister_types::{CanisterLogFilter, CanisterLogRecord, FetchCanisterLogsArgs}; use icp::context::Context; use icp::signal::stop_signal; use itertools::Itertools; @@ -13,7 +12,7 @@ use serde::Serialize; use time::{OffsetDateTime, format_description::well_known::Rfc3339}; use tokio::select; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Fetch and display canister logs #[derive(Debug, Args)] @@ -50,6 +49,13 @@ pub(crate) struct LogsArgs { /// Output command results as JSON #[arg(long)] pub(crate) json: bool, + + /// Principal of a proxy canister to route the management canister call through. + /// + /// Hidden until the IC supports fetch_canister_logs in replicated mode. + /// Tracking: https://github.com/dfinity/portal/pull/6106 + #[arg(long, hide = true)] + pub(crate) proxy: Option, } fn parse_timestamp(s: &str) -> Result { @@ -103,14 +109,12 @@ pub(crate) async fn exec(ctx: &Context, args: &LogsArgs) -> Result<(), anyhow::E ) .await?; - let mgmt = ManagementCanister::create(&agent); - if args.follow { // Follow mode: continuously fetch and display new logs - follow_logs(args, &mgmt, &canister_id, args.interval).await + follow_logs(args, &agent, args.proxy, &canister_id, args.interval).await } else { // Single fetch mode: fetch all logs once - fetch_and_display_logs(args, &mgmt, &canister_id, build_filter(args)?).await + fetch_and_display_logs(args, &agent, args.proxy, &canister_id, build_filter(args)?).await } } @@ -161,7 +165,8 @@ fn build_filter(args: &LogsArgs) -> Result, anyhow::Er async fn fetch_and_display_logs( args: &LogsArgs, - mgmt: &ManagementCanister<'_>, + agent: &Agent, + proxy: Option, canister_id: &candid::Principal, filter: Option, ) -> Result<(), anyhow::Error> { @@ -169,8 +174,7 @@ async fn fetch_and_display_logs( canister_id: *canister_id, filter, }; - let (result,): (FetchCanisterLogsResult,) = mgmt - .fetch_canister_logs(&fetch_args) + let result = proxy_management::fetch_canister_logs(agent, proxy, fetch_args) .await .context("Failed to fetch canister logs")?; @@ -207,7 +211,8 @@ const FOLLOW_LOOKBACK_NANOS: u64 = 60 * 60 * 1_000_000_000; // 1 hour async fn follow_logs( args: &LogsArgs, - mgmt: &ManagementCanister<'_>, + agent: &Agent, + proxy: Option, canister_id: &candid::Principal, interval_seconds: u64, ) -> Result<(), anyhow::Error> { @@ -237,8 +242,7 @@ async fn follow_logs( canister_id: *canister_id, filter, }; - let (result,): (FetchCanisterLogsResult,) = mgmt - .fetch_canister_logs(&fetch_args) + let result = proxy_management::fetch_canister_logs(agent, proxy, fetch_args) .await .context("Failed to fetch canister logs")?; @@ -440,6 +444,7 @@ mod tests { since_index, until_index, json: false, + proxy: None, } } diff --git a/crates/icp-cli/src/commands/canister/migrate_id.rs b/crates/icp-cli/src/commands/canister/migrate_id.rs index 777cc5d3..01ab97a7 100644 --- a/crates/icp-cli/src/commands/canister/migrate_id.rs +++ b/crates/icp-cli/src/commands/canister/migrate_id.rs @@ -4,8 +4,9 @@ use std::time::{Duration, Instant}; use anyhow::bail; use clap::Args; use dialoguer::Confirm; -use ic_management_canister_types::CanisterStatusType; -use ic_utils::interfaces::ManagementCanister; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterSettings, CanisterStatusType, UpdateSettingsArgs, +}; use icp::context::Context; use icp_canister_interfaces::nns_migration::{MigrationStatus, NNS_MIGRATION_PRINCIPAL}; use indicatif::{ProgressBar, ProgressStyle}; @@ -17,6 +18,7 @@ use crate::operations::canister_migration::{ get_subnet_for_canister, migrate_canister, migration_status, }; use crate::operations::misc::format_timestamp; +use crate::operations::proxy_management; use icp::context::CanisterSelection; /// Minimum cycles required for migration (10T). @@ -45,6 +47,10 @@ pub(crate) struct MigrateIdArgs { /// Exit as soon as the migrated canister is deleted (don't wait for full completion) #[arg(long)] skip_watch: bool, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyhow::Error> { @@ -105,11 +111,23 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh } } - 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?; + let source_status = proxy_management::canister_status( + &agent, + args.proxy, + CanisterIdRecord { + canister_id: source_cid, + }, + ) + .await?; + let target_status = proxy_management::canister_status( + &agent, + args.proxy, + CanisterIdRecord { + canister_id: target_cid, + }, + ) + .await?; // Check both are stopped ensure_canister_stopped(source_status.status, &source_name)?; @@ -156,7 +174,14 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh } // Check target canister has no snapshots - let (snapshots,) = mgmt.list_canister_snapshots(&target_cid).await?; + let snapshots = proxy_management::list_canister_snapshots( + &agent, + args.proxy, + CanisterIdRecord { + canister_id: target_cid, + }, + ) + .await?; if !snapshots.is_empty() { bail!( "The target canister '{target_name}' ({target_cid}) has {} snapshot(s). \ @@ -188,11 +213,19 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh info!("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?; + proxy_management::update_settings( + &agent, + args.proxy, + UpdateSettingsArgs { + canister_id: source_cid, + settings: CanisterSettings { + controllers: Some(new_controllers), + ..Default::default() + }, + sender_canister_version: None, + }, + ) + .await?; } let target_controllers = target_status.settings.controllers; @@ -200,11 +233,19 @@ pub(crate) async fn exec(ctx: &Context, args: &MigrateIdArgs) -> Result<(), anyh info!("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?; + proxy_management::update_settings( + &agent, + args.proxy, + UpdateSettingsArgs { + canister_id: target_cid, + settings: CanisterSettings { + controllers: Some(new_controllers), + ..Default::default() + }, + sender_canister_version: None, + }, + ) + .await?; } // Initiate migration diff --git a/crates/icp-cli/src/commands/canister/settings/sync.rs b/crates/icp-cli/src/commands/canister/settings/sync.rs index 06ba3f06..7de66c89 100644 --- a/crates/icp-cli/src/commands/canister/settings/sync.rs +++ b/crates/icp-cli/src/commands/canister/settings/sync.rs @@ -1,6 +1,6 @@ use anyhow::bail; +use candid::Principal; use clap::Args; -use ic_utils::interfaces::ManagementCanister; use icp::context::{CanisterSelection, Context}; use crate::commands::args::CanisterCommandArgs; @@ -10,6 +10,10 @@ use crate::commands::args::CanisterCommandArgs; pub(crate) struct SyncArgs { #[command(flatten)] cmd_args: CanisterCommandArgs, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::Error> { @@ -37,8 +41,6 @@ pub(crate) async fn exec(ctx: &Context, args: &SyncArgs) -> Result<(), anyhow::E ) .await?; - let mgmt = ManagementCanister::create(&agent); - - crate::operations::settings::sync_settings(&mgmt, &cid, &canister).await?; + crate::operations::settings::sync_settings(&agent, args.proxy, &cid, &canister).await?; Ok(()) } diff --git a/crates/icp-cli/src/commands/canister/settings/update.rs b/crates/icp-cli/src/commands/canister/settings/update.rs index 4c60f534..46c8a5f5 100644 --- a/crates/icp-cli/src/commands/canister/settings/update.rs +++ b/crates/icp-cli/src/commands/canister/settings/update.rs @@ -1,16 +1,20 @@ use anyhow::bail; +use candid::Nat; use clap::{ArgAction, Args}; use dialoguer::Confirm; use ic_agent::Identity; use ic_agent::export::Principal; -use ic_management_canister_types::{CanisterStatusResult, EnvironmentVariable, LogVisibility}; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterSettings, CanisterStatusResult, EnvironmentVariable, LogVisibility, + UpdateSettingsArgs, +}; use icp::ProjectLoadError; use icp::context::{CanisterSelection, Context}; use icp::parsers::{CyclesAmount, DurationAmount, MemoryAmount}; use std::collections::{HashMap, HashSet}; use tracing::warn; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; #[derive(Clone, Debug, Default, Args)] pub(crate) struct ControllerOpt { @@ -130,6 +134,10 @@ pub(crate) struct UpdateArgs { #[command(flatten)] environment_variables: Option, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow::Error> { @@ -164,12 +172,16 @@ pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow: <_>::default() }; - // Management Interface - let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); - let mut current_status: Option = None; if require_current_settings(args) { - current_status = Some(mgmt.canister_status(&cid).await?.0); + current_status = Some( + proxy_management::canister_status( + &agent, + args.proxy, + CanisterIdRecord { canister_id: cid }, + ) + .await?, + ); } // TODO(VZ): Ask for consent if the freezing threshold is too long or too short. @@ -179,13 +191,25 @@ pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow: if let Some(controllers_opt) = &args.controllers { controllers = get_controllers(controllers_opt, current_status.as_ref()); - // Check if the caller is being removed from controllers + // Check if the effective controller is being removed from the controller list. + // When --proxy is set, the proxy canister is the one making management calls and + // is the effective controller. Without --proxy, it's the caller's identity. + let effective_controller = args.proxy.unwrap_or(caller_principal); if let Some(new_controllers) = &controllers - && !new_controllers.contains(&caller_principal) + && !new_controllers.contains(&effective_controller) && !args.force { - warn!("You are about to remove yourself from the controllers list."); - warn!("This will cause you to lose control of the canister and cannot be undone."); + if args.proxy.is_some() { + warn!( + "You are about to remove the proxy canister ({effective_controller}) from the controllers list." + ); + warn!( + "This will prevent further management calls through this proxy and cannot be undone." + ); + } else { + warn!("You are about to remove yourself from the controllers list."); + warn!("This will cause you to lose control of the canister and cannot be undone."); + } let confirmed = Confirm::new() .with_prompt("Do you want to proceed?") @@ -212,81 +236,77 @@ pub(crate) async fn exec(ctx: &Context, args: &UpdateArgs) -> Result<(), anyhow: get_environment_variables(environment_variables_opt, current_status.as_ref()); } - // Update settings. - let mut update = mgmt.update_settings(&cid); - if let Some(controllers) = controllers { - for controller in controllers { - update = update.with_controller(controller); - } - } - if let Some(compute_allocation) = args.compute_allocation { - if configured_settings.compute_allocation.is_some() { - warn!( - "Compute allocation is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_compute_allocation(compute_allocation); - } - if let Some(memory_allocation) = &args.memory_allocation { - if configured_settings.memory_allocation.is_some() { - warn!( - "Memory allocation is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_memory_allocation(memory_allocation.get()); + // Build settings with warnings for configured values + if args.compute_allocation.is_some() && configured_settings.compute_allocation.is_some() { + warn!( + "Compute allocation is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(freezing_threshold) = &args.freezing_threshold { - if configured_settings.freezing_threshold.is_some() { - warn!( - "Freezing threshold is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_freezing_threshold(freezing_threshold.get()); + if args.memory_allocation.is_some() && configured_settings.memory_allocation.is_some() { + warn!( + "Memory allocation is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(reserved_cycles_limit) = &args.reserved_cycles_limit { - if configured_settings.reserved_cycles_limit.is_some() { - warn!( - "Reserved cycles limit is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_reserved_cycles_limit(reserved_cycles_limit.get()); + if args.freezing_threshold.is_some() && configured_settings.freezing_threshold.is_some() { + warn!( + "Freezing threshold is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(wasm_memory_limit) = &args.wasm_memory_limit { - if configured_settings.wasm_memory_limit.is_some() { - warn!( - "Wasm memory limit is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_wasm_memory_limit(wasm_memory_limit.get()); + if args.reserved_cycles_limit.is_some() && configured_settings.reserved_cycles_limit.is_some() { + warn!( + "Reserved cycles limit is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(wasm_memory_threshold) = &args.wasm_memory_threshold { - if configured_settings.wasm_memory_threshold.is_some() { - warn!( - "Wasm memory threshold is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_wasm_memory_threshold(wasm_memory_threshold.get()); + if args.wasm_memory_limit.is_some() && configured_settings.wasm_memory_limit.is_some() { + warn!( + "Wasm memory limit is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(log_memory_limit) = &args.log_memory_limit { - if configured_settings.log_memory_limit.is_some() { - warn!( - "Log memory limit is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_log_memory_limit(log_memory_limit.get()); + if args.wasm_memory_threshold.is_some() && configured_settings.wasm_memory_threshold.is_some() { + warn!( + "Wasm memory threshold is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(log_visibility) = log_visibility { - if configured_settings.log_visibility.is_some() { - warn!( - "Log visibility is already set in icp.yaml; this new value will be overridden on next settings sync" - ); - } - update = update.with_log_visibility(log_visibility); + if args.log_memory_limit.is_some() && configured_settings.log_memory_limit.is_some() { + warn!( + "Log memory limit is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - if let Some(environment_variables) = environment_variables { - update = update.with_environment_variables(environment_variables); + if log_visibility.is_some() && configured_settings.log_visibility.is_some() { + warn!( + "Log visibility is already set in icp.yaml; this new value will be overridden on next settings sync" + ); } - update.await?; + + let settings = CanisterSettings { + controllers, + compute_allocation: args.compute_allocation.map(|v| Nat::from(v as u64)), + memory_allocation: args.memory_allocation.as_ref().map(|m| Nat::from(m.get())), + freezing_threshold: args.freezing_threshold.as_ref().map(|d| Nat::from(d.get())), + reserved_cycles_limit: args + .reserved_cycles_limit + .as_ref() + .map(|r| Nat::from(r.get())), + wasm_memory_limit: args.wasm_memory_limit.as_ref().map(|m| Nat::from(m.get())), + wasm_memory_threshold: args + .wasm_memory_threshold + .as_ref() + .map(|m| Nat::from(m.get())), + log_memory_limit: args.log_memory_limit.as_ref().map(|m| Nat::from(m.get())), + log_visibility, + environment_variables, + }; + + proxy_management::update_settings( + &agent, + args.proxy, + UpdateSettingsArgs { + canister_id: cid, + settings, + sender_canister_version: None, + }, + ) + .await?; Ok(()) } diff --git a/crates/icp-cli/src/commands/canister/snapshot/create.rs b/crates/icp-cli/src/commands/canister/snapshot/create.rs index b93bbc2d..3ea74fa1 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/create.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/create.rs @@ -2,14 +2,16 @@ use std::io::stdout; use anyhow::bail; use byte_unit::{Byte, UnitType}; +use candid::Principal; use clap::Args; -use ic_management_canister_types::{CanisterStatusType, TakeCanisterSnapshotArgs}; -use ic_utils::interfaces::ManagementCanister; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterStatusType, TakeCanisterSnapshotArgs, +}; use icp::context::Context; use serde::Serialize; use super::SnapshotId; -use crate::{commands::args, operations::misc::format_timestamp}; +use crate::{commands::args, operations::misc::format_timestamp, operations::proxy_management}; /// Create a snapshot of a canister's state #[derive(Debug, Args)] @@ -29,6 +31,10 @@ pub(crate) struct CreateArgs { /// Suppress human-readable output; print only snapshot ID #[arg(long, short)] quiet: bool, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow::Error> { @@ -49,11 +55,14 @@ pub(crate) async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow: ) .await?; - let mgmt = ManagementCanister::create(&agent); - // Check canister status - must be stopped to create a snapshot let name = &args.cmd_args.canister; - let (status,) = mgmt.canister_status(&cid).await?; + let status = proxy_management::canister_status( + &agent, + args.proxy, + CanisterIdRecord { canister_id: cid }, + ) + .await?; match status.status { CanisterStatusType::Running => { bail!( @@ -73,7 +82,7 @@ pub(crate) async fn exec(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow: sender_canister_version: None, }; - let (snapshot,) = mgmt.take_canister_snapshot(&take_args).await?; + let snapshot = proxy_management::take_canister_snapshot(&agent, args.proxy, take_args).await?; if args.json { serde_json::to_writer( stdout(), diff --git a/crates/icp-cli/src/commands/canister/snapshot/delete.rs b/crates/icp-cli/src/commands/canister/snapshot/delete.rs index b11d4f54..e31ce320 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/delete.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/delete.rs @@ -1,11 +1,11 @@ +use candid::Principal; use clap::Args; use ic_management_canister_types::DeleteCanisterSnapshotArgs; -use ic_utils::interfaces::ManagementCanister; use icp::context::Context; use tracing::info; use super::SnapshotId; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Delete a canister snapshot #[derive(Debug, Args)] @@ -15,6 +15,10 @@ pub(crate) struct DeleteArgs { /// The snapshot ID to delete (hex-encoded) snapshot_id: SnapshotId, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow::Error> { @@ -35,14 +39,12 @@ pub(crate) async fn exec(ctx: &Context, args: &DeleteArgs) -> Result<(), anyhow: ) .await?; - let mgmt = ManagementCanister::create(&agent); - let delete_args = DeleteCanisterSnapshotArgs { canister_id: cid, snapshot_id: args.snapshot_id.0.clone(), }; - mgmt.delete_canister_snapshot(&delete_args).await?; + proxy_management::delete_canister_snapshot(&agent, args.proxy, delete_args).await?; let name = &args.cmd_args.canister; info!( diff --git a/crates/icp-cli/src/commands/canister/snapshot/download.rs b/crates/icp-cli/src/commands/canister/snapshot/download.rs index 35b52940..471a3455 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/download.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/download.rs @@ -1,4 +1,5 @@ use byte_unit::{Byte, UnitType}; +use candid::Principal; use clap::Args; use icp::context::Context; use icp::prelude::*; @@ -29,6 +30,10 @@ pub(crate) struct DownloadArgs { /// Resume a previously interrupted download #[arg(long)] resume: bool, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyhow::Error> { @@ -84,7 +89,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyho id = hex::encode(snapshot_id), ); - let metadata = read_snapshot_metadata(&agent, cid, snapshot_id).await?; + let metadata = read_snapshot_metadata(&agent, args.proxy, cid, snapshot_id).await?; info!( " Timestamp: {}", @@ -119,6 +124,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyho let pb = create_transfer_progress_bar(metadata.wasm_module_size, "WASM module"); download_blob_to_file( &agent, + args.proxy, cid, snapshot_id, BlobType::WasmModule, @@ -140,6 +146,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyho let pb = create_transfer_progress_bar(metadata.wasm_memory_size, "WASM memory"); download_blob_to_file( &agent, + args.proxy, cid, snapshot_id, BlobType::WasmMemory, @@ -165,6 +172,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyho create_transfer_progress_bar(metadata.stable_memory_size, "Stable memory"); download_blob_to_file( &agent, + args.proxy, cid, snapshot_id, BlobType::StableMemory, @@ -193,7 +201,15 @@ pub(crate) async fn exec(ctx: &Context, args: &DownloadArgs) -> Result<(), anyho for chunk_hash in &metadata.wasm_chunk_store { let chunk_path = paths.wasm_chunk_path(&chunk_hash.hash); if !chunk_path.exists() { - download_wasm_chunk(&agent, cid, snapshot_id, chunk_hash, paths).await?; + download_wasm_chunk( + &agent, + args.proxy, + cid, + snapshot_id, + chunk_hash, + paths, + ) + .await?; } } info!("WASM chunks: done"); diff --git a/crates/icp-cli/src/commands/canister/snapshot/list.rs b/crates/icp-cli/src/commands/canister/snapshot/list.rs index 4f76ca56..01c1bad6 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/list.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/list.rs @@ -1,13 +1,14 @@ use std::io::stdout; use byte_unit::{Byte, UnitType}; +use candid::Principal; use clap::Args; -use ic_utils::interfaces::ManagementCanister; +use ic_management_canister_types::CanisterIdRecord; use icp::context::Context; use itertools::Itertools; use serde::Serialize; -use crate::{commands::args, operations::misc::format_timestamp}; +use crate::{commands::args, operations::misc::format_timestamp, operations::proxy_management}; /// List all snapshots for a canister #[derive(Debug, Args)] @@ -22,6 +23,10 @@ pub(crate) struct ListArgs { /// Suppress human-readable output; print only snapshot IDs #[arg(long, short)] pub(crate) quiet: bool, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub(crate) proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::Error> { @@ -42,9 +47,12 @@ pub(crate) async fn exec(ctx: &Context, args: &ListArgs) -> Result<(), anyhow::E ) .await?; - let mgmt = ManagementCanister::create(&agent); - - let (snapshots,) = mgmt.list_canister_snapshots(&cid).await?; + let snapshots = proxy_management::list_canister_snapshots( + &agent, + args.proxy, + CanisterIdRecord { canister_id: cid }, + ) + .await?; let name = &args.cmd_args.canister; if args.json { diff --git a/crates/icp-cli/src/commands/canister/snapshot/restore.rs b/crates/icp-cli/src/commands/canister/snapshot/restore.rs index 22fdec44..bf6ebb8c 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/restore.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/restore.rs @@ -1,12 +1,14 @@ use anyhow::bail; +use candid::Principal; use clap::Args; -use ic_management_canister_types::{CanisterStatusType, LoadCanisterSnapshotArgs}; -use ic_utils::interfaces::ManagementCanister; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterStatusType, LoadCanisterSnapshotArgs, +}; use icp::context::Context; use tracing::info; use super::SnapshotId; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Restore a canister from a snapshot #[derive(Debug, Args)] @@ -16,6 +18,10 @@ pub(crate) struct RestoreArgs { /// The snapshot ID to restore (hex-encoded) snapshot_id: SnapshotId, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &RestoreArgs) -> Result<(), anyhow::Error> { @@ -36,11 +42,14 @@ pub(crate) async fn exec(ctx: &Context, args: &RestoreArgs) -> Result<(), anyhow ) .await?; - let mgmt = ManagementCanister::create(&agent); - // Check canister status - must be stopped to restore a snapshot let name = &args.cmd_args.canister; - let (status,) = mgmt.canister_status(&cid).await?; + let status = proxy_management::canister_status( + &agent, + args.proxy, + CanisterIdRecord { canister_id: cid }, + ) + .await?; match status.status { CanisterStatusType::Running => { bail!( @@ -59,7 +68,7 @@ pub(crate) async fn exec(ctx: &Context, args: &RestoreArgs) -> Result<(), anyhow sender_canister_version: None, }; - mgmt.load_canister_snapshot(&load_args).await?; + proxy_management::load_canister_snapshot(&agent, args.proxy, load_args).await?; info!( "Restored canister {name} ({cid}) from snapshot {id}", diff --git a/crates/icp-cli/src/commands/canister/snapshot/upload.rs b/crates/icp-cli/src/commands/canister/snapshot/upload.rs index bb93e263..81bdda76 100644 --- a/crates/icp-cli/src/commands/canister/snapshot/upload.rs +++ b/crates/icp-cli/src/commands/canister/snapshot/upload.rs @@ -1,6 +1,7 @@ use std::io::stdout; use byte_unit::{Byte, UnitType}; +use candid::Principal; use clap::Args; use icp::context::Context; use icp::prelude::*; @@ -41,6 +42,10 @@ pub(crate) struct UploadArgs { /// Suppress human-readable output; print only snapshot ID #[arg(long, short, conflicts_with = "json")] quiet: bool, + + /// Principal of a proxy canister to route the management canister calls through. + #[arg(long)] + proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow::Error> { @@ -103,7 +108,8 @@ pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow: // Upload metadata to create a new snapshot let replace_snapshot = args.replace.as_ref().map(|s| s.0.as_slice()); let result = - upload_snapshot_metadata(&agent, cid, &metadata, replace_snapshot).await?; + upload_snapshot_metadata(&agent, args.proxy, cid, &metadata, replace_snapshot) + .await?; let snapshot_id_hex = hex::encode(&result.snapshot_id); info!("Created snapshot {snapshot_id_hex} for upload"); @@ -123,6 +129,7 @@ pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow: let pb = create_transfer_progress_bar(metadata.wasm_module_size, "WASM module"); upload_blob_from_file( &agent, + args.proxy, cid, &snapshot_id_bytes, BlobType::WasmModule, @@ -143,6 +150,7 @@ pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow: let pb = create_transfer_progress_bar(metadata.wasm_memory_size, "WASM memory"); upload_blob_from_file( &agent, + args.proxy, cid, &snapshot_id_bytes, BlobType::WasmMemory, @@ -164,6 +172,7 @@ pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow: create_transfer_progress_bar(metadata.stable_memory_size, "Stable memory"); upload_blob_from_file( &agent, + args.proxy, cid, &snapshot_id_bytes, BlobType::StableMemory, @@ -188,8 +197,15 @@ pub(crate) async fn exec(ctx: &Context, args: &UploadArgs) -> Result<(), anyhow: for chunk_hash in &metadata.wasm_chunk_store { let hash_hex = hex::encode(&chunk_hash.hash); if !progress.wasm_chunks_uploaded.contains(&hash_hex) { - upload_wasm_chunk(&agent, cid, &snapshot_id_bytes, &chunk_hash.hash, paths) - .await?; + upload_wasm_chunk( + &agent, + args.proxy, + cid, + &snapshot_id_bytes, + &chunk_hash.hash, + paths, + ) + .await?; progress.wasm_chunks_uploaded.insert(hash_hex); save_upload_progress(&progress, paths)?; } diff --git a/crates/icp-cli/src/commands/canister/start.rs b/crates/icp-cli/src/commands/canister/start.rs index 72d44070..4278cc7f 100644 --- a/crates/icp-cli/src/commands/canister/start.rs +++ b/crates/icp-cli/src/commands/canister/start.rs @@ -1,13 +1,19 @@ +use candid::Principal; use clap::Args; +use ic_management_canister_types::CanisterIdRecord; use icp::context::Context; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Start a canister on a network #[derive(Debug, Args)] pub(crate) struct StartArgs { #[command(flatten)] pub(crate) cmd_args: args::CanisterCommandArgs, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub(crate) proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow::Error> { @@ -27,11 +33,8 @@ pub(crate) async fn exec(ctx: &Context, args: &StartArgs) -> Result<(), anyhow:: ) .await?; - // Management Interface - let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); - - // Instruct management canister to start canister - mgmt.start_canister(&cid).await?; + proxy_management::start_canister(&agent, args.proxy, CanisterIdRecord { canister_id: cid }) + .await?; Ok(()) } diff --git a/crates/icp-cli/src/commands/canister/status.rs b/crates/icp-cli/src/commands/canister/status.rs index ca46d4c0..18412e1c 100644 --- a/crates/icp-cli/src/commands/canister/status.rs +++ b/crates/icp-cli/src/commands/canister/status.rs @@ -1,7 +1,9 @@ use anyhow::{anyhow, bail}; use clap::Args; use ic_agent::{Agent, AgentError, export::Principal}; -use ic_management_canister_types::{CanisterStatusResult, EnvironmentVariable, LogVisibility}; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterStatusResult, EnvironmentVariable, LogVisibility, +}; use icp::{ context::{CanisterSelection, Context, EnvironmentSelection, NetworkSelection}, identity::IdentitySelection, @@ -10,7 +12,11 @@ use serde::Serialize; use std::fmt::Write; use tracing::debug; -use crate::{commands::args, options}; +use crate::{ + commands::args, + operations::{proxy::UpdateOrProxyError, proxy_management}, + options, +}; /// Error code returned by the replica if the target canister is not found const E_CANISTER_NOT_FOUND: &str = "IC0301"; @@ -56,6 +62,10 @@ pub(crate) struct StatusArgsOptions { /// looks up public information from the state tree. #[arg(short, long)] pub public: bool, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub proxy: Option, } /// Fetch the list of canister ids from the id_store @@ -203,9 +213,6 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow: ) .await?; - // Management Interface - let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); - for (i, (maybe_name, cid)) in cids.iter().enumerate() { let output = match args.options.public { true => { @@ -222,8 +229,14 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow: } false => { // Retrieve canister status from management canister - match mgmt.canister_status(cid).await { - Ok((result,)) => { + match proxy_management::canister_status( + &agent, + args.options.proxy, + CanisterIdRecord { canister_id: *cid }, + ) + .await + { + Ok(result) => { let status = SerializableCanisterStatusResult::from( cid.to_owned(), maybe_name.clone(), @@ -237,17 +250,18 @@ pub(crate) async fn exec(ctx: &Context, args: &StatusArgs) -> Result<(), anyhow: .expect("Failed to build canister status output"), } } - Err(AgentError::UncertifiedReject { - reject, - operation: _, + Err(UpdateOrProxyError::DirectUpdateCall { + source: + AgentError::UncertifiedReject { + reject, + operation: _, + }, }) => { if reject.error_code.as_deref() == Some(E_CANISTER_NOT_FOUND) { - // The canister does not exist bail!("Canister {cid} was not found."); } if reject.error_code.as_deref() != Some(E_NOT_A_CONTROLLER) { - // We don't know this error code bail!( "Error looking up canister {cid}: {:?} - {}", reject.error_code, diff --git a/crates/icp-cli/src/commands/canister/stop.rs b/crates/icp-cli/src/commands/canister/stop.rs index b8822a99..0a10bb0f 100644 --- a/crates/icp-cli/src/commands/canister/stop.rs +++ b/crates/icp-cli/src/commands/canister/stop.rs @@ -1,13 +1,19 @@ +use candid::Principal; use clap::Args; +use ic_management_canister_types::CanisterIdRecord; use icp::context::Context; -use crate::commands::args; +use crate::{commands::args, operations::proxy_management}; /// Stop a canister on a network #[derive(Debug, Args)] pub(crate) struct StopArgs { #[command(flatten)] pub(crate) cmd_args: args::CanisterCommandArgs, + + /// Principal of a proxy canister to route the management canister call through. + #[arg(long)] + pub(crate) proxy: Option, } pub(crate) async fn exec(ctx: &Context, args: &StopArgs) -> Result<(), anyhow::Error> { @@ -27,11 +33,8 @@ pub(crate) async fn exec(ctx: &Context, args: &StopArgs) -> Result<(), anyhow::E ) .await?; - // Management Interface - let mgmt = ic_utils::interfaces::ManagementCanister::create(&agent); - - // Instruct management canister to stop canister - mgmt.stop_canister(&cid).await?; + proxy_management::stop_canister(&agent, args.proxy, CanisterIdRecord { canister_id: cid }) + .await?; Ok(()) } diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 75275afa..443724b0 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -39,9 +39,13 @@ pub(crate) struct DeployArgs { pub(crate) mode: String, /// The subnet to use for the canisters being deployed. - #[clap(long)] + #[clap(long, conflicts_with = "proxy")] pub(crate) subnet: Option, + /// Principal of a proxy canister to route management canister calls through. + #[arg(long, conflicts_with = "subnet")] + pub(crate) proxy: Option, + /// One or more controllers for the canisters being deployed. Repeat `--controller` to specify multiple. #[arg(long)] pub(crate) controller: Vec, @@ -127,9 +131,10 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: if canisters_to_create.is_empty() { info!("All canisters already exist"); } else { - let target = match args.subnet { - Some(subnet) => CreateTarget::Subnet(subnet), - None => CreateTarget::None, + let target = match (args.subnet, args.proxy) { + (Some(subnet), _) => CreateTarget::Subnet(subnet), + (_, Some(proxy)) => CreateTarget::Proxy(proxy), + _ => CreateTarget::None, }; let create_operation = CreateOperation::new( agent.clone(), @@ -217,6 +222,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: set_binding_env_vars_many( agent.clone(), + args.proxy, &env.name, target_canisters.clone(), canister_list, @@ -225,7 +231,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: .await .map_err(|e| anyhow!(e))?; - sync_settings_many(agent.clone(), target_canisters, ctx.debug) + sync_settings_many(agent.clone(), args.proxy, target_canisters, ctx.debug) .await .map_err(|e| anyhow!(e))?; @@ -244,7 +250,7 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: .map_err(|e| anyhow!(e))?; let (mode, status) = - resolve_install_mode_and_status(&agent, name, &cid, &args.mode).await?; + resolve_install_mode_and_status(&agent, args.proxy, name, &cid, &args.mode).await?; let env = ctx.get_environment(&environment_selection).await?; let (_canister_path, canister_info) = @@ -277,7 +283,14 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: info!("Installing canisters:"); - install_many(agent.clone(), canisters, ctx.artifacts.clone(), ctx.debug).await?; + install_many( + agent.clone(), + args.proxy, + canisters, + ctx.artifacts.clone(), + ctx.debug, + ) + .await?; // Sync the selected canisters @@ -315,6 +328,11 @@ pub(crate) async fn exec(ctx: &Context, args: &DeployArgs) -> Result<(), anyhow: if sync_canisters.is_empty() { info!("No canisters have sync steps configured"); } else { + // TODO: When `--proxy` is used and the canister was newly created, the proxy + // canister is its only controller. Sync steps (e.g. asset uploads to a frontend + // canister) will fail because the user's identity lacks the required permissions. + // The fix is to make a proxy call to the frontend canister's `grant_permission` + // method to permit the user identity to upload assets directly before syncing. info!("Syncing canisters:"); sync_many(ctx.syncer.clone(), agent.clone(), sync_canisters, ctx.debug).await?; diff --git a/crates/icp-cli/src/operations/binding_env_vars.rs b/crates/icp-cli/src/operations/binding_env_vars.rs index 668a17ee..88eff87f 100644 --- a/crates/icp-cli/src/operations/binding_env_vars.rs +++ b/crates/icp-cli/src/operations/binding_env_vars.rs @@ -1,16 +1,17 @@ use std::collections::{BTreeMap, HashSet}; use futures::{StreamExt, stream::FuturesOrdered}; -use ic_agent::{Agent, AgentError, export::Principal}; -use ic_utils::interfaces::{ - ManagementCanister, management_canister::builders::EnvironmentVariable, -}; +use ic_agent::{Agent, export::Principal}; +use ic_management_canister_types::{CanisterSettings, EnvironmentVariable, UpdateSettingsArgs}; use icp::Canister; use snafu::Snafu; use tracing::error; use crate::progress::{ProgressManager, ProgressManagerSettings}; +use super::proxy::UpdateOrProxyError; +use super::proxy_management; + #[derive(Debug, Snafu)] pub enum BindingEnvVarsOperationError { #[snafu(display("Could not find canister id(s) for {} in environment '{environment}'. Make sure they are created first", canister_names.join(", ")))] @@ -19,8 +20,8 @@ pub enum BindingEnvVarsOperationError { canister_names: Vec, }, - #[snafu(display("agent error: {source}"))] - Agent { source: AgentError }, + #[snafu(transparent)] + UpdateOrProxy { source: UpdateOrProxyError }, } #[derive(Debug, Snafu)] @@ -37,7 +38,8 @@ struct BindingEnvVarsFailure { } pub(crate) async fn set_env_vars_for_canister( - mgmt: &ManagementCanister<'_>, + agent: &Agent, + proxy: Option, canister_id: &Principal, canister_info: &Canister, binding_vars: &[(String, String)], @@ -57,10 +59,20 @@ pub(crate) async fn set_env_vars_for_canister( .into_iter() .map(|(name, value)| EnvironmentVariable { name, value }) .collect::>(); - mgmt.update_settings(canister_id) - .with_environment_variables(environment_variables) - .await - .map_err(|source| BindingEnvVarsOperationError::Agent { source })?; + + proxy_management::update_settings( + agent, + proxy, + UpdateSettingsArgs { + canister_id: *canister_id, + settings: CanisterSettings { + environment_variables: Some(environment_variables), + ..Default::default() + }, + sender_canister_version: None, + }, + ) + .await?; Ok(()) } @@ -68,13 +80,12 @@ pub(crate) async fn set_env_vars_for_canister( /// Orchestrates setting environment variables for multiple canisters with progress tracking pub(crate) async fn set_binding_env_vars_many( agent: Agent, + proxy: Option, environment_name: &str, target_canisters: Vec<(Principal, Canister)>, canister_list: BTreeMap, debug: bool, ) -> Result<(), SetBindingEnvVarsManyError> { - let mgmt = ManagementCanister::create(&agent); - // Check that all the canisters in this environment have an id // We need to have all the ids to generate environment variables // for the bindings @@ -118,13 +129,13 @@ pub(crate) async fn set_binding_env_vars_many( let canister_name = info.name.clone(); let settings_fn = { - let mgmt = mgmt.clone(); + let agent = agent.clone(); let pb = pb.clone(); let binding_vars = binding_vars.clone(); async move { pb.set_message("Updating environment variables..."); - set_env_vars_for_canister(&mgmt, &cid, &info, &binding_vars).await + set_env_vars_for_canister(&agent, proxy, &cid, &info, &binding_vars).await } }; diff --git a/crates/icp-cli/src/operations/create.rs b/crates/icp-cli/src/operations/create.rs index 6af1a5e5..e77be871 100644 --- a/crates/icp-cli/src/operations/create.rs +++ b/crates/icp-cli/src/operations/create.rs @@ -19,7 +19,8 @@ use rand::seq::IndexedRandom; use snafu::{OptionExt, ResultExt, Snafu}; use tokio::sync::OnceCell; -use super::proxy::{UpdateOrProxyError, update_or_proxy}; +use super::proxy::UpdateOrProxyError; +use super::proxy_management; #[derive(Debug, Snafu)] pub enum CreateOperationError { @@ -220,13 +221,11 @@ impl CreateOperation { sender_canister_version: None, }; - let (result,): (CanisterIdRecord,) = update_or_proxy( + let result = proxy_management::create_canister( &self.inner.agent, - Principal::management_canister(), - "create_canister", - (args,), Some(proxy), self.inner.cycles, + args, ) .await?; diff --git a/crates/icp-cli/src/operations/install.rs b/crates/icp-cli/src/operations/install.rs index f66d80ee..0564f3ed 100644 --- a/crates/icp-cli/src/operations/install.rs +++ b/crates/icp-cli/src/operations/install.rs @@ -1,10 +1,10 @@ +use candid::Encode; use futures::{StreamExt, stream::FuturesOrdered}; -use ic_agent::{Agent, AgentError, export::Principal}; +use ic_agent::{Agent, export::Principal}; use ic_management_canister_types::{ - CanisterId, CanisterStatusType, ChunkHash, UpgradeFlags, UploadChunkArgs, WasmMemoryPersistence, -}; -use ic_utils::interfaces::{ - ManagementCanister, management_canister::builders::CanisterInstallMode, + CanisterId, CanisterIdRecord, CanisterInstallMode, CanisterStatusType, ChunkHash, + ClearChunkStoreArgs, InstallChunkedCodeArgs, InstallCodeArgs, UpgradeFlags, UploadChunkArgs, + WasmMemoryPersistence, }; use sha2::{Digest, Sha256}; use snafu::{ResultExt, Snafu}; @@ -14,26 +14,28 @@ use tracing::{debug, error, warn}; use crate::progress::{ProgressManager, ProgressManagerSettings}; use super::misc::fetch_canister_metadata; +use super::proxy::UpdateOrProxyError; +use super::proxy_management; #[derive(Debug, Snafu)] pub enum InstallOperationError { #[snafu(display("Could not find build artifact for canister '{canister_name}'"))] ArtifactNotFound { canister_name: String }, - #[snafu(display("agent error: {source}"))] - Agent { source: AgentError }, - #[snafu(display("Failed to stop canister '{canister_name}' before upgrade"))] StopCanister { canister_name: String, - source: AgentError, + source: UpdateOrProxyError, }, #[snafu(display("Failed to start canister '{canister_name}' after upgrade"))] StartCanister { canister_name: String, - source: AgentError, + source: UpdateOrProxyError, }, + + #[snafu(transparent)] + UpdateOrProxy { source: UpdateOrProxyError }, } #[derive(Debug, Snafu)] @@ -54,24 +56,30 @@ struct InstallFailure { /// determine whether the canister already has code installed. pub(crate) async fn resolve_install_mode_and_status( agent: &Agent, + proxy: Option, canister_name: &str, canister_id: &Principal, mode: &str, ) -> Result<(CanisterInstallMode, CanisterStatusType), ResolveInstallModeError> { - let mgmt = ManagementCanister::create(agent); - let (status,) = mgmt - .canister_status(canister_id) - .await - .context(ResolveInstallModeSnafu { canister_name })?; + let status = proxy_management::canister_status( + agent, + proxy, + CanisterIdRecord { + canister_id: CanisterId::from(*canister_id), + }, + ) + .await + .context(ResolveInstallModeSnafu { canister_name })?; + let canister_status = status.status; match mode { "auto" => Ok(if status.module_hash.is_some() { - (CanisterInstallMode::Upgrade(None), status.status) + (CanisterInstallMode::Upgrade(None), canister_status) } else { - (CanisterInstallMode::Install, status.status) + (CanisterInstallMode::Install, canister_status) }), - "install" => Ok((CanisterInstallMode::Install, status.status)), - "reinstall" => Ok((CanisterInstallMode::Reinstall, status.status)), - "upgrade" => Ok((CanisterInstallMode::Upgrade(None), status.status)), + "install" => Ok((CanisterInstallMode::Install, canister_status)), + "reinstall" => Ok((CanisterInstallMode::Reinstall, canister_status)), + "upgrade" => Ok((CanisterInstallMode::Upgrade(None), canister_status)), _ => panic!("invalid install mode: {mode}"), } } @@ -80,11 +88,12 @@ pub(crate) async fn resolve_install_mode_and_status( #[snafu(display("Failed to resolve install mode for canister {canister_name}"))] pub(crate) struct ResolveInstallModeError { canister_name: String, - source: AgentError, + source: UpdateOrProxyError, } pub(crate) async fn install_canister( agent: &Agent, + proxy: Option, canister_id: &Principal, canister_name: &str, wasm: &[u8], @@ -118,6 +127,7 @@ pub(crate) async fn install_canister( do_install_operation( agent, + proxy, canister_id, canister_name, wasm, @@ -130,6 +140,7 @@ pub(crate) async fn install_canister( async fn do_install_operation( agent: &Agent, + proxy: Option, canister_id: &Principal, canister_name: &str, wasm: &[u8], @@ -137,8 +148,6 @@ async fn do_install_operation( status: CanisterStatusType, init_args: Option<&[u8]>, ) -> Result<(), InstallOperationError> { - let mgmt = ManagementCanister::create(agent); - // Threshold for chunked installation: 2 MB // Raw install_code messages are limited to 2 MiB const CHUNK_THRESHOLD: usize = 2 * 1024 * 1024; @@ -149,34 +158,46 @@ async fn do_install_operation( // Generous overhead for encoding, target canister ID, install mode, etc. const ENCODING_OVERHEAD: usize = 500; + let cid = CanisterId::from(*canister_id); + let arg = init_args + .map(|a| a.to_vec()) + .unwrap_or_else(|| Encode!().unwrap()); + // Calculate total install message size - let init_args_len = init_args.map_or(0, |args| args.len()); - let total_install_size = wasm.len() + init_args_len + ENCODING_OVERHEAD; + let total_install_size = wasm.len() + arg.len() + ENCODING_OVERHEAD; if total_install_size <= CHUNK_THRESHOLD { // Small wasm: use regular install_code debug!("Installing wasm for {canister_name} using install_code"); - let mut builder = mgmt.install_code(canister_id, wasm).with_mode(mode); - - if let Some(args) = init_args { - builder = builder.with_raw_arg(args.into()); - } + let install_args = InstallCodeArgs { + mode, + canister_id: cid, + wasm_module: wasm.to_vec(), + arg, + sender_canister_version: None, + }; - stop_and_start_if_upgrade(&mgmt, canister_id, canister_name, mode, status, async { - builder - .await - .map_err(|source| InstallOperationError::Agent { source }) - }) + stop_and_start_if_upgrade( + agent, + proxy, + canister_id, + canister_name, + mode, + status, + async { + proxy_management::install_code(agent, proxy, install_args).await?; + Ok(()) + }, + ) .await?; } else { // Large wasm: use chunked installation debug!("Installing wasm for {canister_name} using chunked installation"); // Clear any existing chunks to ensure a clean state - mgmt.clear_chunk_store(canister_id) - .await - .map_err(|source| InstallOperationError::Agent { source })?; + proxy_management::clear_chunk_store(agent, proxy, ClearChunkStoreArgs { canister_id: cid }) + .await?; // Split wasm into chunks and upload them let chunks: Vec<&[u8]> = wasm.chunks(CHUNK_SIZE).collect(); @@ -191,14 +212,11 @@ async fn do_install_operation( ); let upload_args = UploadChunkArgs { - canister_id: CanisterId::from(*canister_id), + canister_id: cid, chunk: chunk.to_vec(), }; - let (chunk_hash,) = mgmt - .upload_chunk(canister_id, &upload_args) - .await - .map_err(|source| InstallOperationError::Agent { source })?; + let chunk_hash = proxy_management::upload_chunk(agent, proxy, upload_args).await?; chunk_hashes.push(chunk_hash); } @@ -210,29 +228,38 @@ async fn do_install_operation( debug!("Installing chunked code with {} chunks", chunk_hashes.len()); - // Build and execute install_chunked_code - let mut builder = mgmt - .install_chunked_code(canister_id, &wasm_module_hash) - .with_chunk_hashes(chunk_hashes) - .with_install_mode(mode); - - if let Some(args) = init_args { - builder = builder.with_raw_arg(args.to_vec()); - } + let chunked_args = InstallChunkedCodeArgs { + mode, + target_canister: cid, + store_canister: None, + chunk_hashes_list: chunk_hashes, + wasm_module_hash, + arg, + sender_canister_version: None, + }; - let install_res = - stop_and_start_if_upgrade(&mgmt, canister_id, canister_name, mode, status, async { - builder - .await - .map_err(|source| InstallOperationError::Agent { source }) - }) - .await; + let install_res = stop_and_start_if_upgrade( + agent, + proxy, + canister_id, + canister_name, + mode, + status, + async { + proxy_management::install_chunked_code(agent, proxy, chunked_args).await?; + Ok(()) + }, + ) + .await; // Clear chunk store after successful installation to free up storage - let clear_res = mgmt - .clear_chunk_store(canister_id) - .await - .map_err(|source| InstallOperationError::Agent { source }); + let clear_res = proxy_management::clear_chunk_store( + agent, + proxy, + ClearChunkStoreArgs { canister_id: cid }, + ) + .await + .map_err(InstallOperationError::from); if let Err(clear_error) = clear_res { if let Err(install_error) = install_res { @@ -249,7 +276,8 @@ async fn do_install_operation( } async fn stop_and_start_if_upgrade( - mgmt: &ManagementCanister<'_>, + agent: &Agent, + proxy: Option, canister_id: &Principal, canister_name: &str, mode: CanisterInstallMode, @@ -260,9 +288,12 @@ async fn stop_and_start_if_upgrade( mode, CanisterInstallMode::Upgrade(_) | CanisterInstallMode::Reinstall ) && matches!(status, CanisterStatusType::Running); + let cid_record = CanisterIdRecord { + canister_id: CanisterId::from(*canister_id), + }; // Stop the canister before proceeding if should_guard { - mgmt.stop_canister(canister_id) + proxy_management::stop_canister(agent, proxy, cid_record.clone()) .await .context(StopCanisterSnafu { canister_name })?; } @@ -270,7 +301,7 @@ async fn stop_and_start_if_upgrade( let install_result = f.await; // Restart the canister whether or not the installation succeeded if should_guard { - let start_result = mgmt.start_canister(canister_id).await; + let start_result = proxy_management::start_canister(agent, proxy, cid_record).await; if let Err(start_error) = start_result { // If both install and start failed, report the install error since it's more likely to be the root cause if let Err(install_error) = install_result { @@ -288,6 +319,7 @@ async fn stop_and_start_if_upgrade( /// Installs code to multiple canisters and displays progress bars. pub(crate) async fn install_many( agent: Agent, + proxy: Option, canisters: impl IntoIterator< Item = ( String, @@ -322,6 +354,7 @@ pub(crate) async fn install_many( install_canister( &agent, + proxy, &cid, &name, &wasm, diff --git a/crates/icp-cli/src/operations/mod.rs b/crates/icp-cli/src/operations/mod.rs index 643bdeb2..f1d8ba2b 100644 --- a/crates/icp-cli/src/operations/mod.rs +++ b/crates/icp-cli/src/operations/mod.rs @@ -5,6 +5,7 @@ pub(crate) mod canister_migration; pub(crate) mod create; pub(crate) mod install; pub(crate) mod proxy; +pub(crate) mod proxy_management; pub(crate) mod settings; pub(crate) mod snapshot_transfer; pub(crate) mod sync; diff --git a/crates/icp-cli/src/operations/proxy.rs b/crates/icp-cli/src/operations/proxy.rs index 51e0b8b9..a50f4947 100644 --- a/crates/icp-cli/src/operations/proxy.rs +++ b/crates/icp-cli/src/operations/proxy.rs @@ -33,6 +33,12 @@ pub enum UpdateOrProxyError { /// If `proxy` is `None`, makes a direct update call to the target canister. /// If `proxy` is `Some`, wraps the call in [`ProxyArgs`] and sends it to the /// proxy canister's `proxy` method, which forwards it to the target. +/// +/// `effective_canister_id` overrides the effective canister ID used for HTTP +/// routing in direct calls. This is required when calling the management +/// canister, where the effective canister ID must be the target canister +/// rather than `aaaaa-aa`. When `None`, defaults to `canister_id`. +/// /// The `cycles` parameter is only used for proxied calls. pub async fn update_or_proxy_raw( agent: &Agent, @@ -40,6 +46,7 @@ pub async fn update_or_proxy_raw( method: &str, arg: Vec, proxy: Option, + effective_canister_id: Option, cycles: u128, ) -> Result, UpdateOrProxyError> { if let Some(proxy_cid) = proxy { @@ -68,11 +75,11 @@ pub async fn update_or_proxy_raw( .fail(), } } else { - let res = agent - .update(&canister_id, method) - .with_arg(arg) - .await - .context(DirectUpdateCallSnafu)?; + let mut builder = agent.update(&canister_id, method).with_arg(arg); + if let Some(eid) = effective_canister_id { + builder = builder.with_effective_canister_id(eid); + } + let res = builder.await.context(DirectUpdateCallSnafu)?; Ok(res) } } @@ -84,6 +91,7 @@ pub async fn update_or_proxy( method: &str, args: A, proxy: Option, + effective_canister_id: Option, cycles: u128, ) -> Result where @@ -91,7 +99,16 @@ where R: for<'a> ArgumentDecoder<'a>, { let arg = candid::encode_args(args).context(CandidEncodeSnafu)?; - let res = update_or_proxy_raw(agent, canister_id, method, arg, proxy, cycles).await?; + let res = update_or_proxy_raw( + agent, + canister_id, + method, + arg, + proxy, + effective_canister_id, + cycles, + ) + .await?; let decoded: R = candid::decode_args(&res).context(CandidDecodeSnafu)?; Ok(decoded) } diff --git a/crates/icp-cli/src/operations/proxy_management.rs b/crates/icp-cli/src/operations/proxy_management.rs new file mode 100644 index 00000000..ffc7cac7 --- /dev/null +++ b/crates/icp-cli/src/operations/proxy_management.rs @@ -0,0 +1,402 @@ +use candid::Principal; +use ic_agent::Agent; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterStatusResult, ClearChunkStoreArgs, CreateCanisterArgs, + DeleteCanisterArgs, DeleteCanisterSnapshotArgs, FetchCanisterLogsArgs, FetchCanisterLogsResult, + InstallChunkedCodeArgs, InstallCodeArgs, ListCanisterSnapshotsArgs, + ListCanisterSnapshotsResult, LoadCanisterSnapshotArgs, ReadCanisterSnapshotDataArgs, + ReadCanisterSnapshotDataResult, ReadCanisterSnapshotMetadataArgs, + ReadCanisterSnapshotMetadataResult, StartCanisterArgs, StopCanisterArgs, + TakeCanisterSnapshotArgs, TakeCanisterSnapshotResult, UpdateSettingsArgs, + UploadCanisterSnapshotDataArgs, UploadCanisterSnapshotMetadataArgs, + UploadCanisterSnapshotMetadataResult, UploadChunkArgs, UploadChunkResult, +}; + +use snafu::{ResultExt, Snafu}; + +use super::proxy::{UpdateOrProxyError, update_or_proxy}; + +pub async fn create_canister( + agent: &Agent, + proxy: Option, + cycles: u128, + args: CreateCanisterArgs, +) -> Result { + let (result,): (CanisterIdRecord,) = update_or_proxy( + agent, + Principal::management_canister(), + "create_canister", + (args,), + proxy, + None, + cycles, + ) + .await?; + Ok(result) +} + +pub async fn canister_status( + agent: &Agent, + proxy: Option, + args: CanisterIdRecord, +) -> Result { + let effective = args.canister_id; + let (result,): (CanisterStatusResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "canister_status", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn stop_canister( + agent: &Agent, + proxy: Option, + args: StopCanisterArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "stop_canister", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn start_canister( + agent: &Agent, + proxy: Option, + args: StartCanisterArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "start_canister", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn delete_canister( + agent: &Agent, + proxy: Option, + args: DeleteCanisterArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "delete_canister", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn update_settings( + agent: &Agent, + proxy: Option, + args: UpdateSettingsArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "update_settings", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn install_code( + agent: &Agent, + proxy: Option, + args: InstallCodeArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "install_code", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn install_chunked_code( + agent: &Agent, + proxy: Option, + args: InstallChunkedCodeArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.target_canister; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "install_chunked_code", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn upload_chunk( + agent: &Agent, + proxy: Option, + args: UploadChunkArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (UploadChunkResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "upload_chunk", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn clear_chunk_store( + agent: &Agent, + proxy: Option, + args: ClearChunkStoreArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "clear_chunk_store", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +#[derive(Debug, Snafu)] +pub enum FetchCanisterLogsError { + #[snafu(display("failed to encode call arguments: {source}"))] + CandidEncode { source: candid::Error }, + + #[snafu(display("failed to decode call response: {source}"))] + CandidDecode { source: candid::Error }, + + #[snafu(display("direct query call failed: {source}"))] + DirectQueryCall { source: ic_agent::AgentError }, + + #[snafu(display("proxied call failed: {source}"))] + ProxiedCall { source: UpdateOrProxyError }, +} + +/// Fetches canister logs from the management canister. +/// +/// Unlike other management canister methods, `fetch_canister_logs` is a +/// **query** call when made directly. When a proxy is provided, the call is +/// routed through the proxy canister as an update call instead. +pub async fn fetch_canister_logs( + agent: &Agent, + proxy: Option, + args: FetchCanisterLogsArgs, +) -> Result { + let effective = args.canister_id; + if proxy.is_some() { + let (result,): (FetchCanisterLogsResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "fetch_canister_logs", + (args,), + proxy, + Some(effective), + 0, + ) + .await + .context(ProxiedCallSnafu)?; + Ok(result) + } else { + let arg = candid::encode_args((args,)).context(CandidEncodeSnafu)?; + let res = agent + .query(&Principal::management_canister(), "fetch_canister_logs") + .with_arg(arg) + .with_effective_canister_id(effective) + .await + .context(DirectQueryCallSnafu)?; + let (result,): (FetchCanisterLogsResult,) = + candid::decode_args(&res).context(CandidDecodeSnafu)?; + Ok(result) + } +} + +pub async fn take_canister_snapshot( + agent: &Agent, + proxy: Option, + args: TakeCanisterSnapshotArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (TakeCanisterSnapshotResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "take_canister_snapshot", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn load_canister_snapshot( + agent: &Agent, + proxy: Option, + args: LoadCanisterSnapshotArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "load_canister_snapshot", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn list_canister_snapshots( + agent: &Agent, + proxy: Option, + args: ListCanisterSnapshotsArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (ListCanisterSnapshotsResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "list_canister_snapshots", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn delete_canister_snapshot( + agent: &Agent, + proxy: Option, + args: DeleteCanisterSnapshotArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "delete_canister_snapshot", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} + +pub async fn read_canister_snapshot_metadata( + agent: &Agent, + proxy: Option, + args: ReadCanisterSnapshotMetadataArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (ReadCanisterSnapshotMetadataResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "read_canister_snapshot_metadata", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn upload_canister_snapshot_metadata( + agent: &Agent, + proxy: Option, + args: UploadCanisterSnapshotMetadataArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (UploadCanisterSnapshotMetadataResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "upload_canister_snapshot_metadata", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn read_canister_snapshot_data( + agent: &Agent, + proxy: Option, + args: ReadCanisterSnapshotDataArgs, +) -> Result { + let effective = args.canister_id; + let (result,): (ReadCanisterSnapshotDataResult,) = update_or_proxy( + agent, + Principal::management_canister(), + "read_canister_snapshot_data", + (args,), + proxy, + Some(effective), + 0, + ) + .await?; + Ok(result) +} + +pub async fn upload_canister_snapshot_data( + agent: &Agent, + proxy: Option, + args: UploadCanisterSnapshotDataArgs, +) -> Result<(), UpdateOrProxyError> { + let effective = args.canister_id; + update_or_proxy::<_, ()>( + agent, + Principal::management_canister(), + "upload_canister_snapshot_data", + (args,), + proxy, + Some(effective), + 0, + ) + .await +} diff --git a/crates/icp-cli/src/operations/settings.rs b/crates/icp-cli/src/operations/settings.rs index 1e7b1bc5..7de4999c 100644 --- a/crates/icp-cli/src/operations/settings.rs +++ b/crates/icp-cli/src/operations/settings.rs @@ -1,10 +1,11 @@ use std::collections::{HashMap, HashSet}; -use candid::Principal; +use candid::{Nat, Principal}; use futures::{StreamExt, stream::FuturesOrdered}; -use ic_agent::{Agent, AgentError}; -use ic_management_canister_types::{EnvironmentVariable, LogVisibility}; -use ic_utils::interfaces::ManagementCanister; +use ic_agent::Agent; +use ic_management_canister_types::{ + CanisterIdRecord, CanisterSettings, EnvironmentVariable, LogVisibility, UpdateSettingsArgs, +}; use icp::{Canister, canister::Settings}; use itertools::Itertools; use num_traits::ToPrimitive; @@ -13,19 +14,20 @@ use tracing::error; use crate::progress::{ProgressManager, ProgressManagerSettings}; +use super::proxy::UpdateOrProxyError; +use super::proxy_management; + #[derive(Debug, Snafu)] #[allow(clippy::enum_variant_names)] pub(crate) enum SyncSettingsOperationError { #[snafu(display("failed to fetch current canister settings for canister {canister}"))] FetchCurrentSettings { - source: AgentError, + source: UpdateOrProxyError, canister: Principal, }, - #[snafu(display("invalid canister settings in manifest for canister {name}"))] - ValidateSettings { source: AgentError, name: String }, #[snafu(display("failed to update canister settings for canister {canister}"))] UpdateSettings { - source: AgentError, + source: UpdateOrProxyError, canister: Principal, }, } @@ -67,14 +69,15 @@ fn environment_variables_eq(a: &[EnvironmentVariable], b: &[EnvironmentVariable] } pub(crate) async fn sync_settings( - mgmt: &ManagementCanister<'_>, + agent: &Agent, + proxy: Option, cid: &Principal, canister: &Canister, ) -> Result<(), SyncSettingsOperationError> { - let (status,) = mgmt - .canister_status(cid) - .await - .context(FetchCurrentSettingsSnafu { canister: *cid })?; + let status = + proxy_management::canister_status(agent, proxy, CanisterIdRecord { canister_id: *cid }) + .await + .context(FetchCurrentSettingsSnafu { canister: *cid })?; let &Settings { ref log_visibility, compute_allocation, @@ -144,52 +147,41 @@ pub(crate) async fn sync_settings( // No changes needed return Ok(()); } - let mut builder = mgmt.update_settings(cid); - if let Some(v) = log_visibility_setting { - builder = builder.with_log_visibility(v); - } - if let Some(v) = compute_allocation { - builder = builder.with_compute_allocation(v); - } - if let Some(v) = memory_allocation.as_ref().map(|m| m.get()) { - builder = builder.with_memory_allocation(v); - } - if let Some(v) = freezing_threshold.as_ref().map(|d| d.get()) { - builder = builder.with_freezing_threshold(v); - } - if let Some(v) = reserved_cycles_limit.as_ref().map(|r| r.get()) { - builder = builder.with_reserved_cycles_limit(v); - } - if let Some(v) = wasm_memory_limit.as_ref().map(|m| m.get()) { - builder = builder.with_wasm_memory_limit(v); - } - if let Some(v) = wasm_memory_threshold.as_ref().map(|m| m.get()) { - builder = builder.with_wasm_memory_threshold(v); - } - if let Some(v) = log_memory_limit.as_ref().map(|m| m.get()) { - builder = builder.with_log_memory_limit(v); - } - if let Some(v) = environment_variable_setting { - builder = builder.with_environment_variables(v); - } - builder - .build() - .context(ValidateSettingsSnafu { - name: &canister.name, - })? - .await - .context(UpdateSettingsSnafu { canister: *cid })?; + + let settings = CanisterSettings { + log_visibility: log_visibility_setting, + compute_allocation: compute_allocation.map(Nat::from), + memory_allocation: memory_allocation.as_ref().map(|m| Nat::from(m.get())), + freezing_threshold: freezing_threshold.as_ref().map(|d| Nat::from(d.get())), + reserved_cycles_limit: reserved_cycles_limit.as_ref().map(|r| Nat::from(r.get())), + wasm_memory_limit: wasm_memory_limit.as_ref().map(|m| Nat::from(m.get())), + wasm_memory_threshold: wasm_memory_threshold.as_ref().map(|m| Nat::from(m.get())), + log_memory_limit: log_memory_limit.as_ref().map(|m| Nat::from(m.get())), + environment_variables: environment_variable_setting, + ..Default::default() + }; + + proxy_management::update_settings( + agent, + proxy, + UpdateSettingsArgs { + canister_id: *cid, + settings, + sender_canister_version: None, + }, + ) + .await + .context(UpdateSettingsSnafu { canister: *cid })?; Ok(()) } pub(crate) async fn sync_settings_many( agent: Agent, + proxy: Option, target_canisters: Vec<(Principal, Canister)>, debug: bool, ) -> Result<(), SyncSettingsManyError> { - let mgmt = ManagementCanister::create(&agent); - let mut futs = FuturesOrdered::new(); let progress_manager = ProgressManager::new(ProgressManagerSettings { hidden: debug }); @@ -198,12 +190,12 @@ pub(crate) async fn sync_settings_many( let canister_name = info.name.clone(); let settings_fn = { - let mgmt = mgmt.clone(); + let agent = agent.clone(); let pb = pb.clone(); async move { pb.set_message("Updating canister settings..."); - sync_settings(&mgmt, &cid, &info).await + sync_settings(&agent, proxy, &cid, &info).await } }; diff --git a/crates/icp-cli/src/operations/snapshot_transfer.rs b/crates/icp-cli/src/operations/snapshot_transfer.rs index ddaf9c4a..24a6388d 100644 --- a/crates/icp-cli/src/operations/snapshot_transfer.rs +++ b/crates/icp-cli/src/operations/snapshot_transfer.rs @@ -12,7 +12,9 @@ use ic_management_canister_types::{ UploadCanisterSnapshotDataArgs, UploadCanisterSnapshotMetadataArgs, UploadCanisterSnapshotMetadataResult, }; -use ic_utils::interfaces::ManagementCanister; + +use super::proxy::UpdateOrProxyError; +use super::proxy_management; use icp::{ fs::lock::{DirectoryStructureLock, LWrite, LockError, PathsAccess}, prelude::*, @@ -105,43 +107,43 @@ pub enum SnapshotTransferError { #[snafu(display("Failed to read snapshot metadata for canister {canister_id}"))] ReadMetadata { canister_id: Principal, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(display("Failed to read snapshot data chunk at offset {offset}"))] ReadDataChunk { offset: u64, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(display("Failed to read WASM chunk with hash {hash}"))] ReadWasmChunk { hash: String, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(display("Failed to upload snapshot metadata for canister {canister_id}"))] UploadMetadata { canister_id: Principal, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(display("Failed to upload snapshot data chunk at offset {offset}"))] UploadDataChunk { offset: u64, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(display("Failed to upload WASM chunk with hash {hash}"))] UploadWasmChunk { hash: String, - #[snafu(source(from(AgentError, Box::new)))] - source: Box, + #[snafu(source(from(UpdateOrProxyError, Box::new)))] + source: Box, }, #[snafu(transparent)] @@ -379,18 +381,27 @@ impl BlobType { } /// Check if an agent error is retryable. -fn is_retryable(error: &AgentError) -> bool { +fn is_retryable_agent_error(error: &AgentError) -> bool { matches!( error, AgentError::TimeoutWaitingForResponse() | AgentError::TransportError(_) ) } +/// Check if an `UpdateOrProxyError` is retryable (by inspecting the inner agent error). +fn is_retryable(error: &UpdateOrProxyError) -> bool { + match error { + UpdateOrProxyError::DirectUpdateCall { source } + | UpdateOrProxyError::ProxyUpdateCall { source } => is_retryable_agent_error(source), + _ => false, + } +} + /// Execute an async operation with exponential backoff retry. -async fn with_retry(operation: F) -> Result +async fn with_retry(operation: F) -> Result where F: Fn() -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let mut backoff = ExponentialBackoff { max_elapsed_time: Some(std::time::Duration::from_secs(60)), @@ -429,19 +440,21 @@ pub fn create_transfer_progress_bar(total_bytes: u64, label: &str) -> ProgressBa /// Read snapshot metadata from a canister. pub async fn read_snapshot_metadata( agent: &Agent, + proxy: Option, canister_id: Principal, snapshot_id: &[u8], ) -> Result { - let mgmt = ManagementCanister::create(agent); - let args = ReadCanisterSnapshotMetadataArgs { canister_id, snapshot_id: snapshot_id.to_vec(), }; - let (metadata,) = with_retry(|| async { mgmt.read_canister_snapshot_metadata(&args).await }) - .await - .context(ReadMetadataSnafu { canister_id })?; + let metadata = with_retry(|| { + let args = args.clone(); + async move { proxy_management::read_canister_snapshot_metadata(agent, proxy, args).await } + }) + .await + .context(ReadMetadataSnafu { canister_id })?; Ok(metadata) } @@ -449,12 +462,11 @@ pub async fn read_snapshot_metadata( /// Upload snapshot metadata to create a new snapshot. pub async fn upload_snapshot_metadata( agent: &Agent, + proxy: Option, canister_id: Principal, metadata: &ReadCanisterSnapshotMetadataResult, replace_snapshot: Option<&[u8]>, ) -> Result { - let mgmt = ManagementCanister::create(agent); - // Convert Option to SnapshotMetadataGlobal, failing on None let globals = metadata .globals @@ -477,9 +489,12 @@ pub async fn upload_snapshot_metadata( on_low_wasm_memory_hook_status: metadata.on_low_wasm_memory_hook_status.clone(), }; - let (result,) = with_retry(|| async { mgmt.upload_canister_snapshot_metadata(&args).await }) - .await - .context(UploadMetadataSnafu { canister_id })?; + let result = with_retry(|| { + let args = args.clone(); + async move { proxy_management::upload_canister_snapshot_metadata(agent, proxy, args).await } + }) + .await + .context(UploadMetadataSnafu { canister_id })?; Ok(result) } @@ -491,6 +506,7 @@ pub async fn upload_snapshot_metadata( /// The agent handles rate limiting and semaphoring internally. pub async fn download_blob_to_file( agent: &Agent, + proxy: Option, canister_id: Principal, snapshot_id: &[u8], blob_type: BlobType, @@ -511,8 +527,6 @@ pub async fn download_blob_to_file( return Ok(()); } - let mgmt = ManagementCanister::create(agent); - // Create or open file for random-access writing let file = if output_path.exists() { File::options() @@ -551,14 +565,18 @@ pub async fn download_blob_to_file( kind: blob_type.make_read_kind(chunk_offset, chunk_size), }; - let mgmt = mgmt.clone(); in_progress.push(async move { - let result = with_retry(|| async { mgmt.read_canister_snapshot_data(&args).await }) - .await - .context(ReadDataChunkSnafu { - offset: chunk_offset, - })?; - Ok::<_, SnapshotTransferError>((chunk_offset, result.0.chunk)) + let result = with_retry(|| { + let args = args.clone(); + async move { + proxy_management::read_canister_snapshot_data(agent, proxy, args).await + } + }) + .await + .context(ReadDataChunkSnafu { + offset: chunk_offset, + })?; + Ok::<_, SnapshotTransferError>((chunk_offset, result.chunk)) }); } @@ -603,13 +621,12 @@ pub async fn download_blob_to_file( /// Download a single WASM chunk by hash. pub async fn download_wasm_chunk( agent: &Agent, + proxy: Option, canister_id: Principal, snapshot_id: &[u8], chunk_hash: &ChunkHash, paths: LWrite<&SnapshotPaths>, ) -> Result<(), SnapshotTransferError> { - let mgmt = ManagementCanister::create(agent); - let args = ReadCanisterSnapshotDataArgs { canister_id, snapshot_id: snapshot_id.to_vec(), @@ -621,9 +638,12 @@ pub async fn download_wasm_chunk( let hash_hex = hex::encode(&chunk_hash.hash); let output_path = paths.wasm_chunk_path(&chunk_hash.hash); - let (result,) = with_retry(|| async { mgmt.read_canister_snapshot_data(&args).await }) - .await - .context(ReadWasmChunkSnafu { hash: &hash_hex })?; + let result = with_retry(|| { + let args = args.clone(); + async move { proxy_management::read_canister_snapshot_data(agent, proxy, args).await } + }) + .await + .context(ReadWasmChunkSnafu { hash: &hash_hex })?; icp::fs::write(&output_path, &result.chunk)?; @@ -637,6 +657,7 @@ pub async fn download_wasm_chunk( /// Returns the final byte offset after all uploads complete. pub async fn upload_blob_from_file( agent: &Agent, + proxy: Option, canister_id: Principal, snapshot_id: &[u8], blob_type: BlobType, @@ -659,8 +680,6 @@ pub async fn upload_blob_from_file( BlobType::StableMemory => progress.stable_memory_offset, }; - let mgmt = ManagementCanister::create(agent); - let mut file = File::open(&input_path) .await .context(OpenBlobForUploadSnafu { path: &input_path })?; @@ -695,12 +714,17 @@ pub async fn upload_blob_from_file( chunk, }; - let mgmt = mgmt.clone(); + let chunk_len = args.chunk.len() as u64; in_progress.push(async move { - with_retry(|| async { mgmt.upload_canister_snapshot_data(&args).await }) - .await - .context(UploadDataChunkSnafu { offset })?; - Ok::<_, SnapshotTransferError>((offset, args.chunk.len() as u64)) + with_retry(|| { + let args = args.clone(); + async move { + proxy_management::upload_canister_snapshot_data(agent, proxy, args).await + } + }) + .await + .context(UploadDataChunkSnafu { offset })?; + Ok::<_, SnapshotTransferError>((offset, chunk_len)) }); } @@ -751,13 +775,12 @@ pub async fn upload_blob_from_file( /// Upload a single WASM chunk. pub async fn upload_wasm_chunk( agent: &Agent, + proxy: Option, canister_id: Principal, snapshot_id: &[u8], chunk_hash: &[u8], paths: LWrite<&SnapshotPaths>, ) -> Result<(), SnapshotTransferError> { - let mgmt = ManagementCanister::create(agent); - let chunk_path = paths.wasm_chunk_path(chunk_hash); let chunk = icp::fs::read(&chunk_path)?; @@ -770,9 +793,12 @@ pub async fn upload_wasm_chunk( let hash_hex = hex::encode(chunk_hash); - with_retry(|| async { mgmt.upload_canister_snapshot_data(&args).await }) - .await - .context(UploadWasmChunkSnafu { hash: hash_hex })?; + with_retry(|| { + let args = args.clone(); + async move { proxy_management::upload_canister_snapshot_data(agent, proxy, args).await } + }) + .await + .context(UploadWasmChunkSnafu { hash: hash_hex })?; Ok(()) } diff --git a/crates/icp-cli/tests/canister_delete_tests.rs b/crates/icp-cli/tests/canister_delete_tests.rs index c6c79133..6a6f9da2 100644 --- a/crates/icp-cli/tests/canister_delete_tests.rs +++ b/crates/icp-cli/tests/canister_delete_tests.rs @@ -116,3 +116,95 @@ async fn canister_delete() { .failure() .stderr(contains("could not find ID for canister")); } + +#[tokio::test] +async fn canister_delete_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Verify canister ID exists in id store + let id_mapping_path = project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"); + let id_mapping_before = + std::fs::read_to_string(&id_mapping_path).expect("ID mapping file should exist"); + assert!( + id_mapping_before.contains("my-canister"), + "ID mapping should contain my-canister before deletion" + ); + + // Stop canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Delete canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "delete", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify canister ID is removed from the id store + let id_mapping_after = + std::fs::read_to_string(&id_mapping_path).expect("ID mapping file should still exist"); + assert!( + !id_mapping_after.contains("my-canister"), + "ID mapping should NOT contain my-canister after deletion" + ); +} diff --git a/crates/icp-cli/tests/canister_install_tests.rs b/crates/icp-cli/tests/canister_install_tests.rs index 479dc870..9bed9d1c 100644 --- a/crates/icp-cli/tests/canister_install_tests.rs +++ b/crates/icp-cli/tests/canister_install_tests.rs @@ -1116,3 +1116,86 @@ async fn canister_install_upgrade_rejects_incompatible_candid() { .success() .stdout(eq("(\"Hello, 42!\")").trim()); } + +#[tokio::test] +async fn canister_install_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Build canister + ctx.icp() + .current_dir(&project_dir) + .args(["build", "my-canister"]) + .assert() + .success(); + + // Create canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "create", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Install canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "install", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify canister works + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "my-canister", + "greet", + "(\"test\")", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(eq("(\"Hello, test!\")").trim()); +} diff --git a/crates/icp-cli/tests/canister_logs_tests.rs b/crates/icp-cli/tests/canister_logs_tests.rs index b84484b7..49484f91 100644 --- a/crates/icp-cli/tests/canister_logs_tests.rs +++ b/crates/icp-cli/tests/canister_logs_tests.rs @@ -365,3 +365,82 @@ async fn canister_logs_filter_by_timestamp() { .success() .stdout(contains("Timestamped message")); } + +// Ignored: fetch_canister_logs is not yet available in replicated mode. +// Tracking: https://github.com/dfinity/portal/pull/6106 +#[ignore] +#[cfg(unix)] // moc +#[tokio::test] +async fn canister_logs_through_proxy() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("canister_logs"); + + ctx.copy_asset_dir("canister_logs", &project_dir); + + let pm = formatdoc! {r#" + canisters: + - name: logger + recipe: + type: "@dfinity/motoko@v4.0.0" + configuration: + main: main.mo + args: "" + + {NETWORK_RANDOM_PORT} + {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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy logger through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "logger", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Create some logs by calling through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "logger", + "log", + "(\"Proxy log message\")", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Fetch logs through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "logs", + "logger", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Proxy log message")); +} diff --git a/crates/icp-cli/tests/canister_settings_tests.rs b/crates/icp-cli/tests/canister_settings_tests.rs index 16300351..d01d9fe1 100644 --- a/crates/icp-cli/tests/canister_settings_tests.rs +++ b/crates/icp-cli/tests/canister_settings_tests.rs @@ -318,6 +318,89 @@ fn get_principal(client: &icp_cli::Client<'_>, identity: &str) -> String { client.get_principal(identity).to_string() } +#[tokio::test] +async fn canister_settings_update_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let client = clients::icp(&ctx, &project_dir, None); + let principal_alice = get_principal(&client, "alice"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Add controller through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "settings", + "update", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + "--add-controller", + principal_alice.as_str(), + ]) + .assert() + .success(); + + // Verify the controller was added + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "settings", + "show", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout( + starts_with("Canister Id:") + .and(contains(&proxy_cid)) + .and(contains(principal_alice.as_str())), + ); +} + #[tokio::test] async fn canister_settings_update_log_visibility() { let ctx = TestContext::new(); @@ -1293,3 +1376,95 @@ async fn canister_settings_sync_log_visibility() { sync(&ctx, &project_dir); confirm_log_visibility(&ctx, &project_dir, "Allowed viewers: 2vxsx-fae, aaaaa-aa"); } + +#[tokio::test] +async fn canister_settings_sync_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Update manifest with memory_allocation setting + let pm_with_settings = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: cp '{wasm}' "$ICP_WASM_OUTPUT_PATH" + settings: + memory_allocation: 10485760 + + {NETWORK_RANDOM_PORT} + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm_with_settings) + .expect("failed to write project manifest"); + + // Sync settings through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "settings", + "sync", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify the setting was applied + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "settings", + "show", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Memory allocation: 10_485_760")); +} diff --git a/crates/icp-cli/tests/canister_snapshot_tests.rs b/crates/icp-cli/tests/canister_snapshot_tests.rs index 0cb69e2d..49c18cb6 100644 --- a/crates/icp-cli/tests/canister_snapshot_tests.rs +++ b/crates/icp-cli/tests/canister_snapshot_tests.rs @@ -1495,3 +1495,353 @@ async fn canister_migrate_id() { .assert() .failure(); } + +/// Tests the full snapshot workflow through a proxy: create -> list -> download -> upload -> restore -> delete +#[cfg(unix)] // moc +#[tokio::test] +async fn canister_snapshot_workflow_through_proxy() { + let ctx = TestContext::new(); + let project_dir = ctx.create_project_dir("icp"); + let snapshot_dir = ctx.create_project_dir("snapshot"); + + ctx.copy_asset_dir("echo_init_arg_canister", &project_dir); + + let pm = formatdoc! {r#" + canisters: + - name: my-canister + recipe: + type: "@dfinity/motoko@v4.0.0" + configuration: + main: main.mo + args: "" + init_args: "(opt 1 : opt nat8)" + + {NETWORK_RANDOM_PORT} + {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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy with initial value 1 + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "my-canister", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Verify initial value is 1 + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "my-canister", + "get", + "()", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("\"1\"")); + + // Stop canister before creating snapshot + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Create a snapshot through proxy + let create_output = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "create", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let create_output_str = String::from_utf8_lossy(&create_output); + assert!( + create_output_str.contains("Created snapshot"), + "Expected 'Created snapshot' in output, got: {}", + create_output_str + ); + + let snapshot_id = create_output_str + .lines() + .find(|line| line.contains("Created snapshot")) + .and_then(|line| line.split_whitespace().nth(2)) + .expect("Could not extract snapshot ID from output"); + + // List snapshots through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "list", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains(snapshot_id)); + + // Download the snapshot through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "download", + "my-canister", + snapshot_id, + "--output", + snapshot_dir.as_str(), + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stderr(contains("Snapshot downloaded")); + + assert!( + snapshot_dir.join("metadata.json").exists(), + "metadata.json should exist after download" + ); + + // Upload the snapshot back through proxy + let upload_output = ctx + .icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "upload", + "my-canister", + "--input", + snapshot_dir.as_str(), + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .get_output() + .stdout + .clone(); + + let uploaded_snapshot_id = String::from_utf8_lossy(&upload_output) + .lines() + .find(|line| line.contains("uploaded successfully")) + .and_then(|line| line.split_whitespace().nth(1)) + .expect("Could not extract uploaded snapshot ID") + .to_string(); + + // Delete the uploaded snapshot (cleanup) + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "delete", + "my-canister", + &uploaded_snapshot_id, + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Start, reinstall with new value, then restore from snapshot + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "start", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "install", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + "--mode", + "reinstall", + "--args", + "(opt 99 : opt nat8)", + ]) + .assert() + .success(); + + // Verify value is now 99 + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "my-canister", + "get", + "()", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("\"99\"")); + + // Stop and restore from snapshot through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "restore", + "my-canister", + snapshot_id, + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stderr(contains("Restored canister")); + + // Start and verify value is back to 1 + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "start", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "my-canister", + "get", + "()", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("\"1\"")); + + // Delete the snapshot through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "delete", + "my-canister", + snapshot_id, + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stderr(contains("Deleted snapshot")); + + // List snapshots - should be empty + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "snapshot", + "list", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("No snapshots found")); +} diff --git a/crates/icp-cli/tests/canister_start_tests.rs b/crates/icp-cli/tests/canister_start_tests.rs index 48861772..e73a8ce5 100644 --- a/crates/icp-cli/tests/canister_start_tests.rs +++ b/crates/icp-cli/tests/canister_start_tests.rs @@ -120,3 +120,106 @@ async fn canister_start() { .and(contains("Controllers: 2vxsx-fae")), ); } + +#[tokio::test] +async fn canister_start_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Stop canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify stopped + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Status: Stopped")); + + // Start canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "start", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify running + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Status: Running")); +} diff --git a/crates/icp-cli/tests/canister_status_tests.rs b/crates/icp-cli/tests/canister_status_tests.rs index 71437a35..c6dabfb2 100644 --- a/crates/icp-cli/tests/canister_status_tests.rs +++ b/crates/icp-cli/tests/canister_status_tests.rs @@ -76,3 +76,64 @@ async fn canister_status() { .and(contains("Controllers: 2vxsx-fae")), ); } + +#[tokio::test] +async fn canister_status_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Query status through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout( + starts_with("Canister Id:") + .and(contains("Status: Running")) + .and(contains(&proxy_cid)), + ); +} diff --git a/crates/icp-cli/tests/canister_stop_tests.rs b/crates/icp-cli/tests/canister_stop_tests.rs index 70d30074..3f4a0330 100644 --- a/crates/icp-cli/tests/canister_stop_tests.rs +++ b/crates/icp-cli/tests/canister_stop_tests.rs @@ -88,3 +88,79 @@ async fn canister_stop() { .and(contains("Controllers: 2vxsx-fae")), ); } + +#[tokio::test] +async fn canister_stop_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Stop canister through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "stop", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success(); + + // Verify canister is stopped via status through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout( + starts_with("Canister Id:") + .and(contains("Status: Stopped")) + .and(contains(&proxy_cid)), + ); +} diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index 3f5a7f36..1b641f8c 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -444,6 +444,23 @@ impl TestContext { .as_ref() } + /// Get the proxy canister principal from network status JSON output. + pub(crate) fn get_proxy_cid(&self, project_dir: &Path, network: &str) -> String { + let output = self + .icp() + .current_dir(project_dir) + .args(["network", "status", network, "--json"]) + .output() + .expect("failed to get network status"); + let status_json: serde_json::Value = + serde_json::from_slice(&output.stdout).expect("failed to parse network status JSON"); + status_json + .get("proxy_canister_principal") + .and_then(|v| v.as_str()) + .expect("proxy canister principal not found in network status") + .to_string() + } + pub(crate) fn docker_pull_network(&self) { self.docker_pull_image("ghcr.io/dfinity/icp-cli-network-launcher:v11.0.0"); } diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index 37e05271..327b1cc4 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -1,6 +1,7 @@ use indoc::{formatdoc, indoc}; use predicates::{ ord::eq, + prelude::PredicateBooleanExt, str::{PredicateStrExt, contains}, }; use test_tag::tag; @@ -683,3 +684,78 @@ async fn deploy_cloud_engine() { .success() .stdout(eq("(\"Hello, test!\")").trim()); } + +#[tokio::test] +async fn deploy_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let wasm = ctx.make_asset("example_icp_mo.wasm"); + + let pm = formatdoc! {r#" + canisters: + - 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"), &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"); + + let proxy_cid = ctx.get_proxy_cid(&project_dir, "random-network"); + + // Deploy through proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "--proxy", + &proxy_cid, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // Verify canister works by calling it through proxy (proxy is the controller) + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "call", + "--environment", + "random-environment", + "my-canister", + "greet", + "(\"proxy\")", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(eq("(\"Hello, proxy!\")").trim()); + + // Verify canister status through proxy shows the proxy as controller + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "status", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Status: Running").and(contains(&proxy_cid))); +} diff --git a/docs/reference/cli.md b/docs/reference/cli.md index 9a729145..62fe873e 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -266,6 +266,7 @@ Delete a canister from a network * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -307,6 +308,7 @@ Install a built WASM to a canister on a network * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -392,6 +394,7 @@ Migrate a canister ID from one subnet to another * `-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) +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -430,6 +433,7 @@ By default this queries the status endpoint of the management canister. If the c * `-i`, `--id-only` — Only print the canister ids * `--json` — Format output in json * `-p`, `--public` — Show the only the public information. Skips trying to get the status from the management canister and looks up public information from the state tree +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -470,6 +474,7 @@ Change a canister's settings to specified values * `--set-log-viewer ` * `--add-environment-variable ` * `--remove-environment-variable ` +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -489,6 +494,7 @@ Synchronize a canister's settings with those defined in the project * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -528,6 +534,7 @@ Create a snapshot of a canister's state * `--replace ` — Replace an existing snapshot instead of creating a new one. The old snapshot will be deleted once the new one is successfully created * `--json` — Output command results as JSON * `-q`, `--quiet` — Suppress human-readable output; print only snapshot ID +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -548,6 +555,7 @@ Delete a canister snapshot * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -570,6 +578,7 @@ Download a snapshot to local disk * `--identity ` — The user identity to run this command as * `-o`, `--output ` — Output directory for the snapshot files * `--resume` — Resume a previously interrupted download +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -591,6 +600,7 @@ List all snapshots for a canister * `--identity ` — The user identity to run this command as * `--json` — Output command results as JSON * `-q`, `--quiet` — Suppress human-readable output; print only snapshot IDs +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -611,6 +621,7 @@ Restore a canister from a snapshot * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -635,6 +646,7 @@ Upload a snapshot from local disk * `--resume` — Resume a previously interrupted upload * `--json` — Output command results as JSON * `-q`, `--quiet` — Suppress human-readable output; print only snapshot ID +* `--proxy ` — Principal of a proxy canister to route the management canister calls through @@ -654,6 +666,7 @@ Start a canister on a network * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -678,6 +691,7 @@ By default this queries the status endpoint of the management canister. If the c * `-i`, `--id-only` — Only print the canister ids * `--json` — Format output in json * `-p`, `--public` — Show the only the public information. Skips trying to get the status from the management canister and looks up public information from the state tree +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -697,6 +711,7 @@ Stop a canister on a network * `-k`, `--root-key ` — The root key to use if connecting to a network by URL. Required when using `--network ` * `-e`, `--environment ` — Override the environment to connect to. By default, the local environment is used * `--identity ` — The user identity to run this command as +* `--proxy ` — Principal of a proxy canister to route the management canister call through @@ -815,6 +830,7 @@ Deploy a project to an environment Possible values: `auto`, `install`, `reinstall`, `upgrade` * `--subnet ` — The subnet to use for the canisters being deployed +* `--proxy ` — Principal of a proxy canister to route management canister calls through * `--controller ` — One or more controllers for the canisters being deployed. Repeat `--controller` to specify multiple * `--cycles ` — Cycles to fund canister creation. Supports suffixes: k (thousand), m (million), b (billion), t (trillion)