Skip to content

Commit 47b51fb

Browse files
feat: allowing engine controller canister to perform directly membership changes (#10625)
In order to allow people to replace nodes in their engines we need to allow the engine controller to proxy the calls on behalf of engine owners. --------- Co-authored-by: r-birkner <103420898+r-birkner@users.noreply.github.com>
1 parent 5851d6d commit 47b51fb

8 files changed

Lines changed: 263 additions & 12 deletions

File tree

rs/engine_controller/canister/canister.rs

Lines changed: 19 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ use candid::Principal;
77
use ic_base_types::{NodeId, PrincipalId, SubnetId};
88
use ic_cdk::{api::msg_caller, call::Call, init, post_upgrade, println, update};
99
use ic_engine_controller::{
10-
CreateEngineArgs, DeleteEngineArgs, DeployGuestosToAllSubnetNodesPayload,
11-
EngineControllerInitArgs, NewSubnet, UpdateSubnetPayload,
10+
ChangeSubnetMembershipPayload, CreateEngineArgs, DeleteEngineArgs,
11+
DeployGuestosToAllSubnetNodesPayload, EngineControllerInitArgs, NewSubnet, UpdateSubnetPayload,
1212
};
1313
use ic_nns_constants::REGISTRY_CANISTER_ID;
1414
use ic_protobuf::registry::subnet::v1::SubnetFeatures;
@@ -361,6 +361,23 @@ async fn deploy_guestos_to_all_subnet_nodes(
361361
Ok(())
362362
}
363363

364+
/// Proxies to the registry's `change_subnet_membership` endpoint. The
365+
/// registry enforces that, when invoked by the engine controller, the target
366+
/// subnet must be of type `CloudEngine`.
367+
#[update]
368+
async fn change_subnet_membership(payload: ChangeSubnetMembershipPayload) -> Result<(), String> {
369+
ensure_authorized()?;
370+
371+
Call::unbounded_wait(REGISTRY_CANISTER_ID.into(), "change_subnet_membership")
372+
.with_arg(payload)
373+
.await
374+
.map_err(|e| format!("registry.change_subnet_membership call failed: {e:?}"))?
375+
.candid::<()>()
376+
.map_err(|e| format!("Failed to decode registry response: {e}"))?;
377+
378+
Ok(())
379+
}
380+
364381
fn main() {
365382
// This block is intentionally left blank.
366383
}

rs/engine_controller/engine_controller.did

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,9 +107,20 @@ type DeployGuestosToAllSubnetNodesPayload = record {
107107
replica_version_id : text;
108108
};
109109

110+
// Mirror of `registry_canister::mutations::do_change_subnet_membership::
111+
// ChangeSubnetMembershipPayload`. Forwarded unchanged to the registry; the
112+
// registry enforces that the target subnet is of type `CloudEngine` when the
113+
// caller is the engine controller canister.
114+
type ChangeSubnetMembershipPayload = record {
115+
subnet_id : principal;
116+
node_ids_add : vec principal;
117+
node_ids_remove : vec principal;
118+
};
119+
110120
service : (opt EngineControllerInitArgs) -> {
111121
create_engine : (CreateEngineArgs) -> (CreateEngineResult);
112122
delete_engine : (DeleteEngineArgs) -> (Result);
113123
update_subnet : (UpdateSubnetPayload) -> (Result);
124+
change_subnet_membership : (ChangeSubnetMembershipPayload) -> (Result);
114125
deploy_guestos_to_all_subnet_nodes : (DeployGuestosToAllSubnetNodesPayload) -> (Result);
115126
}

rs/engine_controller/src/lib.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ pub use registry_canister::mutations::do_create_subnet::NewSubnet;
1313
// Re-export the payload types accepted by the proxy endpoints
1414
// (`update_subnet` and `deploy_guestos_to_all_subnet_nodes`) so clients
1515
// don't have to depend on `registry-canister` directly to construct them.
16+
pub use registry_canister::mutations::do_change_subnet_membership::ChangeSubnetMembershipPayload;
1617
pub use registry_canister::mutations::do_deploy_guestos_to_all_subnet_nodes::DeployGuestosToAllSubnetNodesPayload;
1718
pub use registry_canister::mutations::do_update_subnet::UpdateSubnetPayload;
1819

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
# How This File Is Used
2+
3+
In general, upcoming/unreleased behavior changes are described here. For details
4+
on the process that this file is part of, see
5+
`rs/nervous_system/changelog_process.md`.
6+
7+
8+
# Next Upgrade Proposal
9+
10+
## Added
11+
12+
* New `change_subnet_membership` update method that proxies to the registry's
13+
`change_subnet_membership` endpoint. The registry restricts this to
14+
`CloudEngine` subnets when the caller is the engine controller canister.
15+
16+
## Changed
17+
18+
## Deprecated
19+
20+
## Removed
21+
22+
## Fixed
23+
24+
## Security

rs/registry/canister/canister/canister.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -712,7 +712,7 @@ fn remove_nodes_from_subnet_(payload: RemoveNodesFromSubnetPayload) {
712712

713713
#[unsafe(export_name = "canister_update change_subnet_membership")]
714714
fn change_subnet_membership() {
715-
check_caller_is_governance_and_log("change_subnet_membership");
715+
check_caller_is_governance_or_engine_controller_and_log("change_subnet_membership");
716716
over(candid_one, |payload: ChangeSubnetMembershipPayload| {
717717
change_subnet_membership_(payload)
718718
});

rs/registry/canister/src/mutations/do_change_subnet_membership.rs

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,38 @@ use candid::{CandidType, Deserialize};
66
#[cfg(target_arch = "wasm32")]
77
use dfn_core::println;
88
use ic_base_types::{NodeId, PrincipalId, SubnetId};
9+
use ic_nns_constants::ENGINE_CONTROLLER_CANISTER_ID;
10+
use ic_protobuf::registry::subnet::v1::SubnetType;
911
use ic_registry_keys::make_subnet_record_key;
1012
use ic_registry_transport::upsert;
1113
use prost::Message;
1214
use serde::Serialize;
1315

1416
impl Registry {
1517
/// Changes membership of nodes in a subnet record in the registry.
16-
///
17-
/// This method is called by the governance canister, after a proposal
18-
/// for modifying a subnet by changing the membership (adding/removing) has been accepted.
1918
pub fn do_change_subnet_membership(&mut self, payload: ChangeSubnetMembershipPayload) {
20-
println!("{LOG_PREFIX}do_change_subnet_membership started: {payload:?}");
19+
let caller = dfn_core::api::caller();
20+
println!("{LOG_PREFIX}do_change_subnet_membership started (caller: {caller}): {payload:?}");
2121

22-
let nodes_to_add = payload.node_ids_add.clone();
2322
let subnet_id = SubnetId::from(payload.subnet_id);
2423
let mut subnet_record = self.get_subnet_or_panic(subnet_id);
2524

25+
// When invoked by the engine controller canister, restrict the operation to
26+
// `CloudEngine` subnets. Governance retains full ability to change
27+
// membership of any subnet.
28+
if caller == ENGINE_CONTROLLER_CANISTER_ID.get() {
29+
assert_eq!(
30+
subnet_record.subnet_type,
31+
i32::from(SubnetType::CloudEngine),
32+
"{LOG_PREFIX}do_change_subnet_membership: the engine controller may \
33+
only change membership of CloudEngine subnets; subnet {subnet_id} \
34+
has subnet_type {}",
35+
subnet_record.subnet_type
36+
);
37+
}
38+
39+
let nodes_to_add = payload.node_ids_add.clone();
40+
2641
let current_subnet_nodes: Vec<NodeId> = subnet_record
2742
.membership
2843
.iter()
@@ -64,7 +79,9 @@ impl Registry {
6479
// Check the invariants and apply the mutations if invariants are satisfied
6580
self.maybe_apply_mutation_internal(mutations);
6681

67-
println!("{LOG_PREFIX}do_change_subnet_membership finished: {payload:?}");
82+
println!(
83+
"{LOG_PREFIX}do_change_subnet_membership finished (caller: {caller}): {payload:?}"
84+
);
6885
}
6986
}
7087

rs/registry/canister/tests/change_subnet_membership.rs

Lines changed: 180 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,24 +3,44 @@ use std::{collections::BTreeMap, convert::TryFrom};
33

44
use assert_matches::assert_matches;
55

6-
use candid::Encode;
6+
use candid::{Decode, Encode, Principal};
77
use canister_test::PrincipalId;
88
use dfn_candid::candid;
99
use ic_base_types::NodeId;
10+
use ic_nervous_system_integration_tests::pocket_ic_helpers::nns::registry::decode_registry_value;
11+
use ic_nns_constants::{
12+
ENGINE_CONTROLLER_CANISTER_ID, GOVERNANCE_CANISTER_ID, REGISTRY_CANISTER_ID,
13+
};
1014
use ic_nns_test_utils::{
1115
itest_helpers::{
1216
forward_call_via_universal_canister, set_up_registry_canister, set_up_universal_canister,
1317
state_machine_test_on_nns_subnet,
1418
},
15-
registry::{get_value_or_panic, prepare_registry, prepare_registry_with_two_node_sets},
19+
registry::{
20+
INITIAL_MUTATION_ID, get_value_or_panic, invariant_compliant_mutation_as_atomic_req,
21+
prepare_registry, prepare_registry_with_two_node_sets,
22+
},
23+
};
24+
use ic_protobuf::registry::{
25+
node::v1::{NodeRecord, NodeRewardType},
26+
subnet::v1::{SubnetListRecord, SubnetRecord},
1627
};
17-
use ic_protobuf::registry::subnet::v1::{SubnetListRecord, SubnetRecord};
1828
use ic_registry_keys::{make_subnet_list_record_key, make_subnet_record_key};
29+
use pocket_ic::PocketIcBuilder;
30+
use pocket_ic::RejectResponse;
31+
use pocket_ic::nonblocking::PocketIc;
1932
use registry_canister::{
2033
init::RegistryCanisterInitPayloadBuilder,
2134
mutations::do_change_subnet_membership::ChangeSubnetMembershipPayload,
2235
};
2336

37+
mod common;
38+
39+
use common::test_helpers::{
40+
install_registry_canister_with_payload_builder, prepare_registry_with_cloud_engine_subnet,
41+
prepare_registry_with_nodes_from_template,
42+
};
43+
2444
#[test]
2545
fn test_the_anonymous_user_cannot_change_subnet_membership() {
2646
state_machine_test_on_nns_subnet(|runtime| {
@@ -410,3 +430,160 @@ fn test_change_subnet_membership_duplicate_nodes() {
410430
}
411431
});
412432
}
433+
434+
/// Sets up a `PocketIc` with the registry canister installed, containing a
435+
/// CloudEngine subnet plus one extra unassigned type-4 node that can be
436+
/// swapped into the subnet.
437+
async fn setup_cloud_engine_registry() -> (PocketIc, Principal, NodeId, NodeId) {
438+
let pocket_ic = PocketIcBuilder::new().with_nns_subnet().build_async().await;
439+
440+
let (cloud_engine_mutate, cloud_engine_subnet_id) =
441+
prepare_registry_with_cloud_engine_subnet(4, INITIAL_MUTATION_ID);
442+
443+
// Prepare an extra unassigned type-4 node to swap into the subnet.
444+
// Type-4 is required because the CloudEngine subnet invariant enforces
445+
// that all members are type-4 nodes.
446+
let extra_node_template = NodeRecord {
447+
node_operator_id: PrincipalId::new_user_test_id(999).into_vec(),
448+
node_reward_type: Some(NodeRewardType::Type4 as i32),
449+
..Default::default()
450+
};
451+
// Use a starting mutation id well outside the range consumed by the
452+
// CloudEngine setup so IPs / test ids do not collide.
453+
let (extra_node_mutate, extra_node_ids_and_pks) = prepare_registry_with_nodes_from_template(
454+
1,
455+
INITIAL_MUTATION_ID + 100,
456+
extra_node_template,
457+
);
458+
let extra_node_id = *extra_node_ids_and_pks
459+
.keys()
460+
.next()
461+
.expect("expected one extra unassigned node");
462+
463+
// Any current member of the CloudEngine subnet can be picked as the one
464+
// to remove; take the first one from the subnet record.
465+
let mut builder = RegistryCanisterInitPayloadBuilder::new();
466+
builder.push_init_mutate_request(invariant_compliant_mutation_as_atomic_req(0));
467+
builder.push_init_mutate_request(cloud_engine_mutate);
468+
builder.push_init_mutate_request(extra_node_mutate);
469+
install_registry_canister_with_payload_builder(&pocket_ic, builder.build(), true).await;
470+
471+
// Read a current member of the CloudEngine subnet to use as the removal
472+
// target.
473+
let subnet_record = decode_registry_value::<SubnetRecord>(
474+
&pocket_ic,
475+
make_subnet_record_key(cloud_engine_subnet_id),
476+
)
477+
.await;
478+
let member_to_remove = NodeId::from(
479+
PrincipalId::try_from(subnet_record.membership.first().unwrap().as_slice()).unwrap(),
480+
);
481+
482+
(
483+
pocket_ic,
484+
cloud_engine_subnet_id.get().0,
485+
extra_node_id,
486+
member_to_remove,
487+
)
488+
}
489+
490+
#[tokio::test]
491+
async fn test_engine_controller_can_change_membership_of_cloud_engine_subnet() {
492+
let (pocket_ic, cloud_engine_subnet_id, node_to_add, node_to_remove) =
493+
setup_cloud_engine_registry().await;
494+
495+
let payload = ChangeSubnetMembershipPayload {
496+
subnet_id: PrincipalId::from(cloud_engine_subnet_id),
497+
node_ids_add: vec![node_to_add],
498+
node_ids_remove: vec![node_to_remove],
499+
};
500+
501+
// The engine controller performs a 1-for-1 swap on the CloudEngine subnet.
502+
// This is expected to succeed.
503+
let response: Vec<u8> = pocket_ic
504+
.update_call(
505+
REGISTRY_CANISTER_ID.get().0,
506+
ENGINE_CONTROLLER_CANISTER_ID.get().0,
507+
"change_subnet_membership",
508+
Encode!(&payload).unwrap(),
509+
)
510+
.await
511+
.unwrap();
512+
Decode!(&response, ()).unwrap();
513+
514+
// Verify the membership actually changed.
515+
let subnet_record = decode_registry_value::<SubnetRecord>(
516+
&pocket_ic,
517+
make_subnet_record_key(ic_base_types::SubnetId::from(PrincipalId::from(
518+
cloud_engine_subnet_id,
519+
))),
520+
)
521+
.await;
522+
let members = subnet_record
523+
.membership
524+
.iter()
525+
.map(|bytes| NodeId::from(PrincipalId::try_from(bytes.as_slice()).unwrap()))
526+
.collect::<Vec<NodeId>>();
527+
assert!(
528+
members.contains(&node_to_add),
529+
"swapped-in node {node_to_add} should now be a subnet member, got {members:?}"
530+
);
531+
assert!(
532+
!members.contains(&node_to_remove),
533+
"swapped-out node {node_to_remove} should no longer be a subnet member, got {members:?}"
534+
);
535+
}
536+
537+
#[tokio::test]
538+
async fn test_engine_controller_cannot_change_membership_of_non_cloud_engine_subnet() {
539+
// Set up a registry that contains only the invariant-compliant system
540+
// subnet (which is not a CloudEngine).
541+
let pocket_ic = PocketIcBuilder::new().with_nns_subnet().build_async().await;
542+
let mut builder = RegistryCanisterInitPayloadBuilder::new();
543+
builder.push_init_mutate_request(invariant_compliant_mutation_as_atomic_req(0));
544+
install_registry_canister_with_payload_builder(&pocket_ic, builder.build(), true).await;
545+
546+
// Identify the (only) non-CloudEngine subnet.
547+
let subnet_list =
548+
decode_registry_value::<SubnetListRecord>(&pocket_ic, make_subnet_list_record_key()).await;
549+
let non_cloud_engine_subnet =
550+
Principal::try_from(subnet_list.subnets.first().unwrap().as_slice()).unwrap();
551+
552+
let payload = ChangeSubnetMembershipPayload {
553+
subnet_id: PrincipalId::from(non_cloud_engine_subnet),
554+
node_ids_add: vec![],
555+
node_ids_remove: vec![],
556+
};
557+
558+
// First, establish that this payload is itself valid by having governance
559+
// (which is exempt from the CloudEngine restriction) successfully apply
560+
// it as a no-op change against the non-CloudEngine subnet.
561+
let response: Vec<u8> = pocket_ic
562+
.update_call(
563+
REGISTRY_CANISTER_ID.get().0,
564+
GOVERNANCE_CANISTER_ID.get().0,
565+
"change_subnet_membership",
566+
Encode!(&payload).unwrap(),
567+
)
568+
.await
569+
.unwrap();
570+
Decode!(&response, ()).unwrap();
571+
572+
// Now the engine controller submits the exact same payload; since the
573+
// target subnet is not a CloudEngine, the call must be rejected.
574+
let response: Result<Vec<u8>, RejectResponse> = pocket_ic
575+
.update_call(
576+
REGISTRY_CANISTER_ID.get().0,
577+
ENGINE_CONTROLLER_CANISTER_ID.get().0,
578+
"change_subnet_membership",
579+
Encode!(&payload).unwrap(),
580+
)
581+
.await;
582+
583+
let err = response.unwrap_err();
584+
assert!(
585+
err.reject_message
586+
.contains("may only change membership of CloudEngine subnets"),
587+
"expected a CloudEngine-specific rejection message, got {err:?}"
588+
);
589+
}

rs/registry/canister/unreleased_changelog.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,10 @@ on the process that this file is part of, see
1919
(e.g. on-demand cloud provisioning). All other node providers remain subject
2020
to the standard limits, and the per-IP `add_node` rate limit continues to
2121
apply to everyone.
22+
* `change_subnet_membership` may now be called by the engine controller canister
23+
in addition to the governance canister. When invoked by the engine controller,
24+
the target subnet must be of type `CloudEngine`; governance retains
25+
unrestricted access to any subnet.
2226

2327
## Deprecated
2428

0 commit comments

Comments
 (0)