diff --git a/crates/icp-cli/tests/canister_create_tests.rs b/crates/icp-cli/tests/canister_create_tests.rs index e96db7e6..f0becbc3 100644 --- a/crates/icp-cli/tests/canister_create_tests.rs +++ b/crates/icp-cli/tests/canister_create_tests.rs @@ -73,6 +73,68 @@ async fn canister_create() { ); } +/// Verifies that `canister create --subnet ` creates the canister on the requested subnet. +/// +/// The network is configured with multiple application subnets so the placement is an actual +/// choice: if `--subnet` were ignored (and a subnet picked by default instead), the canister +/// could land on a different one and the assertion would fail. +#[tokio::test] +async fn canister_create_on_requested_subnet() { + 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 + + networks: + - name: random-network + mode: managed + gateway: + port: 0 + subnets: [application, application] + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + let subnet_id = ctx.application_subnet_id().await; + + let icp_client = clients::icp(&ctx, &project_dir, Some("random-environment".to_string())); + icp_client.mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "canister", + "create", + "my-canister", + "--subnet", + &subnet_id, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // The canister must be created on exactly the subnet we requested. + let actual_subnet = clients::registry(&ctx) + .get_subnet_for_canister(icp_client.get_canister_id("my-canister")) + .await; + assert_eq!( + actual_subnet.to_string(), + subnet_id, + "canister should be created on the requested subnet" + ); +} + #[tokio::test] async fn canister_create_with_settings() { let ctx = TestContext::new(); @@ -979,23 +1041,7 @@ async fn canister_create_cloud_engine() { // Find the CloudEngine subnet by querying the topology endpoint // TODO replace with a subnet selection parameter once we have one - let topology_url = ctx.gateway_url().join("/_/topology").unwrap(); - let topology: serde_json::Value = reqwest::get(topology_url) - .await - .expect("failed to fetch topology") - .json() - .await - .expect("failed to parse topology"); - - let subnet_configs = topology["subnet_configs"] - .as_object() - .expect("subnet_configs should be an object"); - let cloud_engine_subnet_id = subnet_configs - .iter() - .find_map(|(id, config)| { - (config["subnet_kind"].as_str()? == "CloudEngine").then_some(id.clone()) - }) - .expect("no CloudEngine subnet found in topology"); + let cloud_engine_subnet_id = ctx.cloud_engine_subnet_id().await; // Create the canister on the CloudEngine subnet // Only the admin can do this. In local envs, the admin is the anonymous principal diff --git a/crates/icp-cli/tests/canister_delete_tests.rs b/crates/icp-cli/tests/canister_delete_tests.rs index 6a6f9da2..db59e035 100644 --- a/crates/icp-cli/tests/canister_delete_tests.rs +++ b/crates/icp-cli/tests/canister_delete_tests.rs @@ -45,13 +45,7 @@ async fn canister_delete() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_info_tests.rs b/crates/icp-cli/tests/canister_info_tests.rs index b5595f38..2b22ef76 100644 --- a/crates/icp-cli/tests/canister_info_tests.rs +++ b/crates/icp-cli/tests/canister_info_tests.rs @@ -45,13 +45,7 @@ async fn canister_status() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_metadata_tests.rs b/crates/icp-cli/tests/canister_metadata_tests.rs index 2c0cd409..643891b4 100644 --- a/crates/icp-cli/tests/canister_metadata_tests.rs +++ b/crates/icp-cli/tests/canister_metadata_tests.rs @@ -45,13 +45,7 @@ async fn canister_metadata() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -109,13 +103,7 @@ async fn canister_metadata_not_found() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_settings_tests.rs b/crates/icp-cli/tests/canister_settings_tests.rs index b7752b51..35f78334 100644 --- a/crates/icp-cli/tests/canister_settings_tests.rs +++ b/crates/icp-cli/tests/canister_settings_tests.rs @@ -55,13 +55,7 @@ async fn canister_settings_update_controllers() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -425,13 +419,7 @@ async fn canister_settings_update_log_visibility() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -731,8 +719,6 @@ async fn canister_settings_update_miscellaneous() { .current_dir(&project_dir) .args([ "deploy", - "--subnet", - common::SUBNET_ID, "--cycles", "120t", // 120T cycles because compute allocation is expensive "--environment", @@ -859,13 +845,7 @@ async fn canister_settings_update_environment_variables() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1036,13 +1016,7 @@ async fn canister_settings_sync() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1220,13 +1194,7 @@ async fn canister_settings_sync_log_visibility() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1458,13 +1426,7 @@ async fn canister_settings_show() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1555,13 +1517,7 @@ async fn canister_settings_show_not_a_controller() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_start_tests.rs b/crates/icp-cli/tests/canister_start_tests.rs index e73a8ce5..96fd707a 100644 --- a/crates/icp-cli/tests/canister_start_tests.rs +++ b/crates/icp-cli/tests/canister_start_tests.rs @@ -48,13 +48,7 @@ async fn canister_start() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_status_tests.rs b/crates/icp-cli/tests/canister_status_tests.rs index c6dabfb2..6aa10aa0 100644 --- a/crates/icp-cli/tests/canister_status_tests.rs +++ b/crates/icp-cli/tests/canister_status_tests.rs @@ -48,13 +48,7 @@ async fn canister_status() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/canister_stop_tests.rs b/crates/icp-cli/tests/canister_stop_tests.rs index 3f4a0330..4696eec2 100644 --- a/crates/icp-cli/tests/canister_stop_tests.rs +++ b/crates/icp-cli/tests/canister_stop_tests.rs @@ -47,13 +47,7 @@ async fn canister_stop() { .mint_cycles(10 * TRILLION); ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/common/context.rs b/crates/icp-cli/tests/common/context.rs index c4866ae9..92d41e3b 100644 --- a/crates/icp-cli/tests/common/context.rs +++ b/crates/icp-cli/tests/common/context.rs @@ -355,6 +355,48 @@ impl TestContext { self.http_gateway_url.get().unwrap() } + /// Returns the ID of an application subnet of the running network. + /// + /// See [`subnet_id_by_kind`](Self::subnet_id_by_kind) for why discovery happens at runtime. + pub(crate) async fn application_subnet_id(&self) -> String { + self.subnet_id_by_kind("Application").await + } + + /// Returns the ID of a CloudEngine subnet of the running network. + /// + /// See [`subnet_id_by_kind`](Self::subnet_id_by_kind) for why discovery happens at runtime. + pub(crate) async fn cloud_engine_subnet_id(&self) -> String { + self.subnet_id_by_kind("CloudEngine").await + } + + /// Returns the ID of a subnet with the given `subnet_kind`, querying the running network's + /// `/_/topology` endpoint. A topology may contain more than one subnet of a kind; this + /// returns one of them and panics if none exist. + /// + /// Subnet IDs are derived deterministically from the launcher topology, so adding or + /// removing subnets shifts them. Discovering the ID at runtime keeps the tests robust + /// against launcher topology changes instead of hard-coding an ID. + /// + /// The network must be started and healthy before calling this. + async fn subnet_id_by_kind(&self, subnet_kind: &str) -> String { + let topology_url = self.gateway_url().join("/_/topology").unwrap(); + let topology: serde_json::Value = reqwest::get(topology_url) + .await + .expect("failed to fetch topology") + .json() + .await + .expect("failed to parse topology"); + + topology["subnet_configs"] + .as_object() + .expect("subnet_configs should be an object") + .iter() + .find_map(|(id, config)| { + (config["subnet_kind"].as_str()? == subnet_kind).then_some(id.clone()) + }) + .unwrap_or_else(|| panic!("no {subnet_kind} subnet found in topology")) + } + pub(crate) fn agent(&self) -> Agent { let agent = Agent::builder() .with_url(self.http_gateway_url.get().unwrap().as_str()) diff --git a/crates/icp-cli/tests/common/mod.rs b/crates/icp-cli/tests/common/mod.rs index a74a3cb1..38f98b5b 100644 --- a/crates/icp-cli/tests/common/mod.rs +++ b/crates/icp-cli/tests/common/mod.rs @@ -40,14 +40,6 @@ environments: network: docker-engine-network "#; -/// This ID is dependent on the toplogy being served by pocket-ic -/// NOTE: If the topology is changed (another subnet is added, etc) the ID may change. -/// References: -/// - http://localhost:8000/_/topology -/// - http://localhost:8000/_/dashboard -pub(crate) const SUBNET_ID: &str = - "cok7q-nnbiu-4xwf6-7gpqg-kwzft-mqypn-uepxh-mx2hy-q4wuy-5s5my-eae"; - // Spawns a test server that expects a single request and responds with a 200 status code and the given body pub(crate) fn spawn_test_server(method: &str, path: &str, body: &[u8]) -> httptest::Server { // Run the server diff --git a/crates/icp-cli/tests/deploy_tests.rs b/crates/icp-cli/tests/deploy_tests.rs index de0cc454..0768c84c 100644 --- a/crates/icp-cli/tests/deploy_tests.rs +++ b/crates/icp-cli/tests/deploy_tests.rs @@ -40,7 +40,7 @@ fn deploy_empty() { // Deploy project ctx.icp() .current_dir(&project_dir) - .args(["deploy", "--subnet", common::SUBNET_ID]) + .args(["deploy"]) .assert() .success(); } @@ -67,7 +67,7 @@ fn deploy_canister_not_found() { // Deploy project ctx.icp() .current_dir(&project_dir) - .args(["deploy", "my-canister", "--subnet", common::SUBNET_ID]) + .args(["deploy", "my-canister"]) .assert() .failure() .stderr(contains("Error: project does not contain a canister named 'my-canister'").trim()); @@ -112,13 +112,7 @@ async fn deploy() { // Deploy project ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -178,26 +172,14 @@ async fn deploy_twice_should_succeed() { // Deploy project (first time) ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); // Deploy project (second time) ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -218,6 +200,69 @@ async fn deploy_twice_should_succeed() { .stdout(eq("(\"Hello, test!\")").trim()); } +/// Verifies that `deploy --subnet ` routes the canister to the requested subnet. +/// +/// The network is configured with multiple application subnets so the placement is an actual +/// choice: if `--subnet` were ignored (and a subnet picked by default instead), the canister +/// could land on a different one and the assertion would fail. +#[tokio::test] +async fn deploy_routes_canister_to_requested_subnet() { + 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" + + networks: + - name: random-network + mode: managed + gateway: + port: 0 + subnets: [application, application] + {ENVIRONMENT_RANDOM_PORT} + "#}; + + write_string(&project_dir.join("icp.yaml"), &pm).expect("failed to write project manifest"); + + let _g = ctx.start_network_in(&project_dir, "random-network").await; + ctx.ping_until_healthy(&project_dir, "random-network"); + + let subnet_id = ctx.application_subnet_id().await; + + let icp_client = clients::icp(&ctx, &project_dir, Some("random-environment".to_string())); + icp_client.mint_cycles(10 * TRILLION); + + ctx.icp() + .current_dir(&project_dir) + .args([ + "deploy", + "my-canister", + "--subnet", + &subnet_id, + "--environment", + "random-environment", + ]) + .assert() + .success(); + + // The canister must end up on exactly the subnet we requested. + let actual_subnet = clients::registry(&ctx) + .get_subnet_for_canister(icp_client.get_canister_id("my-canister")) + .await; + assert_eq!( + actual_subnet.to_string(), + subnet_id, + "canister should be deployed on the requested subnet" + ); +} + #[tokio::test] async fn canister_create_colocates_canisters() { let ctx = TestContext::new(); @@ -397,13 +442,7 @@ async fn deploy_prints_canister_urls() { // The example_icp_mo.wasm doesn't have http_request, so it should show Candid UI ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success() .stdout(contains("Deployed canisters:")) @@ -454,13 +493,7 @@ async fn deploy_prints_friendly_url_for_asset_canister() { // Deploy and check that the friendly URL is printed (not the Candid UI form) ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success() .stdout(contains("Deployed canisters:")) @@ -637,23 +670,7 @@ async fn deploy_cloud_engine() { // Find the CloudEngine subnet by querying the topology endpoint // TODO replace with a subnet selection parameter once we have one - let topology_url = ctx.gateway_url().join("/_/topology").unwrap(); - let topology: serde_json::Value = reqwest::get(topology_url) - .await - .expect("failed to fetch topology") - .json() - .await - .expect("failed to parse topology"); - - let subnet_configs = topology["subnet_configs"] - .as_object() - .expect("subnet_configs should be an object"); - let cloud_engine_subnet_id = subnet_configs - .iter() - .find_map(|(id, config)| { - (config["subnet_kind"].as_str()? == "CloudEngine").then_some(id.clone()) - }) - .expect("no CloudEngine subnet found in topology"); + let cloud_engine_subnet_id = ctx.cloud_engine_subnet_id().await; // Deploy to the CloudEngine subnet // Only the admin can do this. In local envs, the admin is the anonymous principal @@ -949,15 +966,7 @@ fn deploy_with_args_multiple_canisters_fails() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "canister-a", - "canister-b", - "--subnet", - common::SUBNET_ID, - "--args", - "()", - ]) + .args(["deploy", "canister-a", "canister-b", "--args", "()"]) .assert() .failure() .stderr(contains( @@ -1073,13 +1082,7 @@ async fn deploy_with_fixed_controller_principals() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1143,13 +1146,7 @@ async fn deploy_with_canister_controller() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1218,14 +1215,7 @@ async fn deploy_sync_script_icp_env_vars() { ctx.icp() .current_dir(&project_dir) - .args([ - "--debug", - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["--debug", "deploy", "--environment", "random-environment"]) .assert() .success() .stderr(contains("ENV=random-environment")) diff --git a/crates/icp-cli/tests/identity_tests.rs b/crates/icp-cli/tests/identity_tests.rs index b668e6ff..1c29fd7c 100644 --- a/crates/icp-cli/tests/identity_tests.rs +++ b/crates/icp-cli/tests/identity_tests.rs @@ -404,13 +404,7 @@ async fn identity_storage_forms() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1113,13 +1107,7 @@ async fn identity_link_hsm() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -1377,13 +1365,7 @@ async fn identity_delegation_whoami() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); diff --git a/crates/icp-cli/tests/network_tests.rs b/crates/icp-cli/tests/network_tests.rs index 18dbc2ae..5c40831a 100644 --- a/crates/icp-cli/tests/network_tests.rs +++ b/crates/icp-cli/tests/network_tests.rs @@ -193,26 +193,14 @@ async fn deploy_to_other_projects_network() { ctx.icp() .current_dir(&projb) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "environment-1", - ]) + .args(["deploy", "--environment", "environment-1"]) .assert() .success(); // Deploy project (second time) ctx.icp() .current_dir(&projb) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "environment-1", - ]) + .args(["deploy", "--environment", "environment-1"]) .assert() .success(); diff --git a/crates/icp-cli/tests/sync_tests.rs b/crates/icp-cli/tests/sync_tests.rs index 4d0c9efb..d1b6e192 100644 --- a/crates/icp-cli/tests/sync_tests.rs +++ b/crates/icp-cli/tests/sync_tests.rs @@ -58,14 +58,7 @@ async fn sync_adapter_script_single() { ctx.icp() .current_dir(&project_dir) - .args([ - "--debug", - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["--debug", "deploy", "--environment", "random-environment"]) .assert() .success() .stderr(contains("syncing").trim()); @@ -132,14 +125,7 @@ async fn sync_adapter_script_multiple() { ctx.icp() .current_dir(&project_dir) - .args([ - "--debug", - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["--debug", "deploy", "--environment", "random-environment"]) .assert() .success() .stderr(contains("first").and(contains("second"))); @@ -215,13 +201,7 @@ async fn sync_adapter_static_assets() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -380,13 +360,7 @@ async fn sync_multiple_canisters() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -508,13 +482,7 @@ async fn sync_plugin_registers_seed_data() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -582,13 +550,7 @@ async fn sync_script_icp_env_vars() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "random-environment", - ]) + .args(["deploy", "--environment", "random-environment"]) .assert() .success(); @@ -767,13 +729,7 @@ async fn sync_all_canisters_in_environment() { ctx.icp() .current_dir(&project_dir) - .args([ - "deploy", - "--subnet", - common::SUBNET_ID, - "--environment", - "test-env", - ]) + .args(["deploy", "--environment", "test-env"]) .assert() .success(); diff --git a/network-launcher-version b/network-launcher-version index 704dead7..645d4c8d 100644 --- a/network-launcher-version +++ b/network-launcher-version @@ -1 +1 @@ -13.0.0-2026-05-21-04-45 \ No newline at end of file +14.0.0-2026-05-29-04-44 \ No newline at end of file