diff --git a/CHANGELOG.md b/CHANGELOG.md index fc902e13..38a96e7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ # Unreleased +* feat: `icp canister create --proxy` to create canisters via a proxy canister + # v0.2.2 Important: A network launcher more recent than v12.0.0-83c3f95e8c4ce28e02493df83df5f84a166451c0 is diff --git a/crates/icp-cli/src/commands/canister/create.rs b/crates/icp-cli/src/commands/canister/create.rs index 9b756689..1a67ed74 100644 --- a/crates/icp-cli/src/commands/canister/create.rs +++ b/crates/icp-cli/src/commands/canister/create.rs @@ -10,7 +10,10 @@ use icp_canister_interfaces::management_canister::CanisterSettingsArg; use serde::Serialize; use tracing::info; -use crate::{commands::args, operations::create::CreateOperation}; +use crate::{ + commands::args, + operations::create::{CreateOperation, CreateTarget}, +}; pub(crate) const DEFAULT_CANISTER_CYCLES: u128 = 2 * TRILLION; @@ -82,9 +85,17 @@ pub(crate) struct CreateArgs { pub(crate) cycles: CyclesAmount, /// The subnet to create canisters on. - #[arg(long)] + #[arg(long, conflicts_with = "proxy")] pub(crate) subnet: Option, + /// Principal of a proxy canister to route the create_canister call through. + /// + /// When specified, the canister will be created on the same subnet as the + /// proxy canister by forwarding the management canister call through the + /// proxy's `proxy` method. + #[arg(long, conflicts_with = "subnet")] + pub(crate) proxy: Option, + /// Create a canister detached from any project configuration. The canister id will be /// printed out but not recorded in the project configuration. Not valid if `Canister` /// is provided. @@ -136,6 +147,14 @@ impl CreateArgs { } } + fn create_target(&self) -> CreateTarget { + match (self.subnet, self.proxy) { + (Some(subnet), _) => CreateTarget::Subnet(subnet), + (_, Some(proxy)) => CreateTarget::Proxy(proxy), + _ => CreateTarget::None, + } + } + pub(crate) fn canister_settings(&self) -> CanisterSettingsArg { CanisterSettingsArg { freezing_threshold: self @@ -192,7 +211,8 @@ async fn create_canister(ctx: &Context, args: &CreateArgs) -> Result<(), anyhow: ) .await?; - let create_operation = CreateOperation::new(agent, args.subnet, args.cycles.get(), vec![]); + let create_operation = + CreateOperation::new(agent, args.create_target(), args.cycles.get(), vec![]); let canister_settings = args.canister_settings(); @@ -252,8 +272,12 @@ async fn create_project_canister(ctx: &Context, args: &CreateArgs) -> Result<(), .into_values() .collect(); - let create_operation = - CreateOperation::new(agent, args.subnet, args.cycles.get(), existing_canisters); + let create_operation = CreateOperation::new( + agent, + args.create_target(), + args.cycles.get(), + existing_canisters, + ); let canister_settings = args.canister_settings_with_default(&canister_info); let id = create_operation.create(&canister_settings).await?; diff --git a/crates/icp-cli/src/commands/deploy.rs b/crates/icp-cli/src/commands/deploy.rs index 553b12c4..75275afa 100644 --- a/crates/icp-cli/src/commands/deploy.rs +++ b/crates/icp-cli/src/commands/deploy.rs @@ -19,7 +19,7 @@ use crate::{ binding_env_vars::set_binding_env_vars_many, build::build_many_with_progress_bar, candid_compat::check_candid_compatibility_many, - create::CreateOperation, + create::{CreateOperation, CreateTarget}, install::{install_many, resolve_install_mode_and_status}, settings::sync_settings_many, sync::sync_many, @@ -127,9 +127,13 @@ 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 create_operation = CreateOperation::new( agent.clone(), - args.subnet, + target, args.cycles.get(), existing_canisters.into_values().collect(), ); diff --git a/crates/icp-cli/src/operations/create.rs b/crates/icp-cli/src/operations/create.rs index 833d6f05..a3aaf6f5 100644 --- a/crates/icp-cli/src/operations/create.rs +++ b/crates/icp-cli/src/operations/create.rs @@ -12,6 +12,7 @@ use icp_canister_interfaces::{ management_canister::{ CanisterSettingsArg, MgmtCreateCanisterArgs, MgmtCreateCanisterResponse, }, + proxy::{ProxyArgs, ProxyResult}, }; use rand::seq::IndexedRandom; use snafu::{OptionExt, ResultExt, Snafu}; @@ -49,11 +50,30 @@ pub enum CreateOperationError { #[snafu(display("failed to resolve subnet: {message}"))] SubnetResolution { message: String }, + + #[snafu(display("proxy call failed: {message}"))] + ProxyCall { message: String }, + + #[snafu(display("failed to decode proxy canister response: {source}"))] + ProxyDecode { source: candid::Error }, +} + +/// Determines how a new canister is created. +pub enum CreateTarget { + /// Create the canister on a specific subnet, chosen by the caller. + Subnet(Principal), + /// Create the canister via a proxy canister. The `create_canister` call is + /// forwarded through the proxy's `proxy` method to the management canister, + /// so the new canister will be placed on the same subnet as the proxy. + Proxy(Principal), + /// No explicit target. The subnet is resolved automatically: either from an + /// existing canister in the project or by picking a random available subnet. + None, } struct CreateOperationInner { agent: Agent, - subnet: Option, + target: CreateTarget, cycles: u128, existing_canisters: Vec, resolved_subnet: OnceCell>, @@ -74,14 +94,14 @@ impl Clone for CreateOperation { impl CreateOperation { pub fn new( agent: Agent, - subnet: Option, + target: CreateTarget, cycles: u128, existing_canisters: Vec, ) -> Self { Self { inner: Arc::new(CreateOperationInner { agent, - subnet, + target, cycles, existing_canisters, resolved_subnet: OnceCell::new(), @@ -97,6 +117,10 @@ impl CreateOperation { &self, settings: &CanisterSettingsArg, ) -> Result { + if let CreateTarget::Proxy(proxy) = self.inner.target { + return self.create_proxy(settings, proxy).await; + } + let selected_subnet = self .get_subnet() .await @@ -187,6 +211,49 @@ impl CreateOperation { Ok(resp.canister_id) } + async fn create_proxy( + &self, + settings: &CanisterSettingsArg, + proxy: Principal, + ) -> Result { + let mgmt_arg = MgmtCreateCanisterArgs { + settings: Some(settings.clone()), + sender_canister_version: None, + }; + let mgmt_arg_bytes = Encode!(&mgmt_arg).context(CandidEncodeSnafu)?; + + let proxy_args = ProxyArgs { + canister_id: Principal::management_canister(), + method: "create_canister".to_string(), + args: mgmt_arg_bytes, + cycles: Nat::from(self.inner.cycles), + }; + let proxy_arg_bytes = Encode!(&proxy_args).context(CandidEncodeSnafu)?; + + let proxy_res = self + .inner + .agent + .update(&proxy, "proxy") + .with_arg(proxy_arg_bytes) + .await + .context(AgentSnafu)?; + + let proxy_result: (ProxyResult,) = + candid::decode_args(&proxy_res).context(ProxyDecodeSnafu)?; + + match proxy_result.0 { + ProxyResult::Ok(ok) => { + let resp = + Decode!(&ok.result, MgmtCreateCanisterResponse).context(CandidDecodeSnafu)?; + Ok(resp.canister_id) + } + ProxyResult::Err(err) => ProxyCallSnafu { + message: err.format_error(), + } + .fail(), + } + } + /// 1. If a subnet is explicitly provided, use it /// 2. If no canisters exist yet, pick a random available subnet /// 3. If canisters exist, use the same subnet as the first existing canister @@ -198,7 +265,7 @@ impl CreateOperation { .resolved_subnet .get_or_init(|| async { // If subnet is explicitly provided, use it - if let Some(subnet) = self.inner.subnet { + if let CreateTarget::Subnet(subnet) = self.inner.target { return Ok(subnet); } diff --git a/crates/icp-cli/tests/canister_create_tests.rs b/crates/icp-cli/tests/canister_create_tests.rs index aa21b6e9..44bf3eff 100644 --- a/crates/icp-cli/tests/canister_create_tests.rs +++ b/crates/icp-cli/tests/canister_create_tests.rs @@ -542,6 +542,71 @@ async fn canister_create_detached() { .failure(); } +#[tokio::test] +async fn canister_create_through_proxy() { + let ctx = TestContext::new(); + + let project_dir = ctx.create_project_dir("icp"); + + let pm = formatdoc! {r#" + canisters: + - name: my-canister + build: + steps: + - type: script + command: echo hi + + {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"); + + // Get the proxy canister ID from network status + let output = ctx + .icp() + .current_dir(&project_dir) + .args(["network", "status", "random-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"); + let proxy_cid = status_json + .get("proxy_canister_principal") + .and_then(|v| v.as_str()) + .expect("proxy canister principal not found in network status") + .to_string(); + + // Create canister through the proxy + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "create", + "my-canister", + "--environment", + "random-environment", + "--proxy", + &proxy_cid, + ]) + .assert() + .success() + .stdout(contains("Created canister my-canister with ID")); + + let id_mapping_path = project_dir + .join(".icp") + .join("cache") + .join("mappings") + .join("random-environment.ids.json"); + assert!( + id_mapping_path.exists(), + "ID mapping file should exist at {id_mapping_path}" + ); +} + #[tag(docker)] #[tokio::test] async fn canister_create_cloud_engine() { diff --git a/docs/reference/cli.md b/docs/reference/cli.md index d382dec3..9a729145 100644 --- a/docs/reference/cli.md +++ b/docs/reference/cli.md @@ -242,6 +242,9 @@ Examples: Default value: `2000000000000` * `--subnet ` — The subnet to create canisters on +* `--proxy ` — Principal of a proxy canister to route the create_canister call through. + + When specified, the canister will be created on the same subnet as the proxy canister by forwarding the management canister call through the proxy's `proxy` method. * `--detached` — Create a canister detached from any project configuration. The canister id will be printed out but not recorded in the project configuration. Not valid if `Canister` is provided * `--json` — Output command results as JSON