Skip to content

Commit 82ff6e5

Browse files
authored
feat: management canister's endpoint list_canisters as inter-canister call (#10643)
This PR allows the management canister's endpoint `list_canisters` to be called as an inter-canister call by subnet admins. The endpoint is evaluated on the same subnet as the calling canister.
1 parent 44c473e commit 82ff6e5

19 files changed

Lines changed: 591 additions & 69 deletions

File tree

rs/embedders/src/wasmtime_embedder/system_api/routing.rs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,8 @@ pub(super) fn resolve_destination(
6565
// Figure out the destination subnet based on the method and the payload.
6666
let method = Ic00Method::from_str(method_name);
6767
match method {
68-
Ok(Ic00Method::CreateCanister)
68+
Ok(Ic00Method::ListCanisters)
69+
| Ok(Ic00Method::CreateCanister)
6970
| Ok(Ic00Method::RawRand)
7071
| Ok(Ic00Method::ProvisionalCreateCanisterWithCycles)
7172
| Ok(Ic00Method::HttpRequest)

rs/embedders/src/wasmtime_embedder/system_api/sandbox_safe_system_state.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -314,6 +314,7 @@ impl SystemStateModifications {
314314
| Ok(Ic00Method::CanisterStatus)
315315
| Ok(Ic00Method::CanisterInfo)
316316
| Ok(Ic00Method::CanisterMetadata)
317+
| Ok(Ic00Method::ListCanisters)
317318
| Ok(Ic00Method::StartCanister)
318319
| Ok(Ic00Method::StopCanister)
319320
| Ok(Ic00Method::DeleteCanister)
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
use crate::create_canisters::CreateCanistersArgs;
2+
use crate::utils::{CANISTERS_PER_BATCH, expect_reply, test_canister_wasm};
3+
use candid::Encode;
4+
use criterion::{BenchmarkGroup, Criterion, criterion_group, criterion_main};
5+
use ic_base_types::CanisterId;
6+
use ic_config::execution_environment::Config as HypervisorConfig;
7+
use ic_config::subnet_config::SubnetConfig;
8+
use ic_registry_subnet_type::SubnetType;
9+
use ic_state_machine_tests::{StateMachine, StateMachineBuilder, StateMachineConfig};
10+
use ic_types_cycles::{CanisterCyclesCostSchedule, Cycles};
11+
12+
/// Canister ID assigned to the subnet-admin test canister. On a fresh subnet
13+
/// the first created canister gets the first ID in the subnet's allocation
14+
/// range (i.e. `0`), so the test canister is created first (before the
15+
/// canisters populating the subnet) to receive this ID.
16+
fn admin_canister_id() -> CanisterId {
17+
CanisterId::from_u64(0)
18+
}
19+
20+
/// Builds a `StateMachine` whose subnet has subnet admins configured (which
21+
/// requires a `Free` cost schedule on an application subnet), installs the test
22+
/// canister as the sole subnet admin, and populates the subnet with
23+
/// `canisters_number` additional canisters. Returns the `StateMachine` and the
24+
/// test canister ID.
25+
fn setup_with_canisters(canisters_number: u64) -> (StateMachine, CanisterId) {
26+
let admin = admin_canister_id();
27+
let env = StateMachineBuilder::new()
28+
.with_config(Some(StateMachineConfig::new(
29+
SubnetConfig::new(SubnetType::Application),
30+
HypervisorConfig::default(),
31+
)))
32+
.with_subnet_type(SubnetType::Application)
33+
.with_cost_schedule(CanisterCyclesCostSchedule::Free)
34+
.with_subnet_admins(vec![admin.get()])
35+
.build();
36+
37+
// Create the test canister first so that it receives the first canister ID
38+
// in the subnet's allocation range, which matches the pre-configured
39+
// subnet-admin ID.
40+
let test_canister = env.create_canister_with_cycles(None, Cycles::new(u128::MAX / 2), None);
41+
assert_eq!(test_canister, admin);
42+
env.install_existing_canister(test_canister, test_canister_wasm(), vec![])
43+
.expect("failed to install the test canister");
44+
45+
// Populate the subnet with `canisters_number` additional canisters via the
46+
// test canister (batched inter-canister calls). The canisters are created
47+
// with gaps in between so that the subnet's canister IDs form roughly
48+
// `canisters_number` distinct ranges, which `list_canisters` must report.
49+
//
50+
// The work is split into chunks so that no single ingress message exceeds
51+
// the state machine's per-message tick budget (each chunk creates twice as
52+
// many canisters and then deletes half of them). Gaps are preserved across
53+
// chunk boundaries because each chunk ends with a deleted canister ID.
54+
const CHUNK: u64 = 5_000;
55+
let mut remaining_to_create = canisters_number;
56+
let mut created_ranges = 0;
57+
while remaining_to_create > 0 {
58+
let chunk = remaining_to_create.min(CHUNK);
59+
remaining_to_create -= chunk;
60+
let result = env.execute_ingress(
61+
test_canister,
62+
"create_canisters_with_gaps",
63+
Encode!(&CreateCanistersArgs {
64+
canisters_number: chunk,
65+
canisters_per_batch: CANISTERS_PER_BATCH,
66+
initial_cycles: 0,
67+
})
68+
.unwrap(),
69+
);
70+
created_ranges += expect_reply::<u64>(result);
71+
}
72+
assert_eq!(created_ranges, canisters_number);
73+
74+
(env, test_canister)
75+
}
76+
77+
fn run_bench<M: criterion::measurement::Measurement>(
78+
group: &mut BenchmarkGroup<M>,
79+
bench_name: &str,
80+
canisters_number: u64,
81+
) {
82+
// `list_canisters` is read-only, so the environment (and its set of
83+
// canisters) does not change across iterations and can be set up once.
84+
let (env, test_canister) = setup_with_canisters(canisters_number);
85+
group.bench_function(bench_name, |b| {
86+
b.iter(|| {
87+
let result = env.execute_ingress(test_canister, "list_canisters", Encode!().unwrap());
88+
let ranges: u64 = expect_reply(result);
89+
// The canisters are created with gaps, so there is exactly one
90+
// range per canister on the subnet.
91+
assert_eq!(ranges, canisters_number);
92+
});
93+
});
94+
}
95+
96+
pub fn list_canisters_benchmark(c: &mut Criterion) {
97+
let mut group = c.benchmark_group("list_canisters");
98+
99+
run_bench(&mut group, "10", 10);
100+
run_bench(&mut group, "100", 100);
101+
run_bench(&mut group, "1k", 1_000);
102+
run_bench(&mut group, "10k", 10_000);
103+
run_bench(&mut group, "50k", 50_000);
104+
105+
group.finish();
106+
}
107+
108+
criterion_group!(benchmarks, list_canisters_benchmark);
109+
criterion_main!(benchmarks);

rs/execution_environment/benches/management_canister/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ mod create_execution_state;
55
mod ecdsa;
66
mod http_request;
77
mod install_code;
8+
mod list_canisters;
89
mod update_settings;
910
mod utils;
1011

@@ -18,6 +19,7 @@ fn all_benchmarks(c: &mut Criterion) {
1819
ecdsa::ecdsa_benchmark(c);
1920
http_request::http_request_benchmark(c);
2021
install_code::install_code_benchmark(c);
22+
list_canisters::list_canisters_benchmark(c);
2123
update_settings::update_settings_benchmark(c);
2224
}
2325

rs/execution_environment/benches/management_canister/test_canister/candid.did

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,9 +38,11 @@ type http_request_args = record {
3838

3939
service : {
4040
"create_canisters" : (create_canisters_args) -> (vec principal);
41+
"create_canisters_with_gaps" : (create_canisters_args) -> (nat64);
4142
"install_code" : (install_code_args) -> ();
4243
"update_settings" : (update_settings_args) -> ();
4344
"ecdsa_public_key" : (ecdsa_args) -> ();
4445
"sign_with_ecdsa" : (ecdsa_args) -> ();
4546
"http_request" : (http_request_args) -> ();
47+
"list_canisters" : () -> (nat64);
4648
};

rs/execution_environment/benches/management_canister/test_canister/src/main.rs

Lines changed: 86 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@ use ic_cdk::api::management_canister::http_request::{
1010
http_request as ic_cdk_http_request,
1111
};
1212
use ic_cdk::api::management_canister::main::{
13-
CanisterInstallMode, CanisterSettings, CreateCanisterArgument, InstallCodeArgument,
14-
UpdateSettingsArgument, create_canister as ic_cdk_create_canister,
15-
install_code as ic_cdk_install_code, update_settings as ic_cdk_update_settings,
13+
CanisterIdRecord, CanisterInstallMode, CanisterSettings, CreateCanisterArgument,
14+
InstallCodeArgument, UpdateSettingsArgument, create_canister as ic_cdk_create_canister,
15+
delete_canister as ic_cdk_delete_canister, install_code as ic_cdk_install_code,
16+
stop_canister as ic_cdk_stop_canister, update_settings as ic_cdk_update_settings,
1617
};
18+
use ic_cdk::call::Call;
1719
use ic_cdk::update;
1820
use serde::{Deserialize, Serialize};
1921

@@ -59,6 +61,62 @@ async fn create_canisters(args: CreateCanistersArgs) -> Vec<Principal> {
5961
result
6062
}
6163

64+
/// Creates `2 * args.canisters_number` canisters and then deletes every other
65+
/// one (in canister ID order), leaving `args.canisters_number` canisters
66+
/// separated by gaps. As a result, the management canister's `list_canisters`
67+
/// method reports (roughly) one ID range per remaining canister. Returns the
68+
/// number of remaining canisters.
69+
#[update]
70+
async fn create_canisters_with_gaps(args: CreateCanistersArgs) -> u64 {
71+
let mut canister_ids = create_canisters(CreateCanistersArgs {
72+
canisters_number: args.canisters_number * 2,
73+
canisters_per_batch: args.canisters_per_batch,
74+
initial_cycles: args.initial_cycles,
75+
})
76+
.await;
77+
78+
// Canister ID principals encode the canister index in big-endian order, so
79+
// sorting by the principal's bytes yields the numeric canister ID order.
80+
canister_ids.sort_by(|a, b| a.as_slice().cmp(b.as_slice()));
81+
82+
// Delete every other canister so that the remaining ones are separated by
83+
// gaps (i.e. each remaining canister forms its own ID range).
84+
let to_delete: Vec<Principal> = canister_ids.iter().skip(1).step_by(2).copied().collect();
85+
let mut remaining = to_delete.as_slice();
86+
while !remaining.is_empty() {
87+
let batch_size = (args.canisters_per_batch as usize).min(remaining.len());
88+
let (batch, rest) = remaining.split_at(batch_size);
89+
remaining = rest;
90+
91+
// A canister must be stopped before it can be deleted.
92+
let stop_futures: Vec<_> = batch
93+
.iter()
94+
.map(|canister_id| {
95+
ic_cdk_stop_canister(CanisterIdRecord {
96+
canister_id: *canister_id,
97+
})
98+
})
99+
.collect();
100+
join_all(stop_futures).await.into_iter().for_each(|r| {
101+
r.unwrap(); // Reject if there is an error.
102+
});
103+
104+
let delete_futures: Vec<_> = batch
105+
.iter()
106+
.map(|canister_id| {
107+
ic_cdk_delete_canister(CanisterIdRecord {
108+
canister_id: *canister_id,
109+
})
110+
})
111+
.collect();
112+
join_all(delete_futures).await.into_iter().for_each(|r| {
113+
r.unwrap(); // Reject if there is an error.
114+
});
115+
}
116+
117+
(canister_ids.len() - to_delete.len()) as u64
118+
}
119+
62120
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
63121
pub struct InstallCodeArgs {
64122
pub canister_ids: Vec<Principal>,
@@ -220,4 +278,29 @@ async fn http_request(args: HttpRequestArgs) {
220278
});
221279
}
222280

281+
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
282+
pub struct CanisterIdRange {
283+
pub start: Principal,
284+
pub end: Principal,
285+
}
286+
287+
#[derive(Clone, Debug, CandidType, Deserialize, Serialize)]
288+
pub struct ListCanistersResult {
289+
pub canisters: Vec<CanisterIdRange>,
290+
}
291+
292+
/// Calls the management canister's `list_canisters` method (which takes no
293+
/// arguments) and returns the number of canister ID ranges reported for the
294+
/// subnet. This canister must be a subnet admin for the call to succeed.
295+
#[update]
296+
async fn list_canisters() -> u64 {
297+
let result: ListCanistersResult =
298+
Call::unbounded_wait(Principal::management_canister(), "list_canisters")
299+
.await
300+
.expect("list_canisters call failed")
301+
.candid()
302+
.expect("failed to decode list_canisters response");
303+
result.canisters.len() as u64
304+
}
305+
223306
fn main() {}

rs/execution_environment/src/canister_manager.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,8 @@ impl CanisterManager {
138138
Err(_)
139139
| Ok(Ic00Method::CanisterInfo)
140140
| Ok(Ic00Method::CanisterMetadata)
141+
// `list_canisters` can only be called via inter-canister calls by subnet admins.
142+
| Ok(Ic00Method::ListCanisters)
141143
| Ok(Ic00Method::ECDSAPublicKey)
142144
| Ok(Ic00Method::SetupInitialDKG)
143145
| Ok(Ic00Method::SignWithECDSA)

rs/execution_environment/src/execution/common.rs

Lines changed: 67 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use crate::{
66
ExecuteMessageResult, HypervisorMetrics, RoundLimits, as_round_instructions,
77
canister_manager::types::CanisterManagerError, metrics::CallTreeMetrics,
88
};
9+
use candid::Encode;
910
use ic_base_types::{CanisterId, NumBytes, SubnetId};
1011
use ic_embedders::{
1112
wasm_executor::{CanisterStateChanges, ExecutionStateChanges, SliceExecutionOutput},
@@ -18,11 +19,14 @@ use ic_interfaces::execution_environment::{
1819
HypervisorError, HypervisorResult, SubnetAvailableMemory, WasmExecutionOutput,
1920
};
2021
use ic_logger::{ReplicaLogger, error, fatal, info, warn};
21-
use ic_management_canister_types_private::CanisterStatusType;
22+
use ic_management_canister_types_private::{
23+
CanisterIdRange, CanisterStatusType, EmptyBlob, ListCanistersResponse, Payload as _,
24+
};
25+
use ic_registry_routing_table::canister_id_into_u64;
2226
use ic_registry_subnet_type::SubnetType;
2327
use ic_replicated_state::{
2428
CallContext, CallContextAction, CallOrigin, CanisterState, ExecutionState, NetworkTopology,
25-
SystemState,
29+
ReplicatedState, SystemState,
2630
};
2731
use ic_types::ingress::{IngressState, IngressStatus, WasmResult};
2832
use ic_types::messages::{
@@ -417,6 +421,67 @@ pub(crate) fn validate_controller_or_subnet_admin(
417421
}
418422
}
419423

424+
/// Computes the response to the `list_canisters` management canister method.
425+
///
426+
/// The method takes no arguments and is only available on subnets with subnet
427+
/// admins configured, in which case the caller must be a subnet admin. On
428+
/// success, it returns the Candid-encoded `ListCanistersResponse` listing the
429+
/// ranges of canister IDs hosted on this subnet, together with the number of
430+
/// round instructions the caller must deduct for computing it.
431+
pub(crate) fn list_canisters(
432+
state: &ReplicatedState,
433+
caller: &PrincipalId,
434+
payload: &[u8],
435+
) -> Result<(Vec<u8>, NumInstructions), UserError> {
436+
EmptyBlob::decode(payload)?;
437+
match state.get_own_subnet_admins() {
438+
Some(ref admins) => validate_subnet_admin(admins, caller).map_err(UserError::from)?,
439+
None => {
440+
return Err(UserError::new(
441+
ErrorCode::CanisterRejectedMessage,
442+
"list_canisters is only available on subnets with subnet admins",
443+
));
444+
}
445+
}
446+
let mut canisters: Vec<CanisterIdRange> = Vec::new();
447+
for id in state.canister_states().all_keys() {
448+
let id_u64 = canister_id_into_u64(*id);
449+
match canisters.last_mut() {
450+
Some(last) if canister_id_into_u64(last.end).checked_add(1) == Some(id_u64) => {
451+
last.end = *id;
452+
}
453+
_ => canisters.push(CanisterIdRange {
454+
start: *id,
455+
end: *id,
456+
}),
457+
}
458+
}
459+
let response = ListCanistersResponse { canisters };
460+
Ok((
461+
Encode!(&response).unwrap(),
462+
list_canisters_instructions(state),
463+
))
464+
}
465+
466+
/// Computes the number of round instructions consumed by executing the
467+
/// `list_canisters` management method against the given state.
468+
///
469+
/// The cost model was derived from the `list_canisters` benchmark using the
470+
/// conversion `2B instructions = 1 second` (i.e. `2M instructions = 1 ms`):
471+
/// - a base cost of 20M instructions (≈10ms), and
472+
/// - a variable cost of 16K instructions per canister hosted on the subnet
473+
/// (`list_canisters` iterates over all of them to build the ID ranges).
474+
/// The variable cost reflects the worst case where the canister IDs form
475+
/// gaps so that each canister becomes its own ID range.
476+
// Keep in sync with `list_canisters_respects_round_instruction_limit` in
477+
// `execution_test.rs`.
478+
fn list_canisters_instructions(state: &ReplicatedState) -> NumInstructions {
479+
const BASE_INSTRUCTIONS: u64 = 20_000_000;
480+
const INSTRUCTIONS_PER_CANISTER: u64 = 16_000;
481+
let num_canisters = state.num_canisters() as u64;
482+
NumInstructions::new(BASE_INSTRUCTIONS + INSTRUCTIONS_PER_CANISTER * num_canisters)
483+
}
484+
420485
/// Unregisters the callback corresponding to the given response.
421486
//
422487
// TODO(DSM-95): Consider making this only apply to non-replicated call origins.

0 commit comments

Comments
 (0)