diff --git a/.github/workflows/canister-tests.yml b/.github/workflows/canister-tests.yml index 20f757ba31..503f1a9e32 100644 --- a/.github/workflows/canister-tests.yml +++ b/.github/workflows/canister-tests.yml @@ -355,6 +355,7 @@ jobs: # a relatively small price to pay to make sure PRs are always tested against the latest release. curl -sSL https://github.com/dfinity/internet-identity/releases/latest/download/internet_identity_test.wasm -o internet_identity_previous.wasm curl -sSL https://github.com/dfinity/internet-identity/releases/latest/download/archive.wasm -o archive_previous.wasm + curl -sSL https://github.com/dfinity/internet-identity/releases/download/release-2022-12-07/internet_identity_test.wasm -o internet_identity_v3_storage.wasm cargo test --release env: RUST_BACKTRACE: 1 diff --git a/src/canister_tests/src/framework.rs b/src/canister_tests/src/framework.rs index e3a1b24b68..f58199979c 100644 --- a/src/canister_tests/src/framework.rs +++ b/src/canister_tests/src/framework.rs @@ -69,6 +69,21 @@ lazy_static! { get_wasm_path("II_WASM_PREVIOUS".to_string(), &def_path).expect(&err) }; + /** The Wasm module for the last II build that still initializes storage as V3, which is used when testing + * the candid schema migration */ + pub static ref II_WASM_V3_LAYOUT: Vec = { + let def_path = path::PathBuf::from("..").join("..").join("internet_identity_v3_storage.wasm"); + let err = format!(" + Could not find Internet Identity Wasm module for release with v3 storage layout. + + I will look for it at {:?}, and you can specify another path with the environment variable II_WASM_V3_LAYOUT (note that I run from {:?}). + + In order to get the Wasm module, please run the following command: + curl -SL https://github.com/dfinity/internet-identity/releases/download/release-2022-12-07/internet_identity_test.wasm -o internet_identity_v3_storage.wasm + ", &def_path, &std::env::current_dir().map(|x| x.display().to_string()).unwrap_or("an unknown directory".to_string())); + get_wasm_path("II_WASM_V3_LAYOUT".to_string(), &def_path).expect(&err) + }; + /** The Wasm module for the _previous_ archive build, or latest release, which is used when testing * upgrades and downgrades */ pub static ref ARCHIVE_WASM_PREVIOUS: Vec = { @@ -144,6 +159,7 @@ pub fn arg_with_wasm_hash(wasm: Vec) -> Option assigned_user_number_range: None, archive_module_hash: Some(archive_wasm_hash(&wasm)), canister_creation_cycles_cost: Some(0), + layout_migration_batch_size: None, }) } diff --git a/src/internet_identity/internet_identity.did b/src/internet_identity/internet_identity.did index 516a013523..a27910ff2d 100644 --- a/src/internet_identity/internet_identity.did +++ b/src/internet_identity/internet_identity.did @@ -129,6 +129,16 @@ type GetDelegationResponse = variant { no_such_delegation }; +type LayoutMigrationState = variant { + not_started; + started: record { + // number of anchors that still need migration. Once it reaches 0, the migration is finished. + anchors_left: nat64; + batch_size: nat64; + }; + finished; +}; + type InternetIdentityStats = record { users_registered: nat64; storage_layout_version: nat8; @@ -138,6 +148,8 @@ type InternetIdentityStats = record { }; archive_info: ArchiveInfo; canister_creation_cycles_cost: nat64; + // this is opt so that we can remove the migration state after the migration without breaking any clients + layout_migration_state: opt LayoutMigrationState; }; // Information about the archive. @@ -165,6 +177,9 @@ type InternetIdentityInit = record { // The canister creation cost on mainnet is currently 100'000'000'000 cycles. If this value is higher thant the // canister creation cost, the newly created canister will keep extra cycles. canister_creation_cycles_cost : opt nat64; + // Migration batch size for the anchor layout migration. + // If set to 0, migration is disabled (if the migration was already started, it will be paused in the current state). + layout_migration_batch_size: opt nat32; }; type ChallengeKey = text; diff --git a/src/internet_identity/src/main.rs b/src/internet_identity/src/main.rs index 07358df656..73e7ec8cd9 100644 --- a/src/internet_identity/src/main.rs +++ b/src/internet_identity/src/main.rs @@ -167,6 +167,7 @@ fn stats() -> InternetIdentityStats { archive_info, canister_creation_cycles_cost, storage_layout_version: storage.version(), + layout_migration_state: Some(storage.migration_state()), }) } @@ -233,6 +234,11 @@ fn post_upgrade(maybe_arg: Option) { persistent_state.canister_creation_cycles_cost = cost; }) } + if let Some(batch_size) = arg.layout_migration_batch_size { + state::storage_mut(|storage| { + storage.configure_migration(batch_size); + }) + } } } diff --git a/src/internet_identity/src/storage.rs b/src/internet_identity/src/storage.rs index a2c0b76a6c..e0149032dd 100644 --- a/src/internet_identity/src/storage.rs +++ b/src/internet_identity/src/storage.rs @@ -72,8 +72,9 @@ use ic_stable_structures::reader::{BufferedReader, OutOfBounds, Reader}; use ic_stable_structures::writer::{BufferedWriter, Writer}; use ic_stable_structures::Memory; use internet_identity_interface::{ - CredentialId, DeviceKey, DeviceProtection, KeyType, Purpose, UserNumber, + CredentialId, DeviceKey, DeviceProtection, KeyType, MigrationState, Purpose, UserNumber, }; +use std::cmp::min; use std::convert::TryInto; use std::fmt; use std::io::{Read, Write}; @@ -84,8 +85,10 @@ mod tests; // version 0: invalid // version 1-2: no longer supported -// version 3: 4KB anchors layout (current) -// version 4+: invalid +// version 3: 4KB anchors layout (current), vec layout +// version 4: migration from vec to anchor record in progress +// version 5: candid anchor record layout +// version 6+: invalid const SUPPORTED_LAYOUT_VERSIONS: RangeInclusive = 3..=5; const WASM_PAGE_SIZE: u64 = 65_536; @@ -161,8 +164,10 @@ struct Header { magic: [u8; 3], // version 0: invalid // version 1-2: no longer supported - // version 3: 4KB anchors layout (current) - // version 4+: invalid + // version 3: 4KB anchors layout (current), vec layout + // version 4: migration from vec to anchor record in progress + // version 5: candid anchor record layout + // version 6+: invalid version: u8, num_users: u32, id_range_lo: u64, @@ -170,6 +175,8 @@ struct Header { entry_size: u16, salt: [u8; 32], first_entry_offset: u64, + new_layout_start: u32, // record number of the first entry using the new candid layout + migration_batch_size: u32, } impl Storage { @@ -193,13 +200,15 @@ impl Storage { Self { header: Header { magic: *b"IIC", - version: 3, + version: 5, num_users: 0, id_range_lo, id_range_hi, entry_size: DEFAULT_ENTRY_SIZE, salt: EMPTY_SALT, first_entry_offset: ENTRY_OFFSET, + new_layout_start: 0, + migration_batch_size: 0, }, memory, } @@ -275,14 +284,24 @@ impl Storage { /// Writes the data of the specified user to stable memory. pub fn write(&mut self, user_number: UserNumber, data: Anchor) -> Result<(), StorageError> { let record_number = self.user_number_to_record(user_number)?; - let buf = candid::encode_one( - data.devices - .into_iter() - .map(|d| DeviceDataInternal::from(d)) - .collect::>(), - ) - .map_err(StorageError::SerializationError)?; + + let buf = if self.header.version > 3 && record_number >= self.header.new_layout_start { + // use the new candid layout + candid::encode_one(data).map_err(StorageError::SerializationError)? + } else { + // use the old candid layout + candid::encode_one( + data.devices + .into_iter() + .map(|d| DeviceDataInternal::from(d)) + .collect::>(), + ) + .map_err(StorageError::SerializationError)? + }; self.write_entry_bytes(record_number, buf)?; + + // piggy back on update call and migrate a batch of anchors + self.migrate_record_batch()?; Ok(()) } @@ -309,11 +328,20 @@ impl Storage { pub fn read(&self, user_number: UserNumber) -> Result { let record_number = self.user_number_to_record(user_number)?; let data_buf = self.read_entry_bytes(record_number); - let devices: Vec = - candid::decode_one(&data_buf).map_err(StorageError::DeserializationError)?; - Ok(Anchor { - devices: devices.into_iter().map(|d| Device::from(d)).collect(), - }) + + let anchor = if self.header.version > 3 && record_number >= self.header.new_layout_start { + // use the new candid layout + candid::decode_one(&data_buf).map_err(StorageError::DeserializationError)? + } else { + // use the old candid layout + let devices: Vec = + candid::decode_one(&data_buf).map_err(StorageError::DeserializationError)?; + Anchor { + devices: devices.into_iter().map(|d| Device::from(d)).collect(), + } + }; + + Ok(anchor) } fn read_entry_bytes(&self, record_number: u32) -> Vec { @@ -511,6 +539,96 @@ impl Storage { pub fn version(&self) -> u8 { self.header.version } + + pub fn migration_state(&self) -> MigrationState { + match self.header.version { + 3 => MigrationState::NotStarted, + 4 => MigrationState::Started { + anchors_left: self.header.new_layout_start as u64, + batch_size: self.header.migration_batch_size as u64, + }, + 5 => MigrationState::Finished, + version => trap(&format!("unexpected header version: {}", version)), + } + } + + pub fn configure_migration(&mut self, migration_batch_size: u32) { + if self.header.version == 5 { + // migration is already done, nothing to do + return; + } + + if self.header.version == 3 { + // only initialize this on the first migration configuration + self.header.version = 4; + self.header.new_layout_start = self.header.num_users; + } + + self.header.migration_batch_size = migration_batch_size; + self.flush(); + } + + fn migrate_record_batch(&mut self) -> Result<(), StorageError> { + if self.header.version == 3 || self.header.version == 5 { + // migration is not started or already done, nothing to do + return Ok(()); + } + + for _ in 0..min( + self.header.new_layout_start, + self.header.migration_batch_size, + ) { + self.migrate_next_record()?; + } + + if self.header.new_layout_start == 0 { + self.finalize_migration(); + } + self.flush(); + Ok(()) + } + + fn migrate_next_record(&mut self) -> Result<(), StorageError> { + let record_number = self.header.new_layout_start - 1; + let old_schema_bytes = self.read_entry_bytes(record_number); + + if old_schema_bytes.len() == 0 { + // This anchor was only allocated but never written + // --> nothing to migrate just update pointer. + self.header.new_layout_start -= 1; + return Ok(()); + } + + let devices: Vec = + candid::decode_one(&old_schema_bytes).map_err(StorageError::DeserializationError)?; + let anchor = Anchor { + devices: devices.into_iter().map(|d| Device::from(d)).collect(), + }; + + let new_schema_bytes = + candid::encode_one(anchor).map_err(StorageError::SerializationError)?; + self.write_entry_bytes(record_number, new_schema_bytes)?; + self.header.new_layout_start -= 1; + Ok(()) + } + + fn finalize_migration(&mut self) { + if self.header.version == 5 { + // migration is already done, nothing to do + return; + } + + assert_eq!( + self.header.version, 4, + "unexpected header version during migration finalization {}", + self.header.version + ); + assert_eq!({ self.header.new_layout_start }, 0, "cannot finalize migration when not all anchors were migrated! Remaining anchors to migrate: {}", { self.header.new_layout_start }); + + self.header.version = 5; + // clear now unused header field (new_layout_start is already 0) + self.header.migration_batch_size = 0; + } } #[derive(Debug)] diff --git a/src/internet_identity/src/storage/tests.rs b/src/internet_identity/src/storage/tests.rs index f420e2d450..033f7b6ac6 100644 --- a/src/internet_identity/src/storage/tests.rs +++ b/src/internet_identity/src/storage/tests.rs @@ -4,11 +4,11 @@ use crate::storage::{DeviceDataInternal, Header, PersistentStateError, StorageEr use crate::Storage; use candid::Principal; use ic_stable_structures::{Memory, VectorMemory}; -use internet_identity_interface::{DeviceProtection, KeyType, Purpose}; +use internet_identity_interface::{DeviceProtection, KeyType, MigrationState, Purpose, UserNumber}; use serde_bytes::ByteBuf; const WASM_PAGE_SIZE: u64 = 1 << 16; -const HEADER_SIZE: usize = 66; +const HEADER_SIZE: usize = 74; const RESERVED_HEADER_BYTES: u64 = 2 * WASM_PAGE_SIZE; const PERSISTENT_STATE_MAGIC: [u8; 4] = *b"IIPS"; @@ -26,7 +26,19 @@ fn should_report_max_number_of_entries_for_32gb() { } #[test] -fn should_serialize_header() { +fn should_serialize_header_v3() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((1, 2), memory.clone()); + storage.update_salt([05u8; 32]); + storage.flush(); + + let mut buf = vec![0; HEADER_SIZE]; + memory.read(0, &mut buf); + assert_eq!(buf, hex::decode("4949430300000000010000000000000002000000000000000010050505050505050505050505050505050505050505050505050505050505050500000200000000000000000000000000").unwrap()); +} + +#[test] +fn should_serialize_header_v5() { let memory = VectorMemory::default(); let mut storage = Storage::new((1, 2), memory.clone()); storage.update_salt([05u8; 32]); @@ -34,7 +46,7 @@ fn should_serialize_header() { let mut buf = vec![0; HEADER_SIZE]; memory.read(0, &mut buf); - assert_eq!(buf, hex::decode("494943030000000001000000000000000200000000000000001005050505050505050505050505050505050505050505050505050505050505050000020000000000").unwrap()); + assert_eq!(buf, hex::decode("4949430500000000010000000000000002000000000000000010050505050505050505050505050505050505050505050505050505050505050500000200000000000000000000000000").unwrap()); } #[test] @@ -73,10 +85,36 @@ fn should_enforce_size_limit_for_devices() { } #[test] -fn should_serialize_first_record() { +fn should_read_previous_write_v3() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((12345, 678910), memory.clone()); + let user_number = storage.allocate_user_number().unwrap(); + + let anchor = sample_anchor_record(); + storage.write(user_number, anchor.clone()).unwrap(); + + let read_anchor = storage.read(user_number).unwrap(); + assert_eq!(anchor, read_anchor); +} + +#[test] +fn should_read_previous_write_v5() { + let memory = VectorMemory::default(); + let mut storage = Storage::new((12345, 678910), memory.clone()); + let user_number = storage.allocate_user_number().unwrap(); + + let anchor = sample_anchor_record(); + storage.write(user_number, anchor.clone()).unwrap(); + + let read_anchor = storage.read(user_number).unwrap(); + assert_eq!(anchor, read_anchor); +} + +#[test] +fn should_serialize_first_record_v3() { const EXPECTED_LENGTH: usize = 192; let memory = VectorMemory::default(); - let mut storage = Storage::new((123, 456), memory.clone()); + let mut storage = layout_v3_storage((123, 456), memory.clone()); let user_number = storage.allocate_user_number().unwrap(); assert_eq!(user_number, 123u64); @@ -94,11 +132,28 @@ fn should_serialize_first_record() { } #[test] -fn should_serialize_subsequent_record_to_expected_memory_location() { +fn should_serialize_first_record_v5() { + const EXPECTED_LENGTH: usize = 191; + let memory = VectorMemory::default(); + let mut storage = Storage::new((123, 456), memory.clone()); + let user_number = storage.allocate_user_number().unwrap(); + assert_eq!(user_number, 123u64); + + let anchor = sample_anchor_record(); + storage.write(user_number, anchor.clone()).unwrap(); + + let mut buf = [0u8; EXPECTED_LENGTH]; + memory.read(RESERVED_HEADER_BYTES, &mut buf); + let decoded_from_memory: Anchor = candid::decode_one(&buf[2..]).unwrap(); + assert_eq!(decoded_from_memory, anchor); +} + +#[test] +fn should_serialize_subsequent_record_to_expected_memory_location_v3() { const EXPECTED_LENGTH: usize = 192; const EXPECTED_RECORD_OFFSET: u64 = 409_600; // 100 * max anchor size let memory = VectorMemory::default(); - let mut storage = Storage::new((123, 456), memory.clone()); + let mut storage = layout_v3_storage((123, 456), memory.clone()); for _ in 0..100 { storage.allocate_user_number().unwrap(); } @@ -118,6 +173,27 @@ fn should_serialize_subsequent_record_to_expected_memory_location() { assert_eq!(decoded_from_memory, anchor.devices); } +#[test] +fn should_serialize_subsequent_record_to_expected_memory_location_v5() { + const EXPECTED_LENGTH: usize = 191; + const EXPECTED_RECORD_OFFSET: u64 = 409_600; // 100 * max anchor size + let memory = VectorMemory::default(); + let mut storage = Storage::new((123, 456), memory.clone()); + for _ in 0..100 { + storage.allocate_user_number().unwrap(); + } + let user_number = storage.allocate_user_number().unwrap(); + assert_eq!(user_number, 223u64); + + let anchor = sample_anchor_record(); + storage.write(user_number, anchor.clone()).unwrap(); + + let mut buf = [0u8; EXPECTED_LENGTH]; + memory.read(RESERVED_HEADER_BYTES + EXPECTED_RECORD_OFFSET, &mut buf); + let decoded_from_memory: Anchor = candid::decode_one(&buf[2..]).unwrap(); + assert_eq!(decoded_from_memory, anchor); +} + #[test] fn should_not_write_using_anchor_number_outside_allocated_range() { let memory = VectorMemory::default(); @@ -129,10 +205,10 @@ fn should_not_write_using_anchor_number_outside_allocated_range() { } #[test] -fn should_deserialize_first_record() { +fn should_deserialize_first_record_v3() { let memory = VectorMemory::default(); memory.grow(3); - let mut storage = Storage::new((123, 456), memory.clone()); + let mut storage = layout_v3_storage((123, 456), memory.clone()); let user_number = storage.allocate_user_number().unwrap(); assert_eq!(user_number, 123u64); @@ -146,11 +222,28 @@ fn should_deserialize_first_record() { } #[test] -fn should_deserialize_subsequent_record_at_expected_memory_location() { +fn should_deserialize_first_record_v5() { + let memory = VectorMemory::default(); + memory.grow(3); + let mut storage = Storage::new((123, 456), memory.clone()); + let user_number = storage.allocate_user_number().unwrap(); + assert_eq!(user_number, 123u64); + + let anchor = sample_anchor_record(); + let buf = candid::encode_one(&anchor).unwrap(); + memory.write(RESERVED_HEADER_BYTES, &(buf.len() as u16).to_le_bytes()); + memory.write(RESERVED_HEADER_BYTES + 2, &buf); + + let read_from_storage = storage.read(123).unwrap(); + assert_eq!(read_from_storage, anchor); +} + +#[test] +fn should_deserialize_subsequent_record_at_expected_memory_location_v3() { const EXPECTED_RECORD_OFFSET: u64 = 409_600; // 100 * max anchor size let memory = VectorMemory::default(); memory.grow(9); // grow memory to accommodate a write to record 100 - let mut storage = Storage::new((123, 456), memory.clone()); + let mut storage = layout_v3_storage((123, 456), memory.clone()); for _ in 0..100 { storage.allocate_user_number().unwrap(); } @@ -169,6 +262,30 @@ fn should_deserialize_subsequent_record_at_expected_memory_location() { assert_eq!(read_from_storage, anchor); } +#[test] +fn should_deserialize_subsequent_record_at_expected_memory_location_v5() { + const EXPECTED_RECORD_OFFSET: u64 = 409_600; // 100 * max anchor size + let memory = VectorMemory::default(); + memory.grow(9); // grow memory to accommodate a write to record 100 + let mut storage = Storage::new((123, 456), memory.clone()); + for _ in 0..100 { + storage.allocate_user_number().unwrap(); + } + let user_number = storage.allocate_user_number().unwrap(); + assert_eq!(user_number, 223u64); + + let anchor = sample_anchor_record(); + let buf = candid::encode_one(&anchor).unwrap(); + memory.write( + RESERVED_HEADER_BYTES + EXPECTED_RECORD_OFFSET, + &(buf.len() as u16).to_le_bytes(), + ); + memory.write(RESERVED_HEADER_BYTES + 2 + EXPECTED_RECORD_OFFSET, &buf); + + let read_from_storage = storage.read(223).unwrap(); + assert_eq!(read_from_storage, anchor); +} + #[test] fn should_not_read_using_anchor_number_outside_allocated_range() { let memory = VectorMemory::default(); @@ -335,6 +452,193 @@ fn should_read_previously_stored_persistent_state() { ); } +#[test] +fn should_stay_in_v3_if_no_migration_configured() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + assert!(matches!( + storage.migration_state(), + MigrationState::NotStarted + )); +} + +#[test] +fn should_start_migration_when_configuring() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + storage.configure_migration(100); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 32, + batch_size: 100 + } + ); +} + +#[test] +fn should_migrate_anchors() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + storage.configure_migration(100); + storage.write(10_000, sample_anchor_record()).unwrap(); + + assert_eq!(storage.migration_state(), MigrationState::Finished); +} + +#[test] +fn should_load_anchors_from_memory_after_migration() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + // write some data to check for migration + let mut test_anchor = sample_anchor_record(); + test_anchor.devices.push(Device { + pubkey: ByteBuf::from("recovery pub key"), + alias: "my protected recovery phrase".to_string(), + credential_id: None, + purpose: Purpose::Recovery, + key_type: KeyType::SeedPhrase, + protection: DeviceProtection::Protected, + }); + storage.write(10_001, test_anchor.clone()).unwrap(); + + storage.configure_migration(100); + // write data to trigger migration + storage.write(10_000, sample_anchor_record()).unwrap(); + + assert_eq!(storage.migration_state(), MigrationState::Finished); + + let storage = Storage::from_memory(memory.clone()).unwrap(); + assert_eq!(storage.version(), 5); + assert_eq!(storage.read(10_001).unwrap(), test_anchor); +} + +#[test] +fn should_pause_migration_with_batch_size_0() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + storage.configure_migration(5); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 32, + batch_size: 5 + } + ); + + storage.write(10_000, sample_anchor_record()).unwrap(); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 5 + } + ); + + storage.configure_migration(0); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 0 + } + ); + + storage.write(10_000, sample_anchor_record()).unwrap(); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 0 + } + ); +} + +#[test] +fn should_recover_migration_state_from_memory() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + storage.configure_migration(5); + storage.write(10_000, sample_anchor_record()).unwrap(); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 5 + } + ); + + let storage = Storage::from_memory(memory.clone()).unwrap(); + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 5 + } + ); +} + +#[test] +fn should_restart_paused_migration() { + let memory = VectorMemory::default(); + let mut storage = layout_v3_storage((10_000, 3_784_873), memory.clone()); + storage.flush(); + for _ in 0..32 { + storage.allocate_user_number(); + } + + storage.configure_migration(5); + storage.write(10_000, sample_anchor_record()).unwrap(); + storage.configure_migration(0); + + assert_eq!( + storage.migration_state(), + MigrationState::Started { + anchors_left: 27, + batch_size: 0 + } + ); + + storage.configure_migration(100); + storage.write(10_000, sample_anchor_record()).unwrap(); + assert_eq!(storage.migration_state(), MigrationState::Finished); +} + fn sample_anchor_record() -> Anchor { Anchor { devices: vec![Device { @@ -361,3 +665,13 @@ fn sample_persistent_state() -> PersistentState { }; persistent_state } + +fn layout_v3_storage( + anchor_range: (UserNumber, UserNumber), + memory: VectorMemory, +) -> Storage { + let mut storage = Storage::new(anchor_range, memory.clone()); + storage.flush(); + memory.write(3, &[3u8]); // fix version + Storage::from_memory(memory.clone()).unwrap() +} diff --git a/src/internet_identity/stable_memory/genesis-layout-migrated-to-v5.bin.gz b/src/internet_identity/stable_memory/genesis-layout-migrated-to-v5.bin.gz new file mode 100644 index 0000000000..4505fcec9b Binary files /dev/null and b/src/internet_identity/stable_memory/genesis-layout-migrated-to-v5.bin.gz differ diff --git a/src/internet_identity/tests/archive_integration_tests.rs b/src/internet_identity/tests/archive_integration_tests.rs index 903b6b0d3a..1f858ed0ee 100644 --- a/src/internet_identity/tests/archive_integration_tests.rs +++ b/src/internet_identity/tests/archive_integration_tests.rs @@ -37,6 +37,7 @@ fn should_deploy_archive_with_cycles() -> Result<(), CallError> { assigned_user_number_range: None, archive_module_hash: Some(archive_wasm_hash(&ARCHIVE_WASM)), canister_creation_cycles_cost: Some(100_000_000_000), // current cost in application subnets + layout_migration_batch_size: None, }), ); env.add_cycles(ii_canister, 150_000_000_000); diff --git a/src/internet_identity/tests/schema_migration_tests.rs b/src/internet_identity/tests/schema_migration_tests.rs new file mode 100644 index 0000000000..82c7446952 --- /dev/null +++ b/src/internet_identity/tests/schema_migration_tests.rs @@ -0,0 +1,409 @@ +use canister_tests::api::internet_identity as api; +use canister_tests::flows; +use canister_tests::framework::*; +use ic_state_machine_tests::StateMachine; +use internet_identity_interface::*; +use serde_bytes::ByteBuf; + +#[test] +fn should_migrate_anchors() -> Result<(), CallError> { + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, II_WASM_V3_LAYOUT.clone()); + + assert_eq!(api::stats(&env, canister_id)?.storage_layout_version, 3); + + for _ in 0..10 { + flows::register_anchor(&env, canister_id); + } + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(5), + }), + ) + .unwrap(); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 10, + MigrationState::Started { + anchors_left: 10, + batch_size: 5 + } + ) + ); + + // register anchor to trigger migration batch + flows::register_anchor(&env, canister_id); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 11, + MigrationState::Started { + anchors_left: 5, + batch_size: 5 + } + ) + ); + + // register anchor to trigger migration batch + flows::register_anchor(&env, canister_id); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state(12, MigrationState::Finished) + ); + + Ok(()) +} + +#[test] +fn should_keep_anchors_intact_when_migrating() -> Result<(), CallError> { + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, II_WASM_V3_LAYOUT.clone()); + + let anchor0 = flows::register_anchor(&env, canister_id); + let device_anchor0 = DeviceData { + pubkey: ByteBuf::from("custom device key of anchor 0"), + alias: "device of the very first anchor".to_string(), + credential_id: None, + purpose: Purpose::Authentication, + key_type: KeyType::Unknown, + protection: DeviceProtection::Unprotected, + }; + api::add( + &env, + canister_id, + principal_1(), + anchor0, + device_anchor0.clone(), + )?; + let anchor0_devices = vec![device_data_1(), device_anchor0.clone()]; + assert_devices_equal(&env, canister_id, anchor0, anchor0_devices.clone()); + + // Generate dummy anchors to guarantee that the anchors we care about in this test span multiple + // wasm pages + for _ in 0..13 { + flows::register_anchor(&env, canister_id); + } + let default_anchor = flows::register_anchor(&env, canister_id); + + let anchor1 = flows::register_anchor_with(&env, canister_id, principal_2(), &device_data_2()); + let device_anchor1 = DeviceData { + pubkey: ByteBuf::from("custom device key"), + alias: "originally old layout anchor device".to_string(), + credential_id: None, + purpose: Purpose::Authentication, + key_type: KeyType::Unknown, + protection: DeviceProtection::Unprotected, + }; + api::add( + &env, + canister_id, + principal_2(), + anchor1, + device_anchor1.clone(), + )?; + let anchor1_devices = vec![device_data_2(), device_anchor1]; + assert_devices_equal(&env, canister_id, anchor1, anchor1_devices.clone()); + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(1), + }), + ) + .unwrap(); + + // migration activated, but no anchor migrated yet + assert_devices_equal(&env, canister_id, anchor1, anchor1_devices.clone()); + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 16, + MigrationState::Started { + anchors_left: 16, + batch_size: 1 + } + ) + ); + + let anchor2 = flows::register_anchor(&env, canister_id); + let device_anchor2 = DeviceData { + pubkey: ByteBuf::from("custom device key 2"), + alias: "originally new layout anchor device".to_string(), + credential_id: None, + purpose: Purpose::Recovery, + key_type: KeyType::SeedPhrase, + protection: DeviceProtection::Protected, + }; + api::add( + &env, + canister_id, + principal_1(), + anchor2, + device_anchor2.clone(), + )?; + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 17, + MigrationState::Started { + anchors_left: 14, + batch_size: 1 + } + ) + ); + + // anchor1 has now been migrated by registering anchor2 and adding a device + assert_devices_equal(&env, canister_id, anchor1, anchor1_devices.clone()); + + // anchor0 has not yet been migrated + assert_devices_equal(&env, canister_id, anchor0, anchor0_devices.clone()); + + // anchor2 is in the new layout from the beginning + let anchor2_devices = vec![device_data_1(), device_anchor2]; + assert_devices_equal(&env, canister_id, anchor2, anchor2_devices.clone()); + + // upgrade again to increase migration speed + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(100), + }), + ) + .unwrap(); + + let device_anchor0_2 = DeviceData { + pubkey: ByteBuf::from("custom device key of anchor 0, 2"), + alias: "device added to the very first anchor during migration".to_string(), + credential_id: None, + purpose: Purpose::Authentication, + key_type: KeyType::Unknown, + protection: DeviceProtection::Unprotected, + }; + api::add( + &env, + canister_id, + principal_1(), + anchor0, + device_anchor0_2.clone(), + )?; + + // adding the device has completed the migration + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state(17, MigrationState::Finished) + ); + + // check all anchors again + assert_devices_equal(&env, canister_id, default_anchor, vec![device_data_1()]); + assert_devices_equal( + &env, + canister_id, + anchor0, + vec![device_data_1(), device_anchor0, device_anchor0_2], + ); + assert_devices_equal(&env, canister_id, anchor1, anchor1_devices); + assert_devices_equal(&env, canister_id, anchor2, anchor2_devices); + + Ok(()) +} + +#[test] +fn should_upgrade_during_migration() -> Result<(), CallError> { + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, II_WASM_V3_LAYOUT.clone()); + + for _ in 0..10 { + flows::register_anchor(&env, canister_id); + } + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(5), + }), + ) + .unwrap(); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 10, + MigrationState::Started { + anchors_left: 10, + batch_size: 5 + } + ) + ); + + // register anchor to trigger migration batch + flows::register_anchor(&env, canister_id); + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(5), + }), + ) + .unwrap(); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 11, + MigrationState::Started { + anchors_left: 5, + batch_size: 5 + } + ) + ); + Ok(()) +} + +#[test] +fn should_start_and_pause_migration() -> Result<(), CallError> { + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, II_WASM_V3_LAYOUT.clone()); + + for _ in 0..10 { + flows::register_anchor(&env, canister_id); + } + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(5), + }), + ) + .unwrap(); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 10, + MigrationState::Started { + anchors_left: 10, + batch_size: 5 + } + ) + ); + + upgrade_ii_canister_with_arg( + &env, + canister_id, + II_WASM.clone(), + Some(InternetIdentityInit { + assigned_user_number_range: None, + archive_module_hash: None, + canister_creation_cycles_cost: None, + layout_migration_batch_size: Some(0), + }), + ) + .unwrap(); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 10, + MigrationState::Started { + anchors_left: 10, + batch_size: 0 + } + ) + ); + + flows::register_anchor(&env, canister_id); + + // make sure it did not make further migrations + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state( + 11, + MigrationState::Started { + anchors_left: 10, + batch_size: 0 + } + ) + ); + + Ok(()) +} + +#[test] +fn should_not_migrate_anchors_if_not_configured() -> Result<(), CallError> { + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, II_WASM_V3_LAYOUT.clone()); + + for _ in 0..10 { + flows::register_anchor(&env, canister_id); + } + + upgrade_ii_canister(&env, canister_id, II_WASM.clone()); + + flows::register_anchor(&env, canister_id); + + assert_eq!( + api::stats(&env, canister_id)?, + stats_with_migration_state(11, MigrationState::NotStarted) + ); + Ok(()) +} + +fn stats_with_migration_state( + num_users: u64, + migration_state: MigrationState, +) -> InternetIdentityStats { + let storage_layout_version = match migration_state { + MigrationState::NotStarted => 3, + MigrationState::Started { .. } => 4, + MigrationState::Finished => 5, + }; + + InternetIdentityStats { + assigned_user_number_range: (10000, 3784873), + users_registered: num_users, + archive_info: ArchiveInfo { + archive_canister: None, + expected_wasm_hash: None, + }, + canister_creation_cycles_cost: 0, + storage_layout_version, + layout_migration_state: Some(migration_state), + } +} diff --git a/src/internet_identity/tests/tests.rs b/src/internet_identity/tests/tests.rs index 1004921866..34bf1f91da 100644 --- a/src/internet_identity/tests/tests.rs +++ b/src/internet_identity/tests/tests.rs @@ -61,6 +61,7 @@ mod upgrade_tests { assigned_user_number_range: Some((2000, 4000)), archive_module_hash: None, canister_creation_cycles_cost: None, + layout_migration_batch_size: None, }), ); @@ -88,6 +89,7 @@ mod upgrade_tests { assigned_user_number_range: Some(stats.assigned_user_number_range), archive_module_hash: None, canister_creation_cycles_cost: None, + layout_migration_batch_size: None, }), ); @@ -238,6 +240,7 @@ mod registration_tests { assigned_user_number_range: Some((127, 129)), archive_module_hash: None, canister_creation_cycles_cost: None, + layout_migration_batch_size: None, }), ); @@ -490,7 +493,38 @@ mod stable_memory_tests { let devices = api::lookup(&env, canister_id, 10_030)?; assert_eq!(devices, vec![device5, device6]); + Ok(()) + } + + /// Tests that some known anchors with their respective devices are available after stable memory restore. + /// Uses the same data initially created using the genesis layout and then later migrated to v3 and then to v5. + #[test] + fn should_load_genesis_migrated_to_v5_backup() -> Result<(), CallError> { + let (device1, device2, device3, device4, device5, device6) = known_devices(); + + let env = StateMachine::new(); + let canister_id = install_ii_canister(&env, EMPTY_WASM.clone()); + + restore_compressed_stable_memory( + &env, + canister_id, + "stable_memory/genesis-layout-migrated-to-v5.bin.gz", + ); + upgrade_ii_canister(&env, canister_id, II_WASM.clone()); + + // check known anchors in the backup + let devices = api::lookup(&env, canister_id, 10_000)?; + assert_eq!(devices, vec![device1]); + + let mut devices = api::lookup(&env, canister_id, 10_002)?; + devices.sort_by(|a, b| a.pubkey.cmp(&b.pubkey)); + assert_eq!(devices, vec![device2, device3]); + let devices = api::lookup(&env, canister_id, 10_029)?; + assert_eq!(devices, vec![device4]); + + let devices = api::lookup(&env, canister_id, 10_030)?; + assert_eq!(devices, vec![device5, device6]); Ok(()) } diff --git a/src/internet_identity_interface/src/lib.rs b/src/internet_identity_interface/src/lib.rs index b04fcf84f8..5d6ffd53b8 100644 --- a/src/internet_identity_interface/src/lib.rs +++ b/src/internet_identity_interface/src/lib.rs @@ -175,6 +175,7 @@ pub struct InternetIdentityInit { pub assigned_user_number_range: Option<(UserNumber, UserNumber)>, pub archive_module_hash: Option<[u8; 32]>, pub canister_creation_cycles_cost: Option, + pub layout_migration_batch_size: Option, } #[derive(Clone, Debug, CandidType, Deserialize, Eq, PartialEq)] @@ -194,6 +195,7 @@ pub struct InternetIdentityStats { pub archive_info: ArchiveInfo, pub canister_creation_cycles_cost: u64, pub storage_layout_version: u8, + pub layout_migration_state: Option, } // Archive specific types