Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
34 changes: 29 additions & 5 deletions crates/icp-cli/src/commands/canister/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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>,

/// 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")]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

is that an actual limitation of the proxy canister?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. It's the proxy canister making the inter-canister call to the management canister's create_canister method. There's no way to specify a subnet in that path; the new canister will always be created on the same subnet as the caller (the proxy canister).

The current interface-spec restricts the create_canister:

This method can only be called by canisters, i.e., it cannot be called by external users via ingress messages.

For the recent cloud-engine subnet feature, we enabled the subnet admin to call this method via ingress messages. An ingress message can use the effective_canister_id field to indirectly target a specific subnet — which is exactly how we did it.

pub(crate) proxy: Option<Principal>,

/// 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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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?;
Expand Down
8 changes: 6 additions & 2 deletions crates/icp-cli/src/commands/deploy.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
);
Expand Down
75 changes: 71 additions & 4 deletions crates/icp-cli/src/operations/create.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -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<Principal>,
target: CreateTarget,
cycles: u128,
existing_canisters: Vec<Principal>,
resolved_subnet: OnceCell<Result<Principal, String>>,
Expand All @@ -74,14 +94,14 @@ impl Clone for CreateOperation {
impl CreateOperation {
pub fn new(
agent: Agent,
subnet: Option<Principal>,
target: CreateTarget,
cycles: u128,
existing_canisters: Vec<Principal>,
) -> Self {
Self {
inner: Arc::new(CreateOperationInner {
agent,
subnet,
target,
cycles,
existing_canisters,
resolved_subnet: OnceCell::new(),
Expand All @@ -97,6 +117,10 @@ impl CreateOperation {
&self,
settings: &CanisterSettingsArg,
) -> Result<Principal, CreateOperationError> {
if let CreateTarget::Proxy(proxy) = self.inner.target {
return self.create_proxy(settings, proxy).await;
}

let selected_subnet = self
.get_subnet()
.await
Expand Down Expand Up @@ -187,6 +211,49 @@ impl CreateOperation {
Ok(resp.canister_id)
}

async fn create_proxy(
&self,
settings: &CanisterSettingsArg,
proxy: Principal,
) -> Result<Principal, CreateOperationError> {
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
Expand All @@ -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);
}

Expand Down
65 changes: 65 additions & 0 deletions crates/icp-cli/tests/canister_create_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
3 changes: 3 additions & 0 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ Examples:

Default value: `2000000000000`
* `--subnet <SUBNET>` — The subnet to create canisters on
* `--proxy <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

Expand Down
Loading