diff --git a/rs/registry/canister/canister/canister.rs b/rs/registry/canister/canister/canister.rs index 8cc09af4f053..846612a1d7fe 100644 --- a/rs/registry/canister/canister/canister.rs +++ b/rs/registry/canister/canister/canister.rs @@ -72,6 +72,7 @@ use registry_canister::{ DeployHostosToSomeNodes, UpdateNodesHostosVersionPayload, }, do_update_ssh_readonly_access_for_all_unassigned_nodes::UpdateSshReadOnlyAccessForAllUnassignedNodesPayload, + do_update_standard_engine_replica_version::UpdateStandardEngineReplicaVersionPayload, do_update_subnet::UpdateSubnetPayload, do_update_subnet_admins::UpdateSubnetAdminsPayload, do_update_unassigned_nodes_config::UpdateUnassignedNodesConfigPayload, @@ -990,6 +991,18 @@ fn update_unassigned_nodes_config_(payload: UpdateUnassignedNodesConfigPayload) recertify_registry(); } +#[unsafe(export_name = "canister_update update_standard_engine_replica_version")] +fn update_standard_engine_replica_version() { + check_caller_is_governance_and_log("update_standard_engine_replica_version"); + over(candid_one, update_standard_engine_replica_version_); +} + +#[candid_method(update, rename = "update_standard_engine_replica_version")] +fn update_standard_engine_replica_version_(payload: UpdateStandardEngineReplicaVersionPayload) { + registry_mut().do_update_standard_engine_replica_version(payload); + recertify_registry(); +} + #[unsafe(export_name = "canister_update deploy_guestos_to_all_unassigned_nodes")] fn deploy_guestos_to_all_unassigned_nodes() { check_caller_is_governance_and_log("deploy_guestos_to_all_unassigned_nodes"); diff --git a/rs/registry/canister/canister/registry.did b/rs/registry/canister/canister/registry.did index 399cf2f84b52..895ce709046a 100644 --- a/rs/registry/canister/canister/registry.did +++ b/rs/registry/canister/canister/registry.did @@ -540,6 +540,12 @@ type UpdateUnassignedNodesConfigPayload = record { ssh_readonly_access : opt vec text; }; +type UpdateStandardEngineReplicaVersionPayload = record { + new_replica_version_id : text; + old_replica_version_id : text; + deployment_progress : float64; +}; + type MigrateNodeOperatorPayload = record { new_node_operator_id : opt principal; old_node_operator_id : opt principal; @@ -670,6 +676,9 @@ service : { update_ssh_readonly_access_for_all_unassigned_nodes : ( UpdateSshReadOnlyAccessForAllUnassignedNodesPayload ) -> (); + update_standard_engine_replica_version : ( + UpdateStandardEngineReplicaVersionPayload + ) -> (); update_subnet : (UpdateSubnetPayload) -> (); update_subnet_admins : (UpdateSubnetAdminsPayload) -> (); update_unassigned_nodes_config : (UpdateUnassignedNodesConfigPayload) -> (); diff --git a/rs/registry/canister/canister/registry_test.did b/rs/registry/canister/canister/registry_test.did index 2f78294f4b19..22550e34a8fb 100644 --- a/rs/registry/canister/canister/registry_test.did +++ b/rs/registry/canister/canister/registry_test.did @@ -540,6 +540,12 @@ type UpdateUnassignedNodesConfigPayload = record { ssh_readonly_access : opt vec text; }; +type UpdateStandardEngineReplicaVersionPayload = record { + new_replica_version_id : text; + old_replica_version_id : text; + deployment_progress : float64; +}; + type MigrateNodeOperatorPayload = record { new_node_operator_id : opt principal; old_node_operator_id : opt principal; @@ -670,6 +676,9 @@ service : { update_ssh_readonly_access_for_all_unassigned_nodes : ( UpdateSshReadOnlyAccessForAllUnassignedNodesPayload ) -> (); + update_standard_engine_replica_version : ( + UpdateStandardEngineReplicaVersionPayload + ) -> (); update_subnet : (UpdateSubnetPayload) -> (); update_subnet_admins : (UpdateSubnetAdminsPayload) -> (); update_unassigned_nodes_config : (UpdateUnassignedNodesConfigPayload) -> (); diff --git a/rs/registry/canister/src/invariants/checks.rs b/rs/registry/canister/src/invariants/checks.rs index 02d4258c1533..0680be820c96 100644 --- a/rs/registry/canister/src/invariants/checks.rs +++ b/rs/registry/canister/src/invariants/checks.rs @@ -12,6 +12,7 @@ use crate::{ node_record::check_node_record_invariants, replica_version::check_replica_version_invariants, routing_table::{check_canister_migrations_invariants, check_routing_table_invariants}, + standard_engine_replica_version::check_standard_engine_replica_version_invariants, subnet::check_subnet_invariants, unassigned_nodes_config::check_unassigned_nodes_config_invariants, }, @@ -117,6 +118,9 @@ impl Registry { // NodeRecord invariants. result = result.and(check_node_record_invariants(&snapshot)); + // Standard engine replica version invariants + result = result.and(check_standard_engine_replica_version_invariants(&snapshot)); + if let Err(e) = result { panic!( "{}invariant check failed with message: {}", diff --git a/rs/registry/canister/src/invariants/mod.rs b/rs/registry/canister/src/invariants/mod.rs index fad8ad4863e6..187b4054ce8d 100644 --- a/rs/registry/canister/src/invariants/mod.rs +++ b/rs/registry/canister/src/invariants/mod.rs @@ -10,5 +10,6 @@ mod node_operator; mod node_record; mod replica_version; mod routing_table; +mod standard_engine_replica_version; pub(crate) mod subnet; mod unassigned_nodes_config; diff --git a/rs/registry/canister/src/invariants/replica_version.rs b/rs/registry/canister/src/invariants/replica_version.rs index dade5e8223e4..b7b51afcd551 100644 --- a/rs/registry/canister/src/invariants/replica_version.rs +++ b/rs/registry/canister/src/invariants/replica_version.rs @@ -8,11 +8,13 @@ use crate::invariants::common::{ use ic_base_types::SubnetId; use ic_protobuf::registry::{ - replica_version::v1::ReplicaVersionRecord, subnet::v1::SubnetRecord, - unassigned_nodes_config::v1::UnassignedNodesConfigRecord, + replica_version::v1::ReplicaVersionRecord, + standard_engine_replica_version::v1::StandardEngineReplicaVersionRecord, + subnet::v1::SubnetRecord, unassigned_nodes_config::v1::UnassignedNodesConfigRecord, }; use ic_registry_keys::{ - make_replica_version_key, make_subnet_record_key, make_unassigned_nodes_config_record_key, + make_replica_version_key, make_standard_engine_replica_version_record_key, + make_subnet_record_key, make_unassigned_nodes_config_record_key, }; use prost::Message; @@ -40,6 +42,7 @@ pub(crate) fn check_replica_version_invariants( if let Some(version) = unassigned_version_id { versions_in_use.insert(version); } + versions_in_use.append(&mut get_all_standard_engine_replica_versions(snapshot)); versions_in_use.append(&mut get_all_api_boundary_node_versions(snapshot)); let elected_set: BTreeSet<_> = get_all_replica_version_records(snapshot) @@ -104,6 +107,21 @@ fn get_all_api_boundary_node_versions(snapshot: &RegistrySnapshot) -> BTreeSet BTreeSet { + snapshot + .get(make_standard_engine_replica_version_record_key().as_bytes()) + .map(|bytes| { + let record = StandardEngineReplicaVersionRecord::decode(bytes.as_slice()).unwrap(); + [record.new_replica_version_id, record.old_replica_version_id] + }) + .into_iter() + .flatten() + .collect() +} + #[cfg(test)] mod tests { use crate::common::test_helpers::invariant_compliant_registry; @@ -120,19 +138,18 @@ mod tests { const MOCK_HASH: &str = "C0FFEEC0FFEEC0FFEEC0FFEEC0FFEEC0FFEEC0FFEEC0FFEEC0FFEEC0FFEED00D"; const MOCK_URL: &str = "http://release_package.tar.gz"; + // Replica version IDs are git commit IDs (pointing to the source code used + // to build the Replica). + const REPLICA_VERSION_ID_1: &str = "eb3ab997954f2a91db8a42f84132cf37078d481c"; + const REPLICA_VERSION_ID_2: &str = "63d086714a1e2bc6b0615008d5582f527d554cd3"; + fn elect_version_mutations(versions: Vec) -> Vec { versions .into_iter() .map(|v| { - let value = ReplicaVersionRecord { - release_package_sha256_hex: "".to_string(), - release_package_urls: Vec::new(), - guest_launch_measurements: None, - }; - insert( make_replica_version_key(v).as_bytes(), - value.encode_to_vec(), + ReplicaVersionRecord::default().encode_to_vec(), ) }) .collect() @@ -191,6 +208,91 @@ mod tests { registry.check_global_state_invariants(&mutation); } + #[test] + fn test_can_update_standard_engine_replica_version_record() { + // Step 1: Prepare the world. In particular, elect a couple of replica versions. + let mut registry = invariant_compliant_registry(0); + registry.maybe_apply_mutation_internal(elect_version_mutations(vec![ + REPLICA_VERSION_ID_1.to_string(), + REPLICA_VERSION_ID_2.to_string(), + ])); + + // Step 2: Run the code under test. + + // Prepare the mutation that's supposed to succeed. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + old_replica_version_id: REPLICA_VERSION_ID_2.to_string(), + deployment_progress: 0.1, + } + .encode_to_vec(); + + // Attempt the mutation. + let mutation = vec![insert(key.as_bytes(), value)]; + registry.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The implicit assertion here is the previous line did not panic. + } + + #[test] + #[should_panic(expected = "Using a version that isn't elected.")] + fn panic_when_new_replica_version_is_not_elected_in_standard_engine_replica_version_record() { + // Step 1: Prepare the world. Elect old, but not new. + let mut registry = invariant_compliant_registry(0); + registry.maybe_apply_mutation_internal(elect_version_mutations(vec![ + REPLICA_VERSION_ID_1.to_string(), + ])); + + // Step 2: Run the code under test. + + // Prepare mutation. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: "garbage".to_string(), // <- This is the bomb. + old_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + deployment_progress: 0.1, + } + .encode_to_vec(); + + // Attempt mutation. + let mutation = vec![insert(key.as_bytes(), value)]; + registry.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "Using a version that isn't elected.")] + fn panic_when_old_replica_version_is_not_elected_in_standard_engine_replica_version_record() { + // Step 1: Prepare the world. Elect new, but not old. + // (Same initial state as previous test.) + let mut registry = invariant_compliant_registry(0); + registry.maybe_apply_mutation_internal(elect_version_mutations(vec![ + REPLICA_VERSION_ID_1.to_string(), + ])); + + // Step 2: Run the code under test. + + // Prepare mutation. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + old_replica_version_id: "garbage".to_string(), // <- This is the bomb. + deployment_progress: 0.1, + } + .encode_to_vec(); + + // Attempt mutation. + let mutation = vec![insert(key.as_bytes(), value)]; + registry.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + #[test] #[should_panic(expected = "Using a version that isn't elected.")] fn panic_when_retiring_a_version_in_use() { @@ -236,6 +338,39 @@ mod tests { registry.check_global_state_invariants(&mutation); } + #[test] + #[should_panic(expected = "Using a version that isn't elected.")] + fn panic_when_retiring_a_version_referenced_by_standard_engine_record() { + // Step 1: Prepare the world. + + // Step 1.1: Elect two replica versions. + let mut registry = invariant_compliant_registry(0); + registry.maybe_apply_mutation_internal(elect_version_mutations(vec![ + REPLICA_VERSION_ID_1.to_string(), + REPLICA_VERSION_ID_2.to_string(), + ])); + + // Step 1.2: Start upgrading engines from one elected version to the + // other. + registry.maybe_apply_mutation_internal(vec![insert( + make_standard_engine_replica_version_record_key(), + StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_2.to_string(), + old_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + deployment_progress: 0.1, + } + .encode_to_vec(), + )]); + + // Step 2: Run the code under test. Try to un-elect one of the versions + // referenced by the (engine replica version) upgrade. + let mutation = delete(make_replica_version_key(REPLICA_VERSION_ID_2).as_bytes()); + registry.check_global_state_invariants(&[mutation]); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + fn check_replica_version(hash: &str, urls: Vec) { let registry = invariant_compliant_registry(0); diff --git a/rs/registry/canister/src/invariants/standard_engine_replica_version.rs b/rs/registry/canister/src/invariants/standard_engine_replica_version.rs new file mode 100644 index 000000000000..606f8486bc93 --- /dev/null +++ b/rs/registry/canister/src/invariants/standard_engine_replica_version.rs @@ -0,0 +1,161 @@ +use crate::invariants::common::{InvariantCheckError, RegistrySnapshot, get_value_from_snapshot}; +use ic_protobuf::registry::standard_engine_replica_version::v1::StandardEngineReplicaVersionRecord; +use ic_registry_keys::make_standard_engine_replica_version_record_key; + +/// A predicate on the StandardEngineReplicaVersionRecord, if one is present in +/// the registry snapshot. +/// +/// Currently, this checks that: +/// +/// 1. deployment_progress is in the range [0.0, 1.0]. +/// 2. new_replica_version_id and old_replica_version_id are different. +/// +/// It is checked elsewhere that both versions are elected, so that is not +/// repeated here. +pub(crate) fn check_standard_engine_replica_version_invariants( + snapshot: &RegistrySnapshot, +) -> Result<(), InvariantCheckError> { + let Some(record) = get_value_from_snapshot::( + snapshot, + make_standard_engine_replica_version_record_key(), + ) else { + // If there is no record yet, then we are trivially valid. + return Ok(()); + }; + + let StandardEngineReplicaVersionRecord { + new_replica_version_id, + old_replica_version_id, + deployment_progress, + } = record; + + // Inspect deployment_progress. + if !(0.0..=1.0).contains(&deployment_progress) { + return Err(InvariantCheckError { + msg: format!( + "StandardEngineReplicaVersionRecord.deployment_progress must be in the range \ + [0.0, 1.0], got {deployment_progress}.", + ), + source: None, + }); + } + + // Replica versions must be different. + if new_replica_version_id == old_replica_version_id { + return Err(InvariantCheckError { + msg: format!( + "Versions in StandardEngineReplicaVersionRecord must be different. \ + Got {new_replica_version_id} vs {old_replica_version_id}.", + ), + source: None, + }); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{common::test_helpers::invariant_compliant_registry, registry::Registry}; + use ic_protobuf::registry::replica_version::v1::ReplicaVersionRecord; + use ic_registry_keys::make_replica_version_key; + use ic_registry_transport::insert; + use lazy_static::lazy_static; + use prost::Message; + + // Replica version IDs are git commit IDs (pointing to the source code used + // to build the Replica). + const REPLICA_VERSION_ID_1: &str = "eb3ab997954f2a91db8a42f84132cf37078d481c"; + const REPLICA_VERSION_ID_2: &str = "63d086714a1e2bc6b0615008d5582f527d554cd3"; + + lazy_static! { + // Fixture for tests below. + static ref REGISTRY: Registry = { + let mut result = invariant_compliant_registry(0); + + // Elect a couple of replica versions. + let mutations = [ + REPLICA_VERSION_ID_1.to_string(), + REPLICA_VERSION_ID_2.to_string(), + ] + .into_iter() + .map(|v| { + insert( + make_replica_version_key(v).as_bytes(), + ReplicaVersionRecord::default().encode_to_vec(), + ) + }) + .collect(); + result.maybe_apply_mutation_internal(mutations); + + result + }; + } + + #[test] + #[should_panic(expected = "deployment_progress must be in the range [0.0, 1.0]")] + fn panic_when_deployment_progress_too_large() { + // Step 1: Prepare the world. + // Already done above, in the definition of REGISTRY itself. + + // Step 2: Run the code under test. Try to set deployment_progress + // outside of [0.0, 1.0]. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + old_replica_version_id: REPLICA_VERSION_ID_2.to_string(), + deployment_progress: 1.1, + } + .encode_to_vec(); + let mutation = vec![insert(key.as_bytes(), value)]; + REGISTRY.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "deployment_progress must be in the range [0.0, 1.0]")] + fn panic_when_deployment_progress_too_small() { + // Step 1: Prepare the world. + // Already done above, in the definition of REGISTRY itself. + + // Step 2: Run the code under test. Try to set deployment_progress + // outside of [0.0, 1.0]. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + old_replica_version_id: REPLICA_VERSION_ID_2.to_string(), + deployment_progress: -0.1, + } + .encode_to_vec(); + let mutation = vec![insert(key.as_bytes(), value)]; + REGISTRY.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "Versions in StandardEngineReplicaVersionRecord must be different")] + fn panic_when_new_and_old_versions_are_the_same() { + // Step 1: Prepare the world. + // Already done above, in the definition of REGISTRY itself. + + // Step 2: Run the code under test. Try to go from one version to + // itself. + let key = make_standard_engine_replica_version_record_key(); + let value = StandardEngineReplicaVersionRecord { + new_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + old_replica_version_id: REPLICA_VERSION_ID_1.to_string(), + deployment_progress: 0.1, + } + .encode_to_vec(); + let mutation = vec![insert(key.as_bytes(), value)]; + REGISTRY.check_global_state_invariants(&mutation); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } +} diff --git a/rs/registry/canister/src/mutations/do_update_standard_engine_replica_version.rs b/rs/registry/canister/src/mutations/do_update_standard_engine_replica_version.rs new file mode 100644 index 000000000000..1bb2b27162a0 --- /dev/null +++ b/rs/registry/canister/src/mutations/do_update_standard_engine_replica_version.rs @@ -0,0 +1,471 @@ +use crate::{common::LOG_PREFIX, registry::Registry}; + +use candid::{CandidType, Deserialize}; +#[cfg(target_arch = "wasm32")] +use dfn_core::println; +use ic_protobuf::registry::standard_engine_replica_version::v1::StandardEngineReplicaVersionRecord; +use ic_registry_keys::make_standard_engine_replica_version_record_key; +use ic_registry_transport::pb::v1::{RegistryMutation, registry_mutation}; +use prost::Message; +use serde::Serialize; + +/// Changes what replica version(s) are run by engines. +/// +/// (Engines are a special kind of subnet, new as of summer 2026). +/// +/// An engine can override what this says by populating its +/// SubnetRecord.replica_version_id field. In practice, we expect such overrides +/// to be rare. +/// +/// Calling this is how Governance executes corresponding proposals. +/// +/// Two kinds of changes are allowed: +/// +/// 1. Change deployment_progress (usually increase, but decrease is allowed). +/// 2. Start a new deployment. +/// +/// The latter is only allowed if the previous/current deployment is complete +/// (deployment_progress == 1.0, or there is no previous deployment). The new +/// deployment must start where the previous one left off, i.e. the new +/// old_replica_version_id must match the current/old new_replica_version_id. +impl Registry { + pub fn do_update_standard_engine_replica_version( + &mut self, + payload: UpdateStandardEngineReplicaVersionPayload, + ) { + println!("{LOG_PREFIX}do_update_standard_engine_replica_version: {payload:?}"); + + let new_record = StandardEngineReplicaVersionRecord::try_from(payload).unwrap(); + + self.check_new_standard_engine_replica_version_record_vs_old(&new_record); + + let upsert = new_record.into_upsert(); + + // Note that this checks invariants, such as that the new and old + // replica versions are actually elected. + self.maybe_apply_mutation_internal(vec![upsert]); + } + + /// Validates the transition per the rule described on + /// `do_update_standard_engine_replica_version`; panics if new_record isn't one + /// of the two allowed cases. If there is no current record, any new_record is + /// allowed. + fn check_new_standard_engine_replica_version_record_vs_old( + &self, + new_record: &StandardEngineReplicaVersionRecord, + ) { + let Some(old_record) = self.get_standard_engine_replica_version_record() else { + println!( + "{LOG_PREFIX}check_new_standard_engine_replica_version_record_vs_old: no current \ + StandardEngineReplicaVersionRecord found; allowing {new_record:?}", + ); + return; + }; + + // Allow deployment_progress to change (in either direction), as long as + // the version IDs are unchanged. + if new_record.has_same_versions_as(&old_record) { + return; + } + + if old_record.deployment_progress < 1.0 { + panic!( + "Invalid StandardEngineReplicaVersionRecord transition: the current deployment \ + is still in progress, so a new deployment cannot be started yet. Current \ + record: {old_record:?}. Proposed new record: {new_record:?}.", + ); + } + + // Based on deployment_progress, looks like we are starting a new deployment. + // Here, we check the new old_replica_version_id. + assert_eq!( + new_record.old_replica_version_id, old_record.new_replica_version_id, + "Invalid StandardEngineReplicaVersionRecord transition: cannot skip a version. The \ + next deployment's old_replica_version_id must equal the current deployment's \ + new_replica_version_id.", + ); + } + + fn get_standard_engine_replica_version_record( + &self, + ) -> Option { + self.get( + make_standard_engine_replica_version_record_key().as_bytes(), + self.latest_version(), + ) + .map(|v| StandardEngineReplicaVersionRecord::decode(v.value.as_slice()).unwrap()) + } +} + +trait StandardEngineReplicaVersionRecordExt { + /// True if both records specify the same (old_replica_version_id, + /// new_replica_version_id) pair (regardless of deployment_progress). If so, + /// the new record is just adjusting deployment_progress. + fn has_same_versions_as(&self, other: &Self) -> bool; + + /// Converts self into an upsert RegistryMutation (at the standard key). + fn into_upsert(self) -> RegistryMutation; +} + +impl StandardEngineReplicaVersionRecordExt for StandardEngineReplicaVersionRecord { + fn has_same_versions_as(&self, other: &Self) -> bool { + self.old_replica_version_id == other.old_replica_version_id + && self.new_replica_version_id == other.new_replica_version_id + } + + fn into_upsert(self) -> RegistryMutation { + RegistryMutation { + mutation_type: registry_mutation::Type::Upsert as i32, + key: make_standard_engine_replica_version_record_key().into_bytes(), + value: self.encode_to_vec(), + } + } +} + +impl TryFrom for StandardEngineReplicaVersionRecord { + type Error = String; + + fn try_from(payload: UpdateStandardEngineReplicaVersionPayload) -> Result { + let UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id, + old_replica_version_id, + deployment_progress, + } = payload; + + if !(0.0..=1.0).contains(&deployment_progress) { + return Err(format!( + "deployment_progress must be in the range [0.0, 1.0], got {}.", + deployment_progress, + )); + } + + if new_replica_version_id == old_replica_version_id { + return Err(format!( + "new_replica_version_id and old_replica_version_id must be different, but both \ + are '{new_replica_version_id}'.", + )); + } + + Ok(Self { + new_replica_version_id, + old_replica_version_id, + deployment_progress, + }) + } +} + +#[derive(Clone, PartialEq, Debug, CandidType, Deserialize, Serialize)] +pub struct UpdateStandardEngineReplicaVersionPayload { + pub new_replica_version_id: String, + pub old_replica_version_id: String, + pub deployment_progress: f64, + // Later, we could add force, which would override the transition + // restrictions. But for now, there seems to be no point in doing that. +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::common::test_helpers::invariant_compliant_registry; + use ic_protobuf::registry::replica_version::v1::ReplicaVersionRecord; + use ic_registry_keys::make_replica_version_key; + use ic_registry_transport::insert; + use lazy_static::lazy_static; + use prost::Message; + + lazy_static! { + // invariant_compliant_registry(0) + some elected replica versions. + static ref REGISTRY: Registry = { + let mut result = invariant_compliant_registry(0); + + for version in ["v1", "v2", "v3", "v4"] { + result.maybe_apply_mutation_internal(vec![insert( + make_replica_version_key(version), + ReplicaVersionRecord { + release_package_sha256_hex: "".into(), + release_package_urls: vec![], + guest_launch_measurements: None, + } + .encode_to_vec(), + )]); + } + + result + }; + } + + #[track_caller] + fn assert_standard_engine_replica_version_record_eq( + registry: &Registry, + expected: StandardEngineReplicaVersionRecord, + ) { + let actual = registry + .get_standard_engine_replica_version_record() + .expect("failed to get standard engine replica version record"); + assert_eq!(actual, expected); + } + + /// Since there is no previous record, the "transition" requirements are met + /// trivially. Nevertheless, the "static" requirements still need to be met. + /// To wit, progress is in [0.0, 1.0] and the two versions are different and + /// elected. + #[test] + fn should_succeed_when_there_is_no_previous_record() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".to_string(), + old_replica_version_id: "v1".to_string(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + assert_standard_engine_replica_version_record_eq( + ®istry, + StandardEngineReplicaVersionRecord { + new_replica_version_id: "v2".to_string(), + old_replica_version_id: "v1".to_string(), + deployment_progress: 0.1, + }, + ); + } + + #[test] + fn should_allow_changing_deployment_progress_in_either_direction() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".to_string(), + old_replica_version_id: "v1".to_string(), + deployment_progress: 0.1, + }, + ); + + // deployment_progress bounces around wildly. + for deployment_progress in [0.15, 0.9, 0.2, 1.0, 0.0] { + // Step 2: Call the code under test. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress, + }, + ); + + // Step 3: Verify result(s). + + // Step 3.1: The previous statement did not panic, unlike many of + // our tests. + + // Step 3.2: The contents of registry reflects the change we tried + // to make. + assert_standard_engine_replica_version_record_eq( + ®istry, + StandardEngineReplicaVersionRecord { + new_replica_version_id: "v2".to_string(), + old_replica_version_id: "v1".to_string(), + deployment_progress, + }, + ); + } + } + + #[test] + fn should_allow_starting_the_next_deployment_once_fully_deployed() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 1.1: v2 is fully deployed (previously, we were on Replica v1). + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress: 1.0, + }, + ); + + // Step 2: Run the code under test. Start the next deployment: v2 -> v3. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v3".into(), + old_replica_version_id: "v2".into(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + + // Step 3.1: The previous statement did not panic, even though the + // versions changed. This is allowed, because previously, + // deployment_progress was 1.0. Furthermore, we are not "jumping the + // gun"; more precisely, new old_replica_version_id ("v2") matches the + // previous/current new_replica_version_id. + + // Step 3.2: The contents of registry reflects the change we tried to make. + assert_standard_engine_replica_version_record_eq( + ®istry, + StandardEngineReplicaVersionRecord { + new_replica_version_id: "v3".to_string(), + old_replica_version_id: "v2".to_string(), + deployment_progress: 0.1, + }, + ); + } + + #[test] + #[should_panic(expected = "the current deployment is still in progress")] + fn should_panic_when_changing_versions_before_fully_deployed() { + // Step 1: Prepare the world. Partially deploy v1 -> v2 + // (deployment_progress isn't 1.0 yet). + let mut registry = REGISTRY.clone(); + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress: 0.9, + }, + ); + + // Step 2: Run the code under test. Try to upgrade engines from v2 to v3. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v3".into(), + old_replica_version_id: "v2".into(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "cannot skip a version")] + fn should_panic_when_skipping_a_version() { + // Step 1: Prepare the world. Fully deploy v1 -> v2. + let mut registry = REGISTRY.clone(); + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress: 1.0, + }, + ); + + // Step 2: Run the code under test. The new old_replica_version_id + // ("v3") doesn't match the current/previous record's + // new_replica_version_id ("v2"), which is supposed to be not allowed. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v4".into(), + old_replica_version_id: "v3".into(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "Using a version that isn't elected.")] + fn should_panic_if_new_version_not_elected() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. new_replica_version_id is + // deliberately not one of REGISTRY's pre-elected versions. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "unelected_version".to_string(), + old_replica_version_id: "v1".to_string(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "Using a version that isn't elected.")] + fn should_panic_if_old_version_not_elected() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. old_replica_version_id is + // deliberately not one of REGISTRY's pre-elected versions. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".to_string(), + old_replica_version_id: "unelected_version".to_string(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "deployment_progress must be in the range [0.0, 1.0]")] + fn should_panic_if_deployment_progress_is_too_large() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress: 1.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "deployment_progress must be in the range [0.0, 1.0]")] + fn should_panic_if_deployment_progress_is_too_small() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v2".into(), + old_replica_version_id: "v1".into(), + deployment_progress: -0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } + + #[test] + #[should_panic(expected = "must be different")] + fn should_panic_if_new_and_old_versions_are_the_same() { + // Step 1: Prepare the world. + let mut registry = REGISTRY.clone(); + + // Step 2: Run the code under test. + registry.do_update_standard_engine_replica_version( + UpdateStandardEngineReplicaVersionPayload { + new_replica_version_id: "v1".into(), + old_replica_version_id: "v1".into(), + deployment_progress: 0.1, + }, + ); + + // Step 3: Verify result(s). + // The assertion is at the top: #[should_panic(...)]. + } +} diff --git a/rs/registry/canister/src/mutations/mod.rs b/rs/registry/canister/src/mutations/mod.rs index b514a6824e20..846fdbac93cf 100644 --- a/rs/registry/canister/src/mutations/mod.rs +++ b/rs/registry/canister/src/mutations/mod.rs @@ -32,6 +32,7 @@ pub mod do_update_node_operator_config_directly; pub mod do_update_node_rewards_table; pub mod do_update_nodes_hostos_version; pub mod do_update_ssh_readonly_access_for_all_unassigned_nodes; +pub mod do_update_standard_engine_replica_version; pub mod do_update_subnet; pub mod do_update_subnet_admins; pub mod do_update_unassigned_nodes_config; diff --git a/rs/registry/canister/unreleased_changelog.md b/rs/registry/canister/unreleased_changelog.md index 94126a0ff421..82e1d7d2edd5 100644 --- a/rs/registry/canister/unreleased_changelog.md +++ b/rs/registry/canister/unreleased_changelog.md @@ -9,6 +9,11 @@ on the process that this file is part of, see ## Added +* A new method: update_standard_engine_replica_version. As usual, only callable + by Governance, and so far, Governance does not call this, so this code is for + all practical purposes not active yet, but the entry point is visible in + registry.did now. + ## Changed ## Deprecated