diff --git a/Cargo.lock b/Cargo.lock index db42dae..254d669 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1784,6 +1784,7 @@ dependencies = [ "once_cell", "oslog", "pipe", + "rand 0.8.5", "reqwest", "ring", "serde", diff --git a/library/Cargo.toml b/library/Cargo.toml index 5c43946..eaf9400 100644 --- a/library/Cargo.toml +++ b/library/Cargo.toml @@ -36,6 +36,8 @@ once_cell = "1.17.1" # Json serialization/de-serialization. # Pipe is a simple in-memory pipe implementation, there might be a std way too? pipe = "0.4.0" +# Used to determine patch rollout group for device +rand = "0.8.5" # Used for networking. reqwest = { version = "0.11", default-features = false, features = [ "blocking", diff --git a/library/src/cache/updater_state.rs b/library/src/cache/updater_state.rs index 459b33c..c6bef21 100644 --- a/library/src/cache/updater_state.rs +++ b/library/src/cache/updater_state.rs @@ -8,8 +8,10 @@ use std::path::{Path, PathBuf}; use anyhow::Result; +use rand::Rng; use serde::{Deserialize, Serialize}; +use crate::config::UpdateConfig; use crate::events::PatchEvent; use super::patch_manager::{ManagePatches, PatchManager}; @@ -52,6 +54,13 @@ struct SerializedState { /// Events that have not yet been sent to the server. /// Format could change between releases, so this is per-release state. queued_events: Vec, + /// A randomly assigned number between 1 and 100 (inclusive) that determines when this device + /// will receive a phased rollout. If the rollout_group is less than or equal to the rollout + /// percentage, the device will receive the update (this logic is implemented server-side). + /// + /// This number is generated once when the state is created (i.e., when a release is first + /// launched) and is not changed until the next release is installed. + rollout_group: u32, } fn is_file_not_found(error: &anyhow::Error) -> bool { @@ -73,6 +82,8 @@ impl UpdaterState { serialized_state: SerializedState { release_version, queued_events: Vec::new(), + // Generate random number between 1 and 100, inclusive. + rollout_group: rand::thread_rng().gen_range(1..=100), }, } } @@ -105,6 +116,14 @@ impl UpdaterState { state } + pub fn load_or_new_from_config(config: &UpdateConfig) -> Self { + UpdaterState::load_or_new_on_error( + &config.storage_dir, + &config.release_version, + config.patch_public_key.as_deref(), + ) + } + pub fn load_or_new_on_error( storage_dir: &Path, release_version: &str, @@ -184,6 +203,11 @@ impl UpdaterState { .or(self.patch_manager.last_successfully_booted_patch()) } + /// The rollout group number (1-100) for this device. + pub fn rollout_group(&self) -> u32 { + self.serialized_state.rollout_group + } + /// This is the patch that will be used for the next boot. /// Will be None if: /// - There has never been a patch selected. @@ -261,6 +285,7 @@ mod tests { serialized_state: SerializedState { release_version: "1.0.0+1".to_string(), queued_events: Vec::new(), + rollout_group: 1, }, } } @@ -312,6 +337,7 @@ mod tests { serialized_state: SerializedState { release_version: "1.0.0+1".to_string(), queued_events: Vec::new(), + rollout_group: 10, }, }; original_state.save().unwrap(); @@ -445,4 +471,35 @@ mod tests { assert!(state.is_known_bad_patch(1)); assert!(!state.is_known_bad_patch(2)); } + + #[test] + fn generates_random_rollout_group_between_1_and_100() { + let tmp_dir = TempDir::new("example").unwrap(); + let state = test_state(&tmp_dir, PatchManager::manager_for_test(&tmp_dir)); + let first_rollout_group = state.serialized_state.rollout_group; + assert!(first_rollout_group >= 1); + assert!(first_rollout_group <= 100); + + let number_of_tries = 5; + for i in 0..number_of_tries { + let state = test_state(&tmp_dir, PatchManager::manager_for_test(&tmp_dir)); + assert!(state.serialized_state.rollout_group >= 1); + assert!(state.serialized_state.rollout_group <= 100); + if state.serialized_state.rollout_group == first_rollout_group { + // This is an unlikely event, but it could happen. + // If it does, we'll try a few more times. + continue; + } + + if i == number_of_tries - 1 { + // The likelihood of getting the same random 1-100 number 5 times in a row is 1 in + // 100^5, or 10,000,000,000. + // Treat this as a failure of our random number generation. + assert!( + false, + "Failed to generate a random rollout group after 5 tries." + ); + } + } + } } diff --git a/library/src/network.rs b/library/src/network.rs index 859e97b..3562a11 100644 --- a/library/src/network.rs +++ b/library/src/network.rs @@ -8,6 +8,7 @@ use std::io::Write; use std::path::Path; use std::string::ToString; +use crate::cache::UpdaterState; use crate::config::{current_arch, current_platform, UpdateConfig}; use crate::events::PatchEvent; @@ -173,6 +174,8 @@ pub struct PatchCheckRequest { pub platform: String, /// Architecture we're running (e.g. "aarch64", "x86", "x86_64"). pub arch: String, + /// Which rollout group this device has been assigned to + pub rollout_group: u32, // We specifically do not send a patch number as part of this request because we always want to // know what the latest available patch is. } @@ -185,6 +188,7 @@ impl PatchCheckRequest { release_version: config.release_version.clone(), platform: current_platform().to_string(), arch: current_arch().to_string(), + rollout_group: UpdaterState::load_or_new_from_config(config).rollout_group(), } } } @@ -327,6 +331,7 @@ mod tests { release_version: "".to_string(), platform: "".to_string(), arch: "".to_string(), + rollout_group: 1, }, ); assert!(result.is_err()); diff --git a/library/src/updater.rs b/library/src/updater.rs index 6f3604e..2f50863 100644 --- a/library/src/updater.rs +++ b/library/src/updater.rs @@ -145,11 +145,7 @@ where F: FnOnce(&UpdaterState) -> anyhow::Result, { with_config(|config| { - let state = UpdaterState::load_or_new_on_error( - &config.storage_dir, - &config.release_version, - config.patch_public_key.as_deref(), - ); + let state = UpdaterState::load_or_new_from_config(&config); f(&state) }) } @@ -159,11 +155,7 @@ where F: FnOnce(&mut UpdaterState) -> anyhow::Result, { with_config(|config| { - let mut state = UpdaterState::load_or_new_on_error( - &config.storage_dir, - &config.release_version, - config.patch_public_key.as_deref(), - ); + let mut state = UpdaterState::load_or_new_from_config(&config); f(&mut state) }) }