diff --git a/Cargo.lock b/Cargo.lock index c0a5563939..8a2b1e0982 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5796,6 +5796,26 @@ dependencies = [ "slog-error-chain", ] +[[package]] +name = "nexus-reconfigurator-simulation" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "indexmap 2.5.0", + "nexus-inventory", + "nexus-reconfigurator-planning", + "nexus-types", + "omicron-common", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "petname", + "slog", + "thiserror", + "typed-rng", + "uuid", +] + [[package]] name = "nexus-saga-recovery" version = "0.1.0" @@ -8064,6 +8084,19 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "petname" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9cd31dcfdbbd7431a807ef4df6edd6473228e94d5c805e8cf671227a21bad068" +dependencies = [ + "anyhow", + "itertools 0.13.0", + "proc-macro2", + "quote", + "rand", +] + [[package]] name = "phf" version = "0.11.2" @@ -8925,13 +8958,13 @@ dependencies = [ "dropshot", "expectorate", "humantime", - "indexmap 2.5.0", "internal-dns-types", "nexus-client", "nexus-db-queries", "nexus-inventory", "nexus-reconfigurator-planning", "nexus-reconfigurator-preparation", + "nexus-reconfigurator-simulation", "nexus-sled-agent-shared", "nexus-test-utils", "nexus-test-utils-macros", @@ -8952,7 +8985,6 @@ dependencies = [ "swrite", "tabled", "tokio", - "typed-rng", "uuid", ] diff --git a/Cargo.toml b/Cargo.toml index 412c193915..ed5c328d05 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -77,6 +77,7 @@ members = [ "nexus/reconfigurator/execution", "nexus/reconfigurator/planning", "nexus/reconfigurator/preparation", + "nexus/reconfigurator/simulation", "nexus/saga-recovery", "nexus/test-interface", "nexus/test-utils-macros", @@ -202,6 +203,7 @@ default-members = [ "nexus/reconfigurator/execution", "nexus/reconfigurator/planning", "nexus/reconfigurator/preparation", + "nexus/reconfigurator/simulation", "nexus/saga-recovery", "nexus/test-interface", "nexus/test-utils-macros", @@ -445,6 +447,7 @@ nexus-networking = { path = "nexus/networking" } nexus-reconfigurator-execution = { path = "nexus/reconfigurator/execution" } nexus-reconfigurator-planning = { path = "nexus/reconfigurator/planning" } nexus-reconfigurator-preparation = { path = "nexus/reconfigurator/preparation" } +nexus-reconfigurator-simulation = { path = "nexus/reconfigurator/simulation" } nexus-saga-recovery = { path = "nexus/saga-recovery" } nexus-sled-agent-shared = { path = "nexus-sled-agent-shared" } nexus-test-interface = { path = "nexus/test-interface" } @@ -504,6 +507,10 @@ paste = "1.0.15" percent-encoding = "2.3.1" peg = "0.8.4" pem = "3.0" +# petname's default features pull in clap for CLI parsing, which we don't need. +# Note that if you depend on petname, you must also set default-features = +# false: petname = { workspace = true, default-features = false }. +petname = { version = "2.0.2", default-features = false, features = ["default-rng", "default-words"] } petgraph = "0.6.5" postgres-protocol = "0.6.7" predicates = "3.1.2" diff --git a/dev-tools/reconfigurator-cli/Cargo.toml b/dev-tools/reconfigurator-cli/Cargo.toml index 2aab2c2333..ad336e3939 100644 --- a/dev-tools/reconfigurator-cli/Cargo.toml +++ b/dev-tools/reconfigurator-cli/Cargo.toml @@ -18,10 +18,10 @@ chrono.workspace = true clap.workspace = true dropshot.workspace = true humantime.workspace = true -indexmap.workspace = true internal-dns-types.workspace = true nexus-inventory.workspace = true nexus-reconfigurator-planning.workspace = true +nexus-reconfigurator-simulation.workspace = true nexus-sled-agent-shared.workspace = true nexus-types.workspace = true omicron-common.workspace = true @@ -34,7 +34,6 @@ slog-error-chain.workspace = true slog.workspace = true swrite.workspace = true tabled.workspace = true -typed-rng.workspace = true uuid.workspace = true omicron-workspace-hack.workspace = true diff --git a/dev-tools/reconfigurator-cli/src/main.rs b/dev-tools/reconfigurator-cli/src/main.rs index f70ac2fc23..dbce621985 100644 --- a/dev-tools/reconfigurator-cli/src/main.rs +++ b/dev-tools/reconfigurator-cli/src/main.rs @@ -6,21 +6,20 @@ use anyhow::{anyhow, bail, Context}; use camino::Utf8PathBuf; -use chrono::Utc; use clap::CommandFactory; use clap::FromArgMatches; use clap::ValueEnum; use clap::{Args, Parser, Subcommand}; -use indexmap::IndexMap; use internal_dns_types::diff::DnsDiff; use nexus_inventory::CollectionBuilder; use nexus_reconfigurator_planning::blueprint_builder::BlueprintBuilder; use nexus_reconfigurator_planning::blueprint_builder::EnsureMultiple; use nexus_reconfigurator_planning::example::ExampleSystemBuilder; use nexus_reconfigurator_planning::planner::Planner; -use nexus_reconfigurator_planning::system::{ - SledBuilder, SledHwInventory, SystemDescription, -}; +use nexus_reconfigurator_planning::system::{SledBuilder, SystemDescription}; +use nexus_reconfigurator_simulation::MutableSimState; +use nexus_reconfigurator_simulation::SimState; +use nexus_reconfigurator_simulation::Simulator; use nexus_sled_agent_shared::inventory::ZoneKind; use nexus_types::deployment::execution; use nexus_types::deployment::execution::blueprint_external_dns_config; @@ -29,17 +28,14 @@ use nexus_types::deployment::BlueprintZoneFilter; use nexus_types::deployment::OmicronZoneNic; use nexus_types::deployment::PlanningInput; use nexus_types::deployment::SledFilter; -use nexus_types::deployment::SledLookupErrorKind; use nexus_types::deployment::{Blueprint, UnstableReconfiguratorState}; -use nexus_types::internal_api::params::DnsConfigParams; -use nexus_types::inventory::Collection; use omicron_common::api::external::Generation; use omicron_common::api::external::Name; use omicron_common::policy::NEXUS_REDUNDANCY; -use omicron_uuid_kinds::CollectionKind; use omicron_uuid_kinds::CollectionUuid; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::OmicronZoneUuid; +use omicron_uuid_kinds::ReconfiguratorSimUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::VnicUuid; use reedline::{Reedline, Signal}; @@ -48,128 +44,47 @@ use std::collections::BTreeMap; use std::io::BufRead; use swrite::{swriteln, SWrite}; use tabled::Tabled; -use typed_rng::TypedUuidRng; use uuid::Uuid; /// REPL state #[derive(Debug)] struct ReconfiguratorSim { - /// describes the sleds in the system - /// - /// This resembles what we get from the `sled` table in a real system. It - /// also contains enough information to generate inventory collections that - /// describe the system. - system: SystemDescription, - - /// inventory collections created by the user - collections: IndexMap, - - /// blueprints created by the user - blueprints: IndexMap, - - /// internal DNS configurations - internal_dns: BTreeMap, - /// external DNS configurations - external_dns: BTreeMap, - - /// Set of silo names configured - /// - /// These are used to determine the contents of external DNS. - silo_names: Vec, - - /// External DNS zone name configured - external_dns_zone_name: String, - - /// RNG for collection IDs - collection_id_rng: TypedUuidRng, - - /// Policy overrides - num_nexus: Option, - + // The simulator currently being used. + sim: Simulator, + // The current state. + current: ReconfiguratorSimUuid, + // The current system state log: slog::Logger, } impl ReconfiguratorSim { - fn new(log: slog::Logger) -> Self { + fn new(log: slog::Logger, seed: Option) -> Self { Self { - system: SystemDescription::new(), - collections: IndexMap::new(), - blueprints: IndexMap::new(), - internal_dns: BTreeMap::new(), - external_dns: BTreeMap::new(), - silo_names: vec!["example-silo".parse().unwrap()], - external_dns_zone_name: String::from("oxide.example"), - collection_id_rng: TypedUuidRng::from_entropy(), - num_nexus: None, + sim: Simulator::new(&log, seed), + current: Simulator::ROOT_ID, log, } } - /// Returns true if the user has made local changes to the simulated - /// system. - /// - /// This is used when the user asks to load an example system. Doing that - /// basically requires a clean slate. - fn user_made_system_changes(&self) -> bool { - // Use this pattern to ensure that if a new field is added to - // ReconfiguratorSim, it will fail to compile until it's added here. - let Self { - system, - collections, - blueprints, - internal_dns, - external_dns, - // For purposes of this method, we let these policy parameters be - // set to any arbitrary value. This lets example systems be - // generated using these values. - silo_names: _, - external_dns_zone_name: _, - collection_id_rng: _, - num_nexus: _, - log: _, - } = self; - - system.has_sleds() - || !collections.is_empty() - || !blueprints.is_empty() - || !internal_dns.is_empty() - || !external_dns.is_empty() + fn current_state(&self) -> &SimState { + self.sim + .get_state(self.current) + .expect("current state should always exist") } - // Reset the state of the REPL. - fn wipe(&mut self) { - *self = Self::new(self.log.clone()); - } - - fn blueprint_lookup(&self, id: Uuid) -> Result<&Blueprint, anyhow::Error> { - self.blueprints - .get(&id) - .ok_or_else(|| anyhow!("no such blueprint: {}", id)) - } - - fn blueprint_insert_new(&mut self, blueprint: Blueprint) { - let previous = self.blueprints.insert(blueprint.id, blueprint); - assert!(previous.is_none()); - } - - fn blueprint_insert_loaded( - &mut self, - blueprint: Blueprint, - ) -> Result<(), anyhow::Error> { - let entry = self.blueprints.entry(blueprint.id); - if let indexmap::map::Entry::Occupied(_) = &entry { - return Err(anyhow!("blueprint already exists: {}", blueprint.id)); - } - let _ = entry.or_insert(blueprint); - Ok(()) + fn commit_and_bump(&mut self, description: String, state: MutableSimState) { + let new_id = state.commit(description, &mut self.sim); + self.current = new_id; } fn planning_input( &self, parent_blueprint: &Blueprint, ) -> anyhow::Result { - let mut builder = self - .system + let state = self.current_state(); + let mut builder = state + .system() + .description() .to_planning_input_builder() .context("generating planning input builder")?; @@ -231,6 +146,10 @@ impl ReconfiguratorSim { #[derive(Parser, Debug)] struct CmdReconfiguratorSim { input_file: Option, + + /// The RNG seed to initialize the simulator with. + #[clap(long)] + seed: Option, } // REPL implementation @@ -244,7 +163,13 @@ fn main() -> anyhow::Result<()> { .to_logger("reconfigurator-sim") .context("creating logger")?; - let mut sim = ReconfiguratorSim::new(log); + let seed_provided = cmd.seed.is_some(); + let mut sim = ReconfiguratorSim::new(log, cmd.seed); + if seed_provided { + println!("using provided RNG seed: {}", sim.sim.initial_seed()); + } else { + println!("generated RNG seed: {}", sim.sim.initial_seed()); + } if let Some(input_file) = cmd.input_file { let file = std::fs::File::open(&input_file) @@ -361,7 +286,7 @@ fn process_entry(sim: &mut ReconfiguratorSim, entry: String) -> LoopResult { Commands::LoadExample(args) => cmd_load_example(sim, args), Commands::FileContents(args) => cmd_file_contents(args), Commands::Save(args) => cmd_save(sim, args), - Commands::Wipe => cmd_wipe(sim), + Commands::Wipe(args) => cmd_wipe(sim, args), }; match cmd_result { @@ -435,7 +360,7 @@ enum Commands { /// show information about what's in a saved file FileContents(FileContentsArgs), /// reset the state of the REPL - Wipe, + Wipe(WipeArgs), } #[derive(Debug, Args)] @@ -569,11 +494,11 @@ struct LoadArgs { struct LoadExampleArgs { /// Seed for the RNG that's used to generate the example system. /// - /// Setting this makes it possible for callers to get deterministic - /// results. In automated tests, the seed is typically the name of the - /// test. - #[clap(long, default_value = "reconfigurator_cli_example")] - seed: String, + /// If this is provided, the RNG is updated with this seed before the + /// example system is generated. If it's not provided, the existing RNG is + /// used. + #[clap(long)] + seed: Option, /// The number of sleds in the example system. #[clap(short = 's', long, default_value_t = ExampleSystemBuilder::DEFAULT_N_SLEDS)] @@ -604,13 +529,32 @@ struct SaveArgs { filename: Utf8PathBuf, } +#[derive(Debug, Args)] +struct WipeArgs { + /// What to wipe + #[clap(subcommand)] + command: WipeCommand, +} + +#[derive(Debug, Subcommand)] +enum WipeCommand { + /// Wipe everything + All, + /// Wipe the system + System, + /// Reset configuration to default + Config, + /// Reset RNG state + Rng, +} + // Command handlers fn cmd_silo_list( sim: &mut ReconfiguratorSim, ) -> anyhow::Result> { let mut s = String::new(); - for silo_name in &sim.silo_names { + for silo_name in sim.current_state().config().silo_names() { swriteln!(s, "{}", silo_name); } Ok(Some(s)) @@ -620,11 +564,10 @@ fn cmd_silo_add( sim: &mut ReconfiguratorSim, args: SiloAddRemoveArgs, ) -> anyhow::Result> { - if sim.silo_names.contains(&args.silo_name) { - bail!("silo already exists: {:?}", &args.silo_name); - } - - sim.silo_names.push(args.silo_name); + let mut state = sim.current_state().to_mut(); + let config = state.config_mut(); + config.add_silo(args.silo_name)?; + sim.commit_and_bump("reconfigurator-cli silo-add".to_owned(), state); Ok(None) } @@ -632,11 +575,10 @@ fn cmd_silo_remove( sim: &mut ReconfiguratorSim, args: SiloAddRemoveArgs, ) -> anyhow::Result> { - let size_before = sim.silo_names.len(); - sim.silo_names.retain(|n| *n != args.silo_name); - if sim.silo_names.len() == size_before { - bail!("no such silo: {:?}", &args.silo_name); - } + let mut state = sim.current_state().to_mut(); + let config = state.config_mut(); + config.remove_silo(args.silo_name)?; + sim.commit_and_bump("reconfigurator-cli silo-remove".to_owned(), state); Ok(None) } @@ -651,8 +593,10 @@ fn cmd_sled_list( subnet: String, } - let planning_input = sim - .system + let state = sim.current_state(); + let planning_input = state + .system() + .description() .to_planning_input_builder() .context("failed to generate planning input")? .build(); @@ -674,21 +618,27 @@ fn cmd_sled_add( sim: &mut ReconfiguratorSim, add: SledAddArgs, ) -> anyhow::Result> { - let mut new_sled = SledBuilder::new(); - if let Some(sled_id) = add.sled_id { - new_sled = new_sled.id(sled_id); - } + let mut state = sim.current_state().to_mut(); + let sled_id = add.sled_id.unwrap_or_else(|| state.rng_mut().next_sled_id()); + let new_sled = SledBuilder::new().id(sled_id); + state.system_mut().description_mut().sled(new_sled)?; + sim.commit_and_bump( + format!("reconfigurator-cli sled-add: {sled_id}"), + state, + ); - let _ = sim.system.sled(new_sled).context("adding sled")?; - Ok(Some(String::from("added sled"))) + // TODO: we should show the sled ID here + Ok(Some("added sled".to_owned())) } fn cmd_sled_show( sim: &mut ReconfiguratorSim, args: SledArgs, ) -> anyhow::Result> { - let planning_input = sim - .system + let state = sim.current_state(); + let planning_input = state + .system() + .description() .to_planning_input_builder() .context("failed to generate planning_input builder")? .build(); @@ -717,7 +667,8 @@ fn cmd_inventory_list( time_done: String, } - let rows = sim.collections.values().map(|collection| { + let state = sim.current_state(); + let rows = state.system().all_collections().map(|collection| { let id = collection.id; InventoryRow { id, @@ -738,21 +689,22 @@ fn cmd_inventory_list( fn cmd_inventory_generate( sim: &mut ReconfiguratorSim, ) -> anyhow::Result> { - let builder = - sim.system.to_collection_builder().context("generating inventory")?; + let mut state = sim.current_state().to_mut(); + let builder = state.to_collection_builder()?; - // sim.system carries around Omicron zones, which will make their way into + // The system carries around Omicron zones, which will make their way into // the inventory. - let mut inventory = builder.build(); - // Assign collection IDs from the RNG. This enables consistent results when - // callers have explicitly seeded the RNG (e.g., in tests). - inventory.id = sim.collection_id_rng.next(); + let inventory = builder.build(); let rv = format!( "generated inventory collection {} from configured sleds", inventory.id ); - sim.collections.insert(inventory.id, inventory); + state.system_mut().add_collection(inventory)?; + sim.commit_and_bump( + "reconfigurator-cli inventory-generate".to_owned(), + state, + ); Ok(Some(rv)) } @@ -767,7 +719,9 @@ fn cmd_blueprint_list( time_created: String, } - let mut rows = sim.blueprints.values().collect::>(); + let state = sim.current_state(); + + let mut rows = state.system().all_blueprints().collect::>(); rows.sort_unstable_by_key(|blueprint| blueprint.time_created); let rows = rows.into_iter().map(|blueprint| BlueprintRow { id: blueprint.id, @@ -791,13 +745,15 @@ fn cmd_blueprint_plan( sim: &mut ReconfiguratorSim, args: BlueprintPlanArgs, ) -> anyhow::Result> { + let mut state = sim.current_state().to_mut(); + let rng = state.rng_mut().next_blueprint_rng(); + let system = state.system_mut(); + let parent_blueprint_id = args.parent_blueprint_id; let collection_id = args.collection_id; - let parent_blueprint = sim.blueprint_lookup(parent_blueprint_id)?; - let collection = sim - .collections - .get(&collection_id) - .ok_or_else(|| anyhow!("no such collection: {}", collection_id))?; + let parent_blueprint = system.get_blueprint(parent_blueprint_id)?; + let collection = system.get_collection(collection_id)?; + let creator = "reconfigurator-sim"; let planning_input = sim.planning_input(parent_blueprint)?; let planner = Planner::new_based_on( @@ -807,13 +763,18 @@ fn cmd_blueprint_plan( creator, collection, ) - .context("creating planner")?; + .context("creating planner")? + .with_rng(rng); + let blueprint = planner.plan().context("generating blueprint")?; let rv = format!( "generated blueprint {} based on parent blueprint {}", blueprint.id, parent_blueprint_id, ); - sim.blueprint_insert_new(blueprint); + system.add_blueprint(blueprint)?; + + sim.commit_and_bump("reconfigurator-cli blueprint-plan".to_owned(), state); + Ok(Some(rv)) } @@ -821,16 +782,24 @@ fn cmd_blueprint_edit( sim: &mut ReconfiguratorSim, args: BlueprintEditArgs, ) -> anyhow::Result> { + let mut state = sim.current_state().to_mut(); + let rng = state.rng_mut().next_blueprint_rng(); + let system = state.system_mut(); + let blueprint_id = args.blueprint_id; - let blueprint = sim.blueprint_lookup(blueprint_id)?; + let blueprint = system.get_blueprint(blueprint_id)?; let creator = args.creator.as_deref().unwrap_or("reconfigurator-cli"); let planning_input = sim.planning_input(blueprint)?; - let latest_collection = sim - .collections - .iter() - .max_by_key(|(_, c)| c.time_started) - .map(|(_, c)| c.clone()) + + // TODO: We may want to do something other than just using the latest + // collection? Using timestamps like this is somewhat dubious, especially + // in the presence of merged data from cmd_load. + let latest_collection = system + .all_collections() + .max_by_key(|c| c.time_started) + .map(|c| c.clone()) .unwrap_or_else(|| CollectionBuilder::new("sim").build()); + let mut builder = BlueprintBuilder::new_based_on( &sim.log, blueprint, @@ -839,6 +808,7 @@ fn cmd_blueprint_edit( creator, ) .context("creating blueprint builder")?; + builder.set_rng(rng); if let Some(comment) = args.comment { builder.comment(comment); @@ -892,7 +862,9 @@ fn cmd_blueprint_edit( "blueprint {} created from blueprint {}: {}", new_blueprint.id, blueprint_id, label ); - sim.blueprint_insert_new(new_blueprint); + system.add_blueprint(new_blueprint)?; + + sim.commit_and_bump("reconfigurator-cli blueprint-edit".to_owned(), state); Ok(Some(rv)) } @@ -900,7 +872,8 @@ fn cmd_blueprint_show( sim: &mut ReconfiguratorSim, args: BlueprintArgs, ) -> anyhow::Result> { - let blueprint = sim.blueprint_lookup(args.blueprint_id)?; + let state = sim.current_state(); + let blueprint = state.system().get_blueprint(args.blueprint_id)?; Ok(Some(format!("{}", blueprint.display()))) } @@ -911,8 +884,10 @@ fn cmd_blueprint_diff( let mut rv = String::new(); let blueprint1_id = args.blueprint1_id; let blueprint2_id = args.blueprint2_id; - let blueprint1 = sim.blueprint_lookup(blueprint1_id)?; - let blueprint2 = sim.blueprint_lookup(blueprint2_id)?; + + let state = sim.current_state(); + let blueprint1 = state.system().get_blueprint(blueprint1_id)?; + let blueprint2 = state.system().get_blueprint(blueprint2_id)?; let sled_diff = blueprint2.diff_since_blueprint(&blueprint1); swriteln!(rv, "{}", sled_diff.display()); @@ -920,7 +895,7 @@ fn cmd_blueprint_diff( // Diff'ing DNS is a little trickier. First, compute what DNS should be for // each blueprint. To do that we need to construct a list of sleds suitable // for the executor. - let sleds_by_id = make_sleds_by_id(&sim.system)?; + let sleds_by_id = make_sleds_by_id(state.system().description())?; let internal_dns_config1 = blueprint_internal_dns_config( &blueprint1, &sleds_by_id, @@ -935,15 +910,16 @@ fn cmd_blueprint_diff( .context("failed to assemble DNS diff")?; swriteln!(rv, "internal DNS:\n{}", dns_diff); + let external_dns_zone_name = state.config().external_dns_zone_name(); let external_dns_config1 = blueprint_external_dns_config( &blueprint1, - &sim.silo_names, - sim.external_dns_zone_name.clone(), + state.config().silo_names(), + external_dns_zone_name.to_owned(), ); let external_dns_config2 = blueprint_external_dns_config( &blueprint2, - &sim.silo_names, - sim.external_dns_zone_name.clone(), + state.config().silo_names(), + external_dns_zone_name.to_owned(), ); let dns_diff = DnsDiff::new(&external_dns_config1, &external_dns_config2) .context("failed to assemble external DNS diff")?; @@ -983,11 +959,13 @@ fn cmd_blueprint_diff_dns( let dns_group = args.dns_group; let dns_version = Generation::from(args.dns_version); let blueprint_id = args.blueprint_id; - let blueprint = sim.blueprint_lookup(blueprint_id)?; + + let state = sim.current_state(); + let blueprint = state.system().get_blueprint(blueprint_id)?; let existing_dns_config = match dns_group { - CliDnsGroup::Internal => sim.internal_dns.get(&dns_version), - CliDnsGroup::External => sim.external_dns.get(&dns_version), + CliDnsGroup::Internal => state.system().get_internal_dns(dns_version), + CliDnsGroup::External => state.system().get_external_dns(dns_version), } .ok_or_else(|| { anyhow!("no such {:?} DNS version: {}", dns_group, dns_version) @@ -995,7 +973,7 @@ fn cmd_blueprint_diff_dns( let blueprint_dns_zone = match dns_group { CliDnsGroup::Internal => { - let sleds_by_id = make_sleds_by_id(&sim.system)?; + let sleds_by_id = make_sleds_by_id(state.system().description())?; blueprint_internal_dns_config( blueprint, &sleds_by_id, @@ -1004,8 +982,8 @@ fn cmd_blueprint_diff_dns( } CliDnsGroup::External => blueprint_external_dns_config( blueprint, - &sim.silo_names, - sim.external_dns_zone_name.clone(), + state.config().silo_names(), + state.config().external_dns_zone_name().to_owned(), ), }; @@ -1021,10 +999,10 @@ fn cmd_blueprint_diff_inventory( ) -> anyhow::Result> { let collection_id = args.collection_id; let blueprint_id = args.blueprint_id; - let collection = sim.collections.get(&collection_id).ok_or_else(|| { - anyhow!("no such inventory collection: {}", collection_id) - })?; - let blueprint = sim.blueprint_lookup(blueprint_id)?; + + let state = sim.current_state(); + let collection = state.system().get_collection(collection_id)?; + let blueprint = state.system().get_blueprint(blueprint_id)?; let diff = blueprint.diff_since_collection(&collection); Ok(Some(diff.display().to_string())) } @@ -1034,7 +1012,9 @@ fn cmd_blueprint_save( args: BlueprintSaveArgs, ) -> anyhow::Result> { let blueprint_id = args.blueprint_id; - let blueprint = sim.blueprint_lookup(blueprint_id)?; + + let state = sim.current_state(); + let blueprint = state.system().get_blueprint(blueprint_id)?; let output_path = &args.filename; let output_str = serde_json::to_string_pretty(&blueprint) @@ -1048,20 +1028,8 @@ fn cmd_save( sim: &mut ReconfiguratorSim, args: SaveArgs, ) -> anyhow::Result> { - let planning_input = sim - .system - .to_planning_input_builder() - .context("creating planning input builder")? - .build(); - let saved = UnstableReconfiguratorState { - planning_input, - collections: sim.collections.values().cloned().collect(), - blueprints: sim.blueprints.values().cloned().collect(), - internal_dns: sim.internal_dns.clone(), - external_dns: sim.external_dns.clone(), - silo_names: sim.silo_names.clone(), - external_dns_zone_names: vec![sim.external_dns_zone_name.clone()], - }; + let state = sim.current_state(); + let saved = state.to_serializable()?; let output_path = &args.filename; let output_str = @@ -1074,36 +1042,80 @@ fn cmd_save( ))) } -fn cmd_wipe(sim: &mut ReconfiguratorSim) -> anyhow::Result> { - sim.wipe(); - Ok(Some("wiped reconfigurator-sim state".to_string())) +fn cmd_wipe( + sim: &mut ReconfiguratorSim, + args: WipeArgs, +) -> anyhow::Result> { + let mut state = sim.current_state().to_mut(); + let output = match args.command { + WipeCommand::All => { + state.system_mut().wipe(); + state.config_mut().wipe(); + state.rng_mut().reset_state(); + format!( + "- wiped system, reconfigurator-sim config, and RNG state\n + - reset seed to {}", + state.rng_mut().seed() + ) + } + WipeCommand::System => { + state.system_mut().wipe(); + "wiped system".to_string() + } + WipeCommand::Config => { + state.config_mut().wipe(); + "wiped reconfigurator-sim config".to_string() + } + WipeCommand::Rng => { + // Don't allow wiping the RNG state if the system is non-empty. + // Wiping the RNG state is likely to cause duplicate IDs to be + // generated. + if !state.system_mut().is_empty() { + bail!( + "cannot wipe RNG state with non-empty system: \ + run `wipe system` first" + ); + } + state.rng_mut().reset_state(); + format!( + "- wiped RNG state\n- reset seed to {}", + state.rng_mut().seed() + ) + } + }; + + sim.commit_and_bump(output.clone(), state); + Ok(Some(output)) } fn cmd_show(sim: &mut ReconfiguratorSim) -> anyhow::Result> { let mut s = String::new(); - do_print_properties(&mut s, sim); + let state = sim.current_state(); + do_print_properties(&mut s, state); swriteln!( s, "target number of Nexus instances: {}", - match sim.num_nexus { - Some(n) => n.to_string(), - None => String::from("default"), - } + state + .config() + .num_nexus() + .map_or_else(|| "default".to_owned(), |n| n.to_string()) ); Ok(Some(s)) } -fn do_print_properties(s: &mut String, sim: &ReconfiguratorSim) { +// TODO: consider moving this to a method on `SimState`. +fn do_print_properties(s: &mut String, state: &SimState) { swriteln!( s, "configured external DNS zone name: {}", - sim.external_dns_zone_name, + state.config().external_dns_zone_name(), ); swriteln!( s, "configured silo names: {}", - sim.silo_names - .iter() + state + .config() + .silo_names() .map(|s| s.as_str()) .collect::>() .join(", ") @@ -1111,18 +1123,20 @@ fn do_print_properties(s: &mut String, sim: &ReconfiguratorSim) { swriteln!( s, "internal DNS generations: {}", - sim.internal_dns - .keys() - .map(|s| s.to_string()) + state + .system() + .all_internal_dns() + .map(|params| params.generation.to_string()) .collect::>() .join(", "), ); swriteln!( s, "external DNS generations: {}", - sim.external_dns - .keys() - .map(|s| s.to_string()) + state + .system() + .all_external_dns() + .map(|params| params.generation.to_string()) .collect::>() .join(", "), ); @@ -1132,20 +1146,34 @@ fn cmd_set( sim: &mut ReconfiguratorSim, args: SetArgs, ) -> anyhow::Result> { - Ok(Some(match args { + let mut state = sim.current_state().to_mut(); + let rv = match args { SetArgs::NumNexus { num_nexus } => { - let rv = format!("{:?} -> {}", sim.num_nexus, num_nexus); - sim.num_nexus = Some(num_nexus); - sim.system.target_nexus_zone_count(usize::from(num_nexus)); + let rv = format!( + "target number of Nexus zones: {:?} -> {}", + state.config_mut().num_nexus(), + num_nexus + ); + state.config_mut().set_num_nexus(num_nexus); + state + .system_mut() + .description_mut() + .target_nexus_zone_count(usize::from(num_nexus)); rv } SetArgs::ExternalDnsZoneName { zone_name } => { - let rv = - format!("{:?} -> {:?}", sim.external_dns_zone_name, zone_name); - sim.external_dns_zone_name = zone_name; + let rv = format!( + "external DNS zone name: {:?} -> {:?}", + state.config_mut().external_dns_zone_name(), + zone_name + ); + state.config_mut().set_external_dns_zone_name(zone_name); rv } - })) + }; + + sim.commit_and_bump(format!("reconfigurator-cli set: {}", rv), state); + Ok(Some(rv)) } fn read_file( @@ -1166,188 +1194,32 @@ fn cmd_load( let collection_id = args.collection_id; let loaded = read_file(&input_path)?; - let mut s = String::new(); + let mut state = sim.current_state().to_mut(); + let result = state.merge_serializable(loaded, collection_id)?; - let collection_id = match collection_id { - Some(s) => s, - None => match loaded.collections.len() { - 1 => loaded.collections[0].id, - 0 => bail!( - "no collection_id specified and file contains 0 collections" - ), - count => bail!( - "no collection_id specified and file contains {} \ - collections: {}", - count, - loaded - .collections - .iter() - .map(|c| c.id.to_string()) - .collect::>() - .join(", ") - ), - }, - }; - - swriteln!( - s, - "using collection {} as source of sled inventory data", - collection_id + sim.commit_and_bump( + format!("reconfigurator-sim: load {:?}", input_path), + state, ); - let primary_collection = - loaded.collections.iter().find(|c| c.id == collection_id).ok_or_else( - || { - anyhow!( - "collection {} not found in file {:?}", - collection_id, - input_path - ) - }, - )?; - - let current_planning_input = sim - .system - .to_planning_input_builder() - .context("generating planning input")? - .build(); - for (sled_id, sled_details) in - loaded.planning_input.all_sleds(SledFilter::Commissioned) - { - match current_planning_input - .sled_lookup(SledFilter::Commissioned, sled_id) - { - Ok(_) => { - swriteln!( - s, - "sled {}: skipped (one with \ - the same id is already loaded)", - sled_id - ); - continue; - } - Err(error) => match error.kind() { - SledLookupErrorKind::Filtered { .. } => { - swriteln!( - s, - "error: load sled {}: turning a decommissioned sled \ - into a commissioned one is not supported", - sled_id - ); - continue; - } - SledLookupErrorKind::Missing => { - // A sled being missing from the input is the only case in - // which we decide to load new sleds. The logic to do that - // is below. - } - }, - } - let Some(inventory_sled_agent) = - primary_collection.sled_agents.get(&sled_id) - else { - swriteln!( - s, - "error: load sled {}: no inventory found for sled agent in \ - collection {}", - sled_id, - collection_id - ); - continue; - }; - - let inventory_sp = inventory_sled_agent.baseboard_id.as_ref().and_then( - |baseboard_id| { - let inv_sp = primary_collection.sps.get(baseboard_id); - let inv_rot = primary_collection.rots.get(baseboard_id); - if let (Some(inv_sp), Some(inv_rot)) = (inv_sp, inv_rot) { - Some(SledHwInventory { - baseboard_id: &baseboard_id, - sp: inv_sp, - rot: inv_rot, - }) - } else { - None - } - }, - ); - - let result = sim.system.sled_full( - sled_id, - sled_details.policy, - sled_details.state, - sled_details.resources.clone(), - inventory_sp, - inventory_sled_agent, - ); - - match result { - Ok(_) => swriteln!(s, "sled {} loaded", sled_id), - Err(error) => { - swriteln!(s, "error: load sled {}: {:#}", sled_id, error) - } - }; - } - - for collection in loaded.collections { - if sim.collections.contains_key(&collection.id) { - swriteln!( - s, - "collection {}: skipped (one with the \ - same id is already loaded)", - collection.id - ); - } else { - swriteln!(s, "collection {} loaded", collection.id); - sim.collections.insert(collection.id, collection); - } - } + let mut s = String::new(); + swriteln!(s, "loaded data from {:?}", input_path); - for blueprint in loaded.blueprints { - let blueprint_id = blueprint.id; - match sim.blueprint_insert_loaded(blueprint) { - Ok(_) => { - swriteln!(s, "blueprint {} loaded", blueprint_id); - } - Err(error) => { - swriteln!( - s, - "blueprint {}: skipped ({:#})", - blueprint_id, - error - ); - } + if !result.warnings.is_empty() { + swriteln!(s, "warnings:"); + for warning in result.warnings { + swriteln!(s, " {}", warning); } } - sim.system.service_ip_pool_ranges( - loaded.planning_input.service_ip_pool_ranges().to_vec(), - ); - swriteln!( - s, - "loaded service IP pool ranges: {:?}", - loaded.planning_input.service_ip_pool_ranges() - ); - - sim.internal_dns = loaded.internal_dns; - sim.external_dns = loaded.external_dns; - sim.silo_names = loaded.silo_names; - - let nnames = loaded.external_dns_zone_names.len(); - if nnames > 0 { - if nnames > 1 { - swriteln!( - s, - "warn: found {} external DNS names; using only the first one", - nnames - ); + if !result.notices.is_empty() { + swriteln!(s, "notices:"); + for notice in result.notices { + swriteln!(s, " {}", notice); } - sim.external_dns_zone_name = - loaded.external_dns_zone_names.into_iter().next().unwrap(); } - do_print_properties(&mut s, sim); - swriteln!(s, "loaded data from {:?}", input_path); + do_print_properties(&mut s, sim.current_state()); Ok(Some(s)) } @@ -1355,7 +1227,9 @@ fn cmd_load_example( sim: &mut ReconfiguratorSim, args: LoadExampleArgs, ) -> anyhow::Result> { - if sim.user_made_system_changes() { + let mut s = String::new(); + let mut state = sim.current_state().to_mut(); + if !state.system_mut().is_empty() { bail!( "changes made to simulated system: run `wipe system` before \ loading an example system" @@ -1363,13 +1237,36 @@ fn cmd_load_example( } // Generate the example system. - let (example, blueprint) = ExampleSystemBuilder::new(&sim.log, &args.seed) - .nsleds(args.nsleds) - .ndisks_per_sled(args.ndisks_per_sled) - .nexus_count(sim.num_nexus.map_or(NEXUS_REDUNDANCY, |n| n.into())) - .create_zones(!args.no_zones) - .create_disks_in_blueprint(!args.no_disks_in_blueprint) - .build(); + match args.seed { + Some(seed) => { + // In this case, reset the RNG state to the provided seed. + swriteln!(s, "setting new RNG seed: {}", seed,); + state.rng_mut().set_seed(seed); + } + None => { + // In this case, use the existing RNG state. + swriteln!( + s, + "using existing RNG state (seed: {})", + state.rng_mut().seed() + ); + } + }; + let rng = state.rng_mut().next_example_rng(); + + let (example, blueprint) = + ExampleSystemBuilder::new_with_rng(&sim.log, rng) + .nsleds(args.nsleds) + .ndisks_per_sled(args.ndisks_per_sled) + .nexus_count( + state + .config_mut() + .num_nexus() + .map_or(NEXUS_REDUNDANCY, |n| n.into()), + ) + .create_zones(!args.no_zones) + .create_disks_in_blueprint(!args.no_disks_in_blueprint) + .build(); // Generate the internal and external DNS configs based on the blueprint. let sleds_by_id = make_sleds_by_id(&example.system)?; @@ -1378,36 +1275,22 @@ fn cmd_load_example( &sleds_by_id, &Default::default(), )?; + let external_dns_zone_name = + state.config_mut().external_dns_zone_name().to_owned(); let external_dns = blueprint_external_dns_config( &blueprint, - &sim.silo_names, - sim.external_dns_zone_name.clone(), + state.config_mut().silo_names(), + external_dns_zone_name, ); - // No more fallible operations from here on out: set the system state. - let collection_id = example.collection.id; let blueprint_id = blueprint.id; - sim.system = example.system; - sim.collections.insert(collection_id, example.collection); - sim.internal_dns.insert( - blueprint.internal_dns_version, - DnsConfigParams { - generation: blueprint.internal_dns_version.into(), - time_created: Utc::now(), - zones: vec![internal_dns], - }, - ); - sim.external_dns.insert( - blueprint.external_dns_version, - DnsConfigParams { - generation: blueprint.external_dns_version.into(), - time_created: Utc::now(), - zones: vec![external_dns], - }, - ); - sim.blueprints.insert(blueprint.id, blueprint); - sim.collection_id_rng = - TypedUuidRng::from_seed(&args.seed, "reconfigurator-cli"); + let collection_id = example.collection.id; + + state + .system_mut() + .load_example(example, blueprint, internal_dns, external_dns) + .expect("already checked non-empty state above"); + sim.commit_and_bump("reconfigurator-cli load-example".to_owned(), state); Ok(Some(format!( "loaded example system with:\n\ diff --git a/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt b/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt index b3143ac016..1bf52b1ff9 100644 --- a/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt +++ b/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt @@ -13,9 +13,9 @@ blueprint-show ade5749d-bdf3-4fab-a8ae-00bea01b3a5a blueprint-diff-inventory 9e187896-7809-46d0-9210-d75be1b3c4d4 ade5749d-bdf3-4fab-a8ae-00bea01b3a5a inventory-generate -blueprint-diff-inventory b32394d8-7d79-486f-8657-fd5219508181 ade5749d-bdf3-4fab-a8ae-00bea01b3a5a +blueprint-diff-inventory 972ca69a-384c-4a9c-a87d-c2cf21e114e0 ade5749d-bdf3-4fab-a8ae-00bea01b3a5a -wipe +wipe system load-example --seed test-basic --nsleds 1 --ndisks-per-sled 4 --no-zones sled-list diff --git a/dev-tools/reconfigurator-cli/tests/output/cmd-example-stdout b/dev-tools/reconfigurator-cli/tests/output/cmd-example-stdout index b281d0c256..c5481cdf7e 100644 --- a/dev-tools/reconfigurator-cli/tests/output/cmd-example-stdout +++ b/dev-tools/reconfigurator-cli/tests/output/cmd-example-stdout @@ -1,3 +1,4 @@ +using provided RNG seed: test_example > load-example --seed test-basic loaded example system with: - collection: 9e187896-7809-46d0-9210-d75be1b3c4d4 @@ -323,10 +324,10 @@ to: blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a > > inventory-generate -generated inventory collection b32394d8-7d79-486f-8657-fd5219508181 from configured sleds +generated inventory collection 972ca69a-384c-4a9c-a87d-c2cf21e114e0 from configured sleds -> blueprint-diff-inventory b32394d8-7d79-486f-8657-fd5219508181 ade5749d-bdf3-4fab-a8ae-00bea01b3a5a -from: collection b32394d8-7d79-486f-8657-fd5219508181 +> blueprint-diff-inventory 972ca69a-384c-4a9c-a87d-c2cf21e114e0 ade5749d-bdf3-4fab-a8ae-00bea01b3a5a +from: collection 972ca69a-384c-4a9c-a87d-c2cf21e114e0 to: blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a UNCHANGED SLEDS: @@ -454,8 +455,8 @@ to: blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a > -> wipe -wiped reconfigurator-sim state +> wipe system +wiped system > load-example --seed test-basic --nsleds 1 --ndisks-per-sled 4 --no-zones loaded example system with: diff --git a/dev-tools/reconfigurator-cli/tests/output/cmd-stdout b/dev-tools/reconfigurator-cli/tests/output/cmd-stdout index 37164ce6b2..b35954e1f5 100644 --- a/dev-tools/reconfigurator-cli/tests/output/cmd-stdout +++ b/dev-tools/reconfigurator-cli/tests/output/cmd-stdout @@ -1,3 +1,4 @@ +using provided RNG seed: test_basic > sled-list ID NZPOOLS SUBNET diff --git a/dev-tools/reconfigurator-cli/tests/test_basic.rs b/dev-tools/reconfigurator-cli/tests/test_basic.rs index d451996e0b..b2f630181b 100644 --- a/dev-tools/reconfigurator-cli/tests/test_basic.rs +++ b/dev-tools/reconfigurator-cli/tests/test_basic.rs @@ -40,7 +40,11 @@ fn path_to_cli() -> PathBuf { // Run a battery of simple commands and make sure things basically seem to work. #[test] fn test_basic() { - let exec = Exec::cmd(path_to_cli()).arg("tests/input/cmds.txt"); + let exec = Exec::cmd(path_to_cli()).args(&[ + "tests/input/cmds.txt", + "--seed", + "test_basic", + ]); let (exit_status, stdout_text, stderr_text) = run_command(exec); assert_exit_code(exit_status, EXIT_SUCCESS, &stderr_text); let stdout_text = Redactor::default().do_redact(&stdout_text); @@ -51,7 +55,13 @@ fn test_basic() { // Run tests against a loaded example system. #[test] fn test_example() { - let exec = Exec::cmd(path_to_cli()).arg("tests/input/cmds-example.txt"); + let exec = Exec::cmd(path_to_cli()).args(&[ + "tests/input/cmds-example.txt", + "--seed", + // The test commands in test-example load their own seed, so this is + // ignored. + "test_example", + ]); let (exit_status, stdout_text, stderr_text) = run_command(exec); assert_exit_code(exit_status, EXIT_SUCCESS, &stderr_text); diff --git a/nexus/inventory/src/builder.rs b/nexus/inventory/src/builder.rs index 00c4b7045d..7142b4f46c 100644 --- a/nexus/inventory/src/builder.rs +++ b/nexus/inventory/src/builder.rs @@ -69,6 +69,28 @@ impl std::fmt::Display for CollectorBug { } } +/// Random generator of UUIDs for a [`CollectionBuilder`]. +#[derive(Debug, Clone)] +pub struct CollectionBuilderRng { + // We just generate one UUID for each collection. + id_rng: TypedUuidRng, +} + +impl CollectionBuilderRng { + pub fn from_entropy() -> Self { + CollectionBuilderRng { id_rng: TypedUuidRng::from_entropy() } + } + + pub fn from_seed(seed: H) -> Self { + // Important to add some more bytes here, so that builders with the + // same seed but different purposes don't end up with the same UUIDs. + const SEED_EXTRA: &str = "collection-builder"; + CollectionBuilderRng { + id_rng: TypedUuidRng::from_seed(seed, SEED_EXTRA), + } + } +} + /// Build an inventory [`Collection`] /// /// This interface is oriented around the interfaces used by an actual @@ -92,9 +114,10 @@ pub struct CollectionBuilder { sleds: BTreeMap, clickhouse_keeper_cluster_membership: BTreeSet, - - // We just generate one UUID for each collection. - id_rng: TypedUuidRng, + // CollectionBuilderRng is taken by value, rather than passed in as a + // mutable ref, to encourage a tree-like structure where each RNG is + // generally independent. + rng: CollectionBuilderRng, } impl CollectionBuilder { @@ -120,7 +143,7 @@ impl CollectionBuilder { rot_pages_found: BTreeMap::new(), sleds: BTreeMap::new(), clickhouse_keeper_cluster_membership: BTreeSet::new(), - id_rng: TypedUuidRng::from_entropy(), + rng: CollectionBuilderRng::from_entropy(), } } @@ -133,7 +156,7 @@ impl CollectionBuilder { } Collection { - id: self.id_rng.next(), + id: self.rng.id_rng.next(), errors: self.errors.into_iter().map(|e| e.to_string()).collect(), time_started: self.time_started, time_done: now_db_precision(), @@ -151,15 +174,12 @@ impl CollectionBuilder { } } - /// Within tests, set a seeded RNG for deterministic results. + /// Within tests, set an RNG for deterministic results. /// /// This will ensure that tests that use this builder will produce the same /// results each time they are run. - pub fn set_rng_seed(&mut self, seed: H) -> &mut Self { - // Important to add some more bytes here, so that builders with the - // same seed but different purposes don't end up with the same UUIDs. - const SEED_EXTRA: &str = "collection-builder"; - self.id_rng.set_seed(seed, SEED_EXTRA); + pub fn set_rng(&mut self, rng: CollectionBuilderRng) -> &mut Self { + self.rng = rng; self } diff --git a/nexus/inventory/src/lib.rs b/nexus/inventory/src/lib.rs index 6dee7bb7ec..10a74b6edf 100644 --- a/nexus/inventory/src/lib.rs +++ b/nexus/inventory/src/lib.rs @@ -24,6 +24,7 @@ mod sled_agent_enumerator; // only exposed for test code to construct collections pub use builder::CollectionBuilder; +pub use builder::CollectionBuilderRng; pub use builder::CollectorBug; pub use builder::InventoryError; diff --git a/nexus/reconfigurator/planning/src/blueprint_builder/builder.rs b/nexus/reconfigurator/planning/src/blueprint_builder/builder.rs index d11108ea54..2c4cecd999 100644 --- a/nexus/reconfigurator/planning/src/blueprint_builder/builder.rs +++ b/nexus/reconfigurator/planning/src/blueprint_builder/builder.rs @@ -250,19 +250,17 @@ impl<'a> BlueprintBuilder<'a> { Self::build_empty_with_sleds_impl( sled_ids, creator, - BlueprintBuilderRng::new(), + BlueprintBuilderRng::from_entropy(), ) } /// A version of [`Self::build_empty_with_sleds`] that allows the - /// blueprint ID to be generated from a random seed. - pub fn build_empty_with_sleds_seeded( + /// blueprint ID to be generated from a deterministic RNG. + pub fn build_empty_with_sleds_seeded( sled_ids: impl Iterator, creator: &str, - seed: H, + rng: BlueprintBuilderRng, ) -> Blueprint { - let mut rng = BlueprintBuilderRng::new(); - rng.set_seed(seed); Self::build_empty_with_sleds_impl(sled_ids, creator, rng) } @@ -398,7 +396,7 @@ impl<'a> BlueprintBuilder<'a> { creator: creator.to_owned(), operations: Vec::new(), comments: Vec::new(), - rng: BlueprintBuilderRng::new(), + rng: BlueprintBuilderRng::from_entropy(), }) } @@ -523,12 +521,12 @@ impl<'a> BlueprintBuilder<'a> { self.sled_state.insert(sled_id, desired_state); } - /// Within tests, set a seeded RNG for deterministic results. + /// Within tests, set an RNG for deterministic results. /// /// This will ensure that tests that use this builder will produce the same /// results each time they are run. - pub fn set_rng_seed(&mut self, seed: H) -> &mut Self { - self.rng.set_seed(seed); + pub fn set_rng(&mut self, rng: BlueprintBuilderRng) -> &mut Self { + self.rng = rng; self } @@ -1707,8 +1705,9 @@ impl<'a> BlueprintBuilder<'a> { } } -#[derive(Debug)] -struct BlueprintBuilderRng { +/// Random generator of UUIDs for a [`BlueprintBuilder`]. +#[derive(Debug, Clone)] +pub struct BlueprintBuilderRng { // Have separate RNGs for the different kinds of UUIDs we might add, // generated from the main RNG. This is so that e.g. adding a new network // interface doesn't alter the blueprint or sled UUID. @@ -1722,10 +1721,17 @@ struct BlueprintBuilderRng { } impl BlueprintBuilderRng { - fn new() -> Self { + pub fn from_entropy() -> Self { Self::new_from_parent(StdRng::from_entropy()) } + pub fn from_seed(seed: H) -> Self { + // Important to add some more bytes here, so that builders with the + // same seed but different purposes don't end up with the same UUIDs. + const SEED_EXTRA: &str = "blueprint-builder"; + Self::new_from_parent(typed_rng::from_seed(seed, SEED_EXTRA)) + } + fn new_from_parent(mut parent: StdRng) -> Self { let blueprint_rng = UuidRng::from_parent_rng(&mut parent, "blueprint"); let zone_rng = TypedUuidRng::from_parent_rng(&mut parent, "zone"); @@ -1741,13 +1747,6 @@ impl BlueprintBuilderRng { external_ip_rng, } } - - fn set_seed(&mut self, seed: H) { - // Important to add some more bytes here, so that builders with the - // same seed but different purposes don't end up with the same UUIDs. - const SEED_EXTRA: &str = "blueprint-builder"; - *self = Self::new_from_parent(typed_rng::from_seed(seed, SEED_EXTRA)); - } } /// Helper for working with sets of zones on each sled @@ -1980,6 +1979,7 @@ impl<'a> BlueprintDisksBuilder<'a> { pub mod test { use super::*; use crate::example::example; + use crate::example::ExampleRngState; use crate::example::ExampleSystemBuilder; use crate::system::SledBuilder; use expectorate::assert_contents; @@ -2118,8 +2118,13 @@ pub mod test { fn test_basic() { static TEST_NAME: &str = "blueprint_builder_test_basic"; let logctx = test_setup_log(TEST_NAME); - let (mut example, blueprint1) = - ExampleSystemBuilder::new(&logctx.log, TEST_NAME).build(); + + let mut rng = ExampleRngState::from_seed(TEST_NAME); + let (mut example, blueprint1) = ExampleSystemBuilder::new_with_rng( + &logctx.log, + rng.next_system_rng(), + ) + .build(); verify_blueprint(&blueprint1); let mut builder = BlueprintBuilder::new_based_on( @@ -2155,7 +2160,9 @@ pub mod test { assert_eq!(diff.sleds_modified.len(), 0); // The next step is adding these zones to a new sled. - let new_sled_id = example.sled_rng.next(); + let mut sled_id_rng = rng.next_sled_id_rng(); + let new_sled_id = sled_id_rng.next(); + let _ = example.system.sled(SledBuilder::new().id(new_sled_id)).unwrap(); let input = example.system.to_planning_input_builder().unwrap().build(); diff --git a/nexus/reconfigurator/planning/src/blueprint_builder/zones.rs b/nexus/reconfigurator/planning/src/blueprint_builder/zones.rs index 25db378ee7..0c8298b476 100644 --- a/nexus/reconfigurator/planning/src/blueprint_builder/zones.rs +++ b/nexus/reconfigurator/planning/src/blueprint_builder/zones.rs @@ -241,8 +241,11 @@ mod tests { use omicron_uuid_kinds::ZpoolUuid; use crate::{ - blueprint_builder::{test::verify_blueprint, BlueprintBuilder, Ensure}, - example::ExampleSystemBuilder, + blueprint_builder::{ + test::verify_blueprint, BlueprintBuilder, BlueprintBuilderRng, + Ensure, + }, + example::{ExampleRngState, ExampleSystemBuilder}, }; use super::*; @@ -252,13 +255,21 @@ mod tests { fn test_builder_zones() { static TEST_NAME: &str = "blueprint_test_builder_zones"; let logctx = test_setup_log(TEST_NAME); - let (mut example, blueprint_initial) = - ExampleSystemBuilder::new(&logctx.log, TEST_NAME).build(); + + let mut rng = ExampleRngState::from_seed(TEST_NAME); + let (example, blueprint_initial) = ExampleSystemBuilder::new_with_rng( + &logctx.log, + rng.next_system_rng(), + ) + .build(); // Add a completely bare sled to the input. let (new_sled_id, input2) = { + let mut sled_id_rng = rng.next_sled_id_rng(); + let new_sled_id = sled_id_rng.next(); + let mut input = example.input.clone().into_builder(); - let new_sled_id = example.sled_rng.next(); + input .add_sled( new_sled_id, @@ -304,7 +315,7 @@ mod tests { "the_test", ) .expect("creating blueprint builder"); - builder.set_rng_seed((TEST_NAME, "bp2")); + builder.set_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))); // Test adding a new sled with an NTP zone. assert_eq!( @@ -476,7 +487,7 @@ mod tests { "the_test", ) .expect("creating blueprint builder"); - builder.set_rng_seed((TEST_NAME, "bp2")); + builder.set_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))); // This call by itself shouldn't bump the generation number. builder.zones.change_sled_zones(existing_sled_id); diff --git a/nexus/reconfigurator/planning/src/example.rs b/nexus/reconfigurator/planning/src/example.rs index 16d7a00684..1c1990b09b 100644 --- a/nexus/reconfigurator/planning/src/example.rs +++ b/nexus/reconfigurator/planning/src/example.rs @@ -4,12 +4,16 @@ //! Example blueprints +use std::fmt; +use std::hash::Hash; use std::net::IpAddr; use std::net::Ipv4Addr; use crate::blueprint_builder::BlueprintBuilder; +use crate::blueprint_builder::BlueprintBuilderRng; use crate::system::SledBuilder; use crate::system::SystemDescription; +use nexus_inventory::CollectionBuilderRng; use nexus_types::deployment::Blueprint; use nexus_types::deployment::BlueprintZoneFilter; use nexus_types::deployment::OmicronZoneNic; @@ -22,18 +26,137 @@ use omicron_uuid_kinds::SledKind; use omicron_uuid_kinds::VnicUuid; use typed_rng::TypedUuidRng; +/// Stateful PRNG for generating example systems. +/// +/// When generating a succession of example systems, this stateful PRNG allows +/// for reproducible generation of those systems after setting an initial seed. +/// The PRNGs are structured in tree form as much as possible, so that (for +/// example) if one part of the system decides to change how many sleds are in +/// the system, it can do so without affecting other UUIDs. +/// +/// We have a number of existing tests that manually set seeds for individual +/// RNG instances. The old-style seeds have been kept around for backwards +/// compatibility. Newer tests should use this struct to generate their RNGs +/// instead, since it conveniently tracks generation numbers for each seed. +#[derive(Clone, Debug)] +pub struct ExampleRngState { + seed: String, + // Generation numbers for each RNG type. + system_rng_gen: u64, + collection_rng_gen: u64, + blueprint_rng_gen: u64, + // TODO: Currently, sled IDs are used to generate UUIDs to mutate the + // system. We should replace it with a more general way to mutate systems, + // e.g. by setting up a chained succession of `ExampleSystem` instances. + // Once we have that, we should no longer need this field. + sled_id_rng_gen: u64, +} + +impl ExampleRngState { + pub fn from_seed(seed: &str) -> Self { + Self { + seed: seed.to_string(), + system_rng_gen: 0, + collection_rng_gen: 0, + blueprint_rng_gen: 0, + sled_id_rng_gen: 0, + } + } + + pub fn seed(&self) -> &str { + &self.seed + } + + pub fn next_system_rng(&mut self) -> ExampleSystemRng { + // Different behavior for the first system_rng_gen is a bit weird, but + // it retains backwards compatibility with existing tests -- it means + // that generated UUIDs particularly in fixtures don't change. + self.system_rng_gen += 1; + if self.system_rng_gen == 1 { + ExampleSystemRng::from_seed(self.seed.as_str()) + } else { + ExampleSystemRng::from_seed(( + self.seed.as_str(), + self.system_rng_gen, + )) + } + } + + pub fn next_collection_rng(&mut self) -> CollectionBuilderRng { + self.collection_rng_gen += 1; + // We don't need to pass in extra bits unique to collections, because + // `CollectionBuilderRng` adds its own. + let seed = (self.seed.as_str(), self.collection_rng_gen); + CollectionBuilderRng::from_seed(seed) + } + + pub fn next_blueprint_rng(&mut self) -> BlueprintBuilderRng { + self.blueprint_rng_gen += 1; + // We don't need to pass in extra bits unique to blueprints, because + // `BlueprintBuilderRng` adds its own. + BlueprintBuilderRng::from_seed(( + self.seed.as_str(), + self.blueprint_rng_gen, + )) + } + + pub fn next_sled_id_rng(&mut self) -> TypedUuidRng { + self.sled_id_rng_gen += 1; + TypedUuidRng::from_seed( + self.seed.as_str(), + ("sled-id-rng", self.sled_id_rng_gen), + ) + } +} + +#[derive(Debug, Clone)] +pub struct ExampleSystemRng { + seed: String, + sled_rng: TypedUuidRng, + collection_rng: CollectionBuilderRng, + // ExampleSystem instances generate two blueprints: create RNGs for both. + blueprint1_rng: BlueprintBuilderRng, + blueprint2_rng: BlueprintBuilderRng, +} + +impl ExampleSystemRng { + pub fn from_seed(seed: H) -> Self { + // This is merely "ExampleSystem" for backwards compatibility with + // existing test fixtures. + let sled_rng = TypedUuidRng::from_seed(&seed, "ExampleSystem"); + // We choose to make our own collection and blueprint RNGs rather than + // passing them in via `ExampleRngState`. This means that + // `ExampleRngState` is influenced as little as possible by the + // specifics of how `ExampleSystem` instances are generated, and RNG + // stability is maintained. + let collection_rng = CollectionBuilderRng::from_seed(( + &seed, + "ExampleSystem collection", + )); + let blueprint1_rng = + BlueprintBuilderRng::from_seed((&seed, "ExampleSystem initial")); + let blueprint2_rng = + BlueprintBuilderRng::from_seed((&seed, "ExampleSystem make_zones")); + Self { + seed: format!("{:?}", seed), + sled_rng, + collection_rng, + blueprint1_rng, + blueprint2_rng, + } + } +} + +/// An example generated system, along with a consistent planning input and +/// collection. +/// +/// The components of this struct are generated together and match each other. +/// The planning input and collection represent database input and inventory +/// that would be collected from a system matching the system description. pub struct ExampleSystem { pub system: SystemDescription, pub input: PlanningInput, pub collection: Collection, - // If we add more types of RNGs than just sleds here, we'll need to - // expand this to be similar to BlueprintBuilderRng where a root RNG - // creates sub-RNGs. - // - // This is currently only used for tests, so it looks unused in normal - // builds. But in the future it could be used by other consumers, too. - #[allow(dead_code)] - pub(crate) sled_rng: TypedUuidRng, } /// Returns a collection, planning input, and blueprint describing a pretty @@ -53,7 +176,7 @@ pub fn example( #[derive(Debug, Clone)] pub struct ExampleSystemBuilder { log: slog::Logger, - test_name: String, + rng: ExampleSystemRng, // TODO: Store a Policy struct instead of these fields: // https://github.com/oxidecomputer/omicron/issues/6803 nsleds: usize, @@ -77,9 +200,17 @@ impl ExampleSystemBuilder { pub const DEFAULT_EXTERNAL_DNS_COUNT: usize = 0; pub fn new(log: &slog::Logger, test_name: &str) -> Self { + let rng = ExampleSystemRng::from_seed(test_name); + Self::new_with_rng(log, rng) + } + + pub fn new_with_rng(log: &slog::Logger, rng: ExampleSystemRng) -> Self { Self { - log: log.new(slog::o!("component" => "ExampleSystem", "test_name" => test_name.to_string())), - test_name: test_name.to_string(), + log: log.new(slog::o!( + "component" => "ExampleSystem", + "rng_seed" => rng.seed.clone(), + )), + rng, nsleds: Self::DEFAULT_N_SLEDS, ndisks_per_sled: SledBuilder::DEFAULT_NPOOLS, nexus_count: None, @@ -204,16 +335,16 @@ impl ExampleSystemBuilder { "create_disks_in_blueprint" => self.create_disks_in_blueprint, ); + let mut rng = self.rng.clone(); + let mut system = SystemDescription::new(); // Update the system's target counts with the counts. (Note that // there's no external DNS count.) system .target_nexus_zone_count(nexus_count.0) .target_internal_dns_zone_count(self.internal_dns_count.0); - let mut sled_rng = - TypedUuidRng::from_seed(&self.test_name, "ExampleSystem"); let sled_ids: Vec<_> = - (0..self.nsleds).map(|_| sled_rng.next()).collect(); + (0..self.nsleds).map(|_| rng.sled_rng.next()).collect(); for sled_id in &sled_ids { let _ = system @@ -234,7 +365,7 @@ impl ExampleSystemBuilder { let initial_blueprint = BlueprintBuilder::build_empty_with_sleds_seeded( base_input.all_sled_ids(SledFilter::Commissioned), "test suite", - (&self.test_name, "ExampleSystem initial"), + rng.blueprint1_rng, ); // Start with an empty collection @@ -252,7 +383,7 @@ impl ExampleSystemBuilder { "test suite", ) .unwrap(); - builder.set_rng_seed((&self.test_name, "ExampleSystem make_zones")); + builder.set_rng(rng.blueprint2_rng); // Add as many external IPs as is necessary for external DNS zones. We // pick addresses in the TEST-NET-2 (RFC 5737) range. @@ -358,7 +489,7 @@ impl ExampleSystemBuilder { let mut builder = system.to_collection_builder().expect("failed to build collection"); - builder.set_rng_seed((&self.test_name, "ExampleSystem collection")); + builder.set_rng(rng.collection_rng); // The blueprint evolves separately from the system -- so it's returned // as a separate value. @@ -366,7 +497,6 @@ impl ExampleSystemBuilder { system, input: input_builder.build(), collection: builder.build(), - sled_rng, }; (example, blueprint) } diff --git a/nexus/reconfigurator/planning/src/planner.rs b/nexus/reconfigurator/planning/src/planner.rs index ed59689218..cd08711e6e 100644 --- a/nexus/reconfigurator/planning/src/planner.rs +++ b/nexus/reconfigurator/planning/src/planner.rs @@ -7,6 +7,7 @@ //! See crate-level documentation for details. use crate::blueprint_builder::BlueprintBuilder; +use crate::blueprint_builder::BlueprintBuilderRng; use crate::blueprint_builder::Ensure; use crate::blueprint_builder::EnsureMultiple; use crate::blueprint_builder::Error; @@ -32,7 +33,6 @@ use slog::error; use slog::{info, warn, Logger}; use std::collections::BTreeMap; use std::collections::BTreeSet; -use std::hash::Hash; use std::str::FromStr; pub(crate) use self::omicron_zone_placement::DiscretionaryOmicronZone; @@ -81,10 +81,10 @@ impl<'a> Planner<'a> { /// /// This will ensure that tests that use this builder will produce the same /// results each time they are run. - pub fn with_rng_seed(mut self, seed: H) -> Self { + pub fn with_rng(mut self, rng: BlueprintBuilderRng) -> Self { // This is an owned builder because it is almost never going to be // conditional. - self.blueprint.set_rng_seed(seed); + self.blueprint.set_rng(rng); self } @@ -795,8 +795,10 @@ mod test { use crate::blueprint_builder::test::assert_planning_makes_no_changes; use crate::blueprint_builder::test::verify_blueprint; use crate::blueprint_builder::BlueprintBuilder; + use crate::blueprint_builder::BlueprintBuilderRng; use crate::blueprint_builder::EnsureMultiple; use crate::example::example; + use crate::example::ExampleRngState; use crate::example::ExampleSystemBuilder; use crate::system::SledBuilder; use chrono::NaiveDateTime; @@ -839,8 +841,12 @@ mod test { let logctx = test_setup_log(TEST_NAME); // Use our example system. - let (mut example, blueprint1) = - ExampleSystemBuilder::new(&logctx.log, TEST_NAME).build(); + let mut rng = ExampleRngState::from_seed(TEST_NAME); + let (mut example, blueprint1) = ExampleSystemBuilder::new_with_rng( + &logctx.log, + rng.next_system_rng(), + ) + .build(); verify_blueprint(&blueprint1); println!("{}", blueprint1.display()); @@ -856,7 +862,7 @@ mod test { &example.collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -874,7 +880,8 @@ mod test { verify_blueprint(&blueprint2); // Now add a new sled. - let new_sled_id = example.sled_rng.next(); + let mut sled_id_rng = rng.next_sled_id_rng(); + let new_sled_id = sled_id_rng.next(); let _ = example.system.sled(SledBuilder::new().id(new_sled_id)).unwrap(); let input = example.system.to_planning_input_builder().unwrap().build(); @@ -888,7 +895,7 @@ mod test { &example.collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("failed to plan"); @@ -926,7 +933,7 @@ mod test { &example.collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp4")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp4"))) .plan() .expect("failed to plan"); let diff = blueprint4.diff_since_blueprint(&blueprint3); @@ -965,7 +972,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp5")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp5"))) .plan() .expect("failed to plan"); @@ -1060,7 +1067,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1132,7 +1139,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1242,7 +1249,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1320,7 +1327,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1354,7 +1361,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("failed to plan"); @@ -1487,7 +1494,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1522,7 +1529,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("failed to re-plan"); @@ -1634,7 +1641,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1719,7 +1726,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1841,7 +1848,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -1977,7 +1984,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -2202,7 +2209,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); @@ -2248,7 +2255,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("succeeded in planner"); @@ -2298,7 +2305,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp4")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp4"))) .plan() .expect("succeeded in planner"); @@ -2357,7 +2364,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to plan"); assert_eq!(bp2.cockroachdb_fingerprint, "bp2"); @@ -2384,7 +2391,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("failed to plan"); assert_eq!(bp3.cockroachdb_fingerprint, "bp3"); @@ -2409,7 +2416,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp4")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp4"))) .plan() .expect("failed to plan"); assert_eq!(bp4.cockroachdb_fingerprint, "bp4"); @@ -2438,7 +2445,10 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, format!("bp5-{}", preserve_downgrade))) + .with_rng(BlueprintBuilderRng::from_seed(( + TEST_NAME, + format!("bp5-{}", preserve_downgrade), + ))) .plan() .expect("failed to plan"); assert_eq!(bp5.cockroachdb_fingerprint, "bp5"); @@ -2493,7 +2503,7 @@ mod test { &collection, ) .expect("failed to create planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("failed to re-plan"); @@ -2556,7 +2566,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("plan"); @@ -2618,7 +2628,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("plan"); @@ -2659,7 +2669,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp4")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp4"))) .plan() .expect("plan"); @@ -2701,7 +2711,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp5")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp5"))) .plan() .expect("plan"); @@ -2742,7 +2752,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp6")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp6"))) .plan() .expect("plan"); @@ -2773,7 +2783,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp7")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp7"))) .plan() .expect("plan"); @@ -2816,7 +2826,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp8")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp8"))) .plan() .expect("plan"); @@ -2869,7 +2879,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("plan"); @@ -2927,7 +2937,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("plan"); @@ -2965,7 +2975,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp4")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp4"))) .plan() .expect("plan"); @@ -2990,7 +3000,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp5")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp5"))) .plan() .expect("plan"); @@ -3023,7 +3033,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp6")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp6"))) .plan() .expect("plan"); @@ -3076,7 +3086,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp2")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp2"))) .plan() .expect("plan"); @@ -3114,7 +3124,7 @@ mod test { &collection, ) .expect("created planner") - .with_rng_seed((TEST_NAME, "bp3")) + .with_rng(BlueprintBuilderRng::from_seed((TEST_NAME, "bp3"))) .plan() .expect("plan"); diff --git a/nexus/reconfigurator/planning/src/system.rs b/nexus/reconfigurator/planning/src/system.rs index 39d25fc2de..ff55265166 100644 --- a/nexus/reconfigurator/planning/src/system.rs +++ b/nexus/reconfigurator/planning/src/system.rs @@ -9,6 +9,8 @@ use anyhow::{anyhow, bail, ensure, Context}; use gateway_client::types::RotState; use gateway_client::types::SpState; use indexmap::IndexMap; +use ipnet::Ipv6Net; +use ipnet::Ipv6Subnets; use nexus_inventory::CollectionBuilder; use nexus_sled_agent_shared::inventory::Baseboard; use nexus_sled_agent_shared::inventory::Inventory; @@ -50,12 +52,7 @@ use std::collections::BTreeSet; use std::fmt::Debug; use std::net::Ipv4Addr; use std::net::Ipv6Addr; - -trait SubnetIterator: Iterator> + Debug {} -impl SubnetIterator for T where - T: Iterator> + Debug -{ -} +use std::sync::Arc; /// Describes an actual or synthetic Oxide rack for planning and testing /// @@ -73,11 +70,15 @@ impl SubnetIterator for T where /// assign subnets and maybe even lay out the initial set of zones (which /// does not exist here yet). This way Reconfigurator and RSS are using the /// same code to do this. -#[derive(Debug)] +/// +/// This is cheaply cloneable, and uses copy-on-write semantics for data inside. +#[derive(Clone, Debug)] pub struct SystemDescription { collector: Option, - sleds: IndexMap, - sled_subnets: Box, + // Arc to make cloning cheap. Mutating sleds is uncommon but + // possible, in which case we'll clone-on-write with Arc::make_mut. + sleds: IndexMap>, + sled_subnets: SubnetIterator, available_non_scrimlet_slots: BTreeSet, available_scrimlet_slots: BTreeSet, target_boundary_ntp_zone_count: usize, @@ -124,13 +125,7 @@ impl SystemDescription { // Skip the initial DNS subnet. // (The same behavior is replicated in RSS in `Plan::create()` in // sled-agent/src/rack_setup/plan/sled.rs.) - let sled_subnets = Box::new( - rack_subnet - .subnets(SLED_PREFIX) - .unwrap() - .skip(1) - .map(|s| Ipv6Subnet::new(s.network())), - ); + let sled_subnets = SubnetIterator::new(rack_subnet); // Policy defaults let target_nexus_zone_count = NEXUS_REDUNDANCY; @@ -283,7 +278,7 @@ impl SystemDescription { sled.omicron_zones, sled.npools, ); - self.sleds.insert(sled_id, sled); + self.sleds.insert(sled_id, Arc::new(sled)); Ok(self) } @@ -309,14 +304,14 @@ impl SystemDescription { ); self.sleds.insert( sled_id, - Sled::new_full( + Arc::new(Sled::new_full( sled_id, sled_policy, sled_state, sled_resources, inventory_sp, inventory_sled_agent, - ), + )), ); Ok(self) } @@ -345,7 +340,7 @@ impl SystemDescription { let sled = self.sleds.get_mut(&sled_id).with_context(|| { format!("attempted to access sled {} not found in system", sled_id) })?; - sled.inventory_sled_agent.omicron_zones = omicron_zones; + Arc::make_mut(sled).inventory_sled_agent.omicron_zones = omicron_zones; Ok(self) } @@ -811,3 +806,27 @@ impl Sled { &self.inventory_sled_agent } } + +#[derive(Clone, Copy, Debug)] +struct SubnetIterator { + subnets: Ipv6Subnets, +} + +impl SubnetIterator { + fn new(rack_subnet: Ipv6Net) -> Self { + let mut subnets = rack_subnet.subnets(SLED_PREFIX).unwrap(); + // Skip the initial DNS subnet. + // (The same behavior is replicated in RSS in `Plan::create()` in + // sled-agent/src/rack_setup/plan/sled.rs.) + subnets.next(); + Self { subnets } + } +} + +impl Iterator for SubnetIterator { + type Item = Ipv6Subnet; + + fn next(&mut self) -> Option { + self.subnets.next().map(|s| Ipv6Subnet::new(s.network())) + } +} diff --git a/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_2_3.txt b/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_2_3.txt index e292093fd8..b336536689 100644 --- a/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_2_3.txt +++ b/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_2_3.txt @@ -117,22 +117,22 @@ to: blueprint 4171ad05-89dd-474b-846b-b007e4346366 ADDED SLEDS: - sled b59ec570-2abb-4017-80ce-129d94e7a025 (active): + sled ec61eded-c34f-443d-a580-dadf757529c4 (active): physical disks at generation 1: ---------------------------------------------------------------------- vendor model serial ---------------------------------------------------------------------- -+ fake-vendor fake-model serial-1bb5ee5d-c2c6-4eaa-86c4-817d89cf10cf -+ fake-vendor fake-model serial-298d1eec-0313-4a42-8af9-0e51299a14ef -+ fake-vendor fake-model serial-2eed666f-a10b-42d0-b626-68335d3270b8 -+ fake-vendor fake-model serial-6cc4d7a7-2a89-4f2f-aa55-5e7a10d0fc08 -+ fake-vendor fake-model serial-7aad6fd9-b698-4c77-af6b-947be10ba953 -+ fake-vendor fake-model serial-a5a15e51-c48a-40e4-a2d8-1c7198c1d46b -+ fake-vendor fake-model serial-b81d4993-ea5b-4720-b8c8-2360c1121d6e -+ fake-vendor fake-model serial-d0064c4d-f5f7-4c89-9f37-0ca475048e79 -+ fake-vendor fake-model serial-dba739c1-76e4-4b6a-a173-89c938fa13ef -+ fake-vendor fake-model serial-e6f289fe-142e-4778-8629-dc87adb53f06 ++ fake-vendor fake-model serial-28699448-c5d9-49ea-bf7e-627800efe783 ++ fake-vendor fake-model serial-2c490e96-27f2-4a7f-b440-04d4bfd1e4f6 ++ fake-vendor fake-model serial-4c3bb1c7-55b6-49b8-b212-516b8f2c26c2 ++ fake-vendor fake-model serial-5db07562-31a8-43e3-b99e-7c7cb89754b7 ++ fake-vendor fake-model serial-9451a5d5-b358-4719-b6c1-a0d187da217c ++ fake-vendor fake-model serial-bb2e2869-9481-483a-bc49-2bdd62f515f5 ++ fake-vendor fake-model serial-d5a36c66-4b2f-46e6-96f4-b82debee1a4a ++ fake-vendor fake-model serial-f99ec996-ec08-4ccf-9a6e-6c5cab440fb4 ++ fake-vendor fake-model serial-faccbb39-d686-42a1-a50a-0eb59ba74a87 ++ fake-vendor fake-model serial-fdfd067b-1d86-444d-a21f-ed33709f3e4d omicron zones at generation 2: diff --git a/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_3_5.txt b/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_3_5.txt index 65629706cf..1be7d67c94 100644 --- a/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_3_5.txt +++ b/nexus/reconfigurator/planning/tests/output/planner_basic_add_sled_3_5.txt @@ -117,22 +117,22 @@ to: blueprint f432fcd5-1284-4058-8b4a-9286a3de6163 MODIFIED SLEDS: - sled b59ec570-2abb-4017-80ce-129d94e7a025 (active): + sled ec61eded-c34f-443d-a580-dadf757529c4 (active): physical disks at generation 1: ---------------------------------------------------------------------- vendor model serial ---------------------------------------------------------------------- - fake-vendor fake-model serial-1bb5ee5d-c2c6-4eaa-86c4-817d89cf10cf - fake-vendor fake-model serial-298d1eec-0313-4a42-8af9-0e51299a14ef - fake-vendor fake-model serial-2eed666f-a10b-42d0-b626-68335d3270b8 - fake-vendor fake-model serial-6cc4d7a7-2a89-4f2f-aa55-5e7a10d0fc08 - fake-vendor fake-model serial-7aad6fd9-b698-4c77-af6b-947be10ba953 - fake-vendor fake-model serial-a5a15e51-c48a-40e4-a2d8-1c7198c1d46b - fake-vendor fake-model serial-b81d4993-ea5b-4720-b8c8-2360c1121d6e - fake-vendor fake-model serial-d0064c4d-f5f7-4c89-9f37-0ca475048e79 - fake-vendor fake-model serial-dba739c1-76e4-4b6a-a173-89c938fa13ef - fake-vendor fake-model serial-e6f289fe-142e-4778-8629-dc87adb53f06 + fake-vendor fake-model serial-28699448-c5d9-49ea-bf7e-627800efe783 + fake-vendor fake-model serial-2c490e96-27f2-4a7f-b440-04d4bfd1e4f6 + fake-vendor fake-model serial-4c3bb1c7-55b6-49b8-b212-516b8f2c26c2 + fake-vendor fake-model serial-5db07562-31a8-43e3-b99e-7c7cb89754b7 + fake-vendor fake-model serial-9451a5d5-b358-4719-b6c1-a0d187da217c + fake-vendor fake-model serial-bb2e2869-9481-483a-bc49-2bdd62f515f5 + fake-vendor fake-model serial-d5a36c66-4b2f-46e6-96f4-b82debee1a4a + fake-vendor fake-model serial-f99ec996-ec08-4ccf-9a6e-6c5cab440fb4 + fake-vendor fake-model serial-faccbb39-d686-42a1-a50a-0eb59ba74a87 + fake-vendor fake-model serial-fdfd067b-1d86-444d-a21f-ed33709f3e4d omicron zones generation 2 -> 3: diff --git a/nexus/reconfigurator/simulation/Cargo.toml b/nexus/reconfigurator/simulation/Cargo.toml new file mode 100644 index 0000000000..6308ee345c --- /dev/null +++ b/nexus/reconfigurator/simulation/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "nexus-reconfigurator-simulation" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +indexmap.workspace = true +nexus-inventory.workspace = true +nexus-reconfigurator-planning.workspace = true +nexus-types.workspace = true +omicron-common.workspace = true +omicron-uuid-kinds.workspace = true +omicron-workspace-hack.workspace = true +petname = { workspace = true, default-features = false } +slog.workspace = true +thiserror.workspace = true +typed-rng.workspace = true +uuid.workspace = true diff --git a/nexus/reconfigurator/simulation/src/config.rs b/nexus/reconfigurator/simulation/src/config.rs new file mode 100644 index 0000000000..b8f639012a --- /dev/null +++ b/nexus/reconfigurator/simulation/src/config.rs @@ -0,0 +1,135 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::ops::Deref; + +use indexmap::IndexSet; +use omicron_common::api::external::Name; + +use crate::errors::{DuplicateError, MissingError}; + +/// Versioned simulator configuration. +/// +/// This is part of the state that is versioned and stored in the store. +#[derive(Clone, Debug)] +pub struct SimConfig { + /// Set of silo names configured + /// + /// These are used to determine the contents of external DNS. + silo_names: IndexSet, + + /// External DNS zone name configured + external_dns_zone_name: String, + + /// The number of Nexus zones to create. + /// + /// TODO: This doesn't quite fit in here because it's more of a policy + /// setting than a config option. But we can't set it in the + /// `SystemDescription` because need to persist policy across system wipes. + /// So users have to remember to set num_nexus twice: once in the config + /// and once in the policy. + /// + /// We can likely make this better after addressing + /// https://github.com/oxidecomputer/omicron/issues/6803. + num_nexus: Option, +} + +impl SimConfig { + pub(crate) fn new() -> Self { + Self { + // We use "example-silo" here rather than "default-silo" to make it + // clear that we're in a test environment. + silo_names: std::iter::once("example-silo".parse().unwrap()) + .collect(), + external_dns_zone_name: String::from("oxide.example"), + num_nexus: None, + } + } + + #[inline] + pub fn silo_names(&self) -> impl ExactSizeIterator { + self.silo_names.iter() + } + + #[inline] + pub fn external_dns_zone_name(&self) -> &str { + &self.external_dns_zone_name + } + + #[inline] + pub fn num_nexus(&self) -> Option { + self.num_nexus + } + + pub(crate) fn to_mut(&self) -> MutableSimConfig { + MutableSimConfig { config: self.clone(), log: Vec::new() } + } +} + +#[derive(Clone, Debug)] +pub struct MutableSimConfig { + config: SimConfig, + log: Vec, +} + +impl MutableSimConfig { + pub fn set_silo_names(&mut self, names: impl IntoIterator) { + self.config.silo_names = names.into_iter().collect(); + self.log.push(SimConfigLogEntry::SetSiloNames( + self.config.silo_names.clone(), + )); + } + + pub fn add_silo(&mut self, name: Name) -> Result<(), DuplicateError> { + if self.config.silo_names.contains(&name) { + return Err(DuplicateError::silo_name(name)); + } + self.config.silo_names.insert(name.clone()); + self.log.push(SimConfigLogEntry::AddSilo(name)); + Ok(()) + } + + pub fn remove_silo(&mut self, name: Name) -> Result<(), MissingError> { + if !self.config.silo_names.shift_remove(&name) { + return Err(MissingError::silo_name(name)); + } + self.log.push(SimConfigLogEntry::RemoveSilo(name)); + Ok(()) + } + + pub fn set_external_dns_zone_name(&mut self, name: String) { + self.config.external_dns_zone_name = name.clone(); + self.log.push(SimConfigLogEntry::SetExternalDnsZoneName(name)); + } + + pub fn set_num_nexus(&mut self, num_nexus: u16) { + self.config.num_nexus = Some(num_nexus); + } + + pub fn wipe(&mut self) { + self.config = SimConfig::new(); + self.log.push(SimConfigLogEntry::Wipe); + } + + pub(crate) fn into_parts(self) -> (SimConfig, Vec) { + (self.config, self.log) + } +} + +impl Deref for MutableSimConfig { + type Target = SimConfig; + + fn deref(&self) -> &Self::Target { + &self.config + } +} + +#[derive(Clone, Debug)] +pub enum SimConfigLogEntry { + AddSilo(Name), + RemoveSilo(Name), + SetSiloNames(IndexSet), + SetExternalDnsZoneName(String), + Wipe, +} diff --git a/nexus/reconfigurator/simulation/src/errors.rs b/nexus/reconfigurator/simulation/src/errors.rs new file mode 100644 index 0000000000..084658fa2f --- /dev/null +++ b/nexus/reconfigurator/simulation/src/errors.rs @@ -0,0 +1,131 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use std::sync::Arc; + +use nexus_types::{ + deployment::Blueprint, internal_api::params::DnsConfigParams, + inventory::Collection, +}; +use omicron_common::api::external::Name; +use omicron_uuid_kinds::CollectionUuid; +use thiserror::Error; +use uuid::Uuid; + +/// The caller attempted to insert a duplicate key. +#[derive(Clone, Debug, Error)] +#[error("attempted to insert duplicate value: {}", self.kind.to_error_string())] +pub struct DuplicateError { + kind: DuplicateErrorKind, +} + +impl DuplicateError { + pub fn collection(collection: Arc) -> Self { + Self { kind: DuplicateErrorKind::Collection(collection) } + } + + pub fn blueprint(blueprint: Arc) -> Self { + Self { kind: DuplicateErrorKind::Blueprint(blueprint) } + } + + pub fn internal_dns(dns: Arc) -> Self { + Self { kind: DuplicateErrorKind::InternalDns(dns) } + } + + pub fn external_dns(dns: Arc) -> Self { + Self { kind: DuplicateErrorKind::ExternalDns(dns) } + } + + pub fn silo_name(name: Name) -> Self { + Self { kind: DuplicateErrorKind::SiloName(name) } + } +} + +#[derive(Clone, Debug)] +pub enum DuplicateErrorKind { + // TODO(rain): just store IDs here + Collection(Arc), + Blueprint(Arc), + InternalDns(Arc), + ExternalDns(Arc), + SiloName(Name), +} + +impl DuplicateErrorKind { + fn to_error_string(&self) -> String { + match self { + DuplicateErrorKind::Collection(c) => { + format!("collection ID {}", c.id) + } + DuplicateErrorKind::Blueprint(b) => { + format!("blueprint ID {}", b.id) + } + DuplicateErrorKind::InternalDns(params) => { + format!("internal DNS at generation {}", params.generation) + } + DuplicateErrorKind::ExternalDns(params) => { + format!("external DNS at generation {}", params.generation) + } + DuplicateErrorKind::SiloName(name) => { + format!("silo name {}", name) + } + } + } +} + +/// The caller attempted to remove a key that does not exist. +#[derive(Clone, Debug, Error)] +#[error("no such value: {}", self.kind.to_error_string())] +pub struct MissingError { + kind: MissingErrorKind, +} + +impl MissingError { + pub fn collection(id: CollectionUuid) -> Self { + Self { kind: MissingErrorKind::Collection(id) } + } + + pub fn blueprint(id: Uuid) -> Self { + Self { kind: MissingErrorKind::Blueprint(id) } + } + + pub fn silo_name(name: Name) -> Self { + Self { kind: MissingErrorKind::SiloName(name) } + } +} + +#[derive(Clone, Debug)] +pub enum MissingErrorKind { + Collection(CollectionUuid), + Blueprint(Uuid), + SiloName(Name), +} + +impl MissingErrorKind { + fn to_error_string(&self) -> String { + match self { + MissingErrorKind::Collection(id) => { + format!("collection ID {}", id) + } + MissingErrorKind::Blueprint(id) => { + format!("blueprint ID {}", id) + } + MissingErrorKind::SiloName(name) => { + format!("silo name {}", name) + } + } + } +} + +/// An operation that requires an empty system was performed on a non-empty +/// system. +#[derive(Clone, Debug, Error)] +#[error("operation requires an empty system")] +pub struct NonEmptySystemError {} + +impl NonEmptySystemError { + pub(crate) fn new() -> Self { + Self {} + } +} diff --git a/nexus/reconfigurator/simulation/src/lib.rs b/nexus/reconfigurator/simulation/src/lib.rs new file mode 100644 index 0000000000..09985679d2 --- /dev/null +++ b/nexus/reconfigurator/simulation/src/lib.rs @@ -0,0 +1,23 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Simulation of reconfigurator states. +//! +//! This library contains facilities to track and simulate successive system +//! states in the face of reconfiguration events. The library uses an operation +//! log internally to make it possible to rewind to previous states. + +mod config; +pub mod errors; +mod policy; +mod rng; +mod sim; +mod state; +mod system; + +pub use config::*; +pub use rng::*; +pub use sim::*; +pub use state::*; +pub use system::*; diff --git a/nexus/reconfigurator/simulation/src/policy.rs b/nexus/reconfigurator/simulation/src/policy.rs new file mode 100644 index 0000000000..7be716e270 --- /dev/null +++ b/nexus/reconfigurator/simulation/src/policy.rs @@ -0,0 +1,3 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. diff --git a/nexus/reconfigurator/simulation/src/rng.rs b/nexus/reconfigurator/simulation/src/rng.rs new file mode 100644 index 0000000000..590f347b0c --- /dev/null +++ b/nexus/reconfigurator/simulation/src/rng.rs @@ -0,0 +1,103 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! Versioned random number generation for the simulator. + +use nexus_inventory::CollectionBuilderRng; +use nexus_reconfigurator_planning::{ + blueprint_builder::BlueprintBuilderRng, + example::{ExampleRngState, ExampleSystemRng}, +}; +use omicron_uuid_kinds::SledUuid; + +/// Versioned random number generator for the simulator. +/// +/// The simulator is designed to be as deterministic as possible, so that +/// simulations can be replayed and compared. To that end, this RNG is +/// versioned. +#[derive(Clone, Debug)] +pub struct SimRng { + // ExampleRngState is cheap to clone (just a string and a bunch of + // integers), so there's no need for Arc. + state: ExampleRngState, +} + +impl SimRng { + /// Create a new RNG. + pub fn from_entropy() -> Self { + let seed = seed_from_entropy(); + let state = ExampleRngState::from_seed(&seed); + Self { state } + } + + pub fn from_seed(seed: String) -> Self { + let state = ExampleRngState::from_seed(&seed); + Self { state } + } + + /// Obtain the current seed. + pub fn seed(&self) -> &str { + self.state.seed() + } + + /// Set a new seed for the RNG, resetting internal state. + pub fn set_seed(&mut self, seed: String) { + self.state = ExampleRngState::from_seed(&seed); + } + + /// Get the next example system RNG. + #[inline] + pub fn next_example_rng(&mut self) -> ExampleSystemRng { + self.state.next_system_rng() + } + + /// Get the next collection RNG. + #[inline] + pub fn next_collection_rng(&mut self) -> CollectionBuilderRng { + self.state.next_collection_rng() + } + + /// Get the next blueprint RNG. + #[inline] + pub fn next_blueprint_rng(&mut self) -> BlueprintBuilderRng { + self.state.next_blueprint_rng() + } + + /// Get the next sled ID. + #[inline] + #[must_use] + pub fn next_sled_id(&mut self) -> SledUuid { + self.state.next_sled_id_rng().next() + } + + /// Reset internal state while keeping the same seed. + /// + /// The RNGs are stateful, so it can be useful to reset them back to their + /// initial state. + /// + /// In general, it only makes sense to call this as part of a system wipe. + /// If it is called outside of a system wipe, then duplicate IDs might be + /// generated. + pub fn reset_state(&mut self) { + self.state = ExampleRngState::from_seed(self.state.seed()); + } + + /// Regenerate a new seed for the RNG, resetting internal state. + /// + /// The seed is returned, and the caller is expected to display it to the + /// user. + #[must_use] + pub fn regenerate_seed(&mut self) -> String { + let seed = seed_from_entropy(); + self.set_seed(seed.clone()); + seed + } +} + +pub(crate) fn seed_from_entropy() -> String { + // Using underscores as the separator makes the seed easier to double-click + // copy than alternatives like hyphens or colons. + petname::petname(3, "_") + .expect("non-zero length requested => cannot be empty") +} diff --git a/nexus/reconfigurator/simulation/src/sim.rs b/nexus/reconfigurator/simulation/src/sim.rs new file mode 100644 index 0000000000..f57cf51a39 --- /dev/null +++ b/nexus/reconfigurator/simulation/src/sim.rs @@ -0,0 +1,125 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! A store of successive reconfigurator states: the main entrypoint for +//! reconfigurator simulation. + +use std::collections::HashMap; + +use indexmap::IndexSet; +use omicron_uuid_kinds::{ReconfiguratorSimKind, ReconfiguratorSimUuid}; +use typed_rng::TypedUuidRng; + +use crate::{seed_from_entropy, SimState}; + +/// A store to track reconfigurator states: the main entrypoint for +/// reconfigurator simulation. +/// +/// This is the main entry point for reconfigurator simulation. It provides +/// key-based storage for systems and their states, and allows for append-only +/// storage of new states. +/// +/// # Implementation notes +/// +/// We currently index by UUIDs, but we could index by the hash of the contents +/// and make it a Merkle tree just as well. (We'd have to hook up canonical +/// hashing etc; it's a bunch of work but not too difficult). If there's a +/// particular need that arises for content-addressing, we should do it. +#[derive(Debug)] +pub struct Simulator { + log: slog::Logger, + // In the future, it would be interesting to store a higher-level chain of + // every set of heads over time. That would let us implement undo and + // restore operations. + heads: IndexSet, + states: HashMap, + // This state corresponds to `ROOT_ID`. + root_state: SimState, + // Top-level (unversioned) RNG. + sim_uuid_rng: TypedUuidRng, +} + +impl Simulator { + /// The root ID of the store. + /// + /// This is always defined to be the nil UUID, and if queried will always + /// have a state associated with it. + pub const ROOT_ID: ReconfiguratorSimUuid = ReconfiguratorSimUuid::nil(); + + /// Create a new simulator with the given initial seed. + pub fn new(log: &slog::Logger, seed: Option) -> Self { + let seed = match seed { + Some(seed) => seed, + None => seed_from_entropy(), + }; + Self::new_inner(log, seed) + } + + fn new_inner(log: &slog::Logger, seed: String) -> Self { + let log = log.new(slog::o!("component" => "SimStore")); + let sim_uuid_rng = + TypedUuidRng::from_seed(&seed, "ReconfiguratorSimUuid"); + let root_state = SimState::new_root(seed); + Self { + log, + heads: IndexSet::new(), + states: HashMap::new(), + root_state, + sim_uuid_rng, + } + } + + /// Get the initial RNG seed. + /// + /// Versioned configurations start with this seed, though they may choose + /// to change it as they go along. + pub fn initial_seed(&self) -> &str { + &self.root_state.rng().seed() + } + + /// Get the current heads of the store. + #[inline] + pub fn heads(&self) -> &IndexSet { + &self.heads + } + + /// Get the state for the given UUID. + pub fn get_state(&self, id: ReconfiguratorSimUuid) -> Option<&SimState> { + if id == Self::ROOT_ID { + return Some(&self.root_state); + } + self.states.get(&id) + } + + #[inline] + pub(crate) fn next_sim_uuid(&mut self) -> ReconfiguratorSimUuid { + self.sim_uuid_rng.next() + } + + // Invariant: the ID should be not present in the store, having been + // generated by next_sim_uuid. + pub(crate) fn add_state(&mut self, state: SimState) { + let id = state.id(); + let parent = state.parent(); + if self.states.insert(id, state).is_some() { + panic!("ID {id} should be unique and generated by the store"); + } + + // Remove the parent if it exists as a head, and in any case add the + // new one. Unlike in source control we don't have a concept of + // "merges" here, so there's exactly one parent that may need to be + // removed. + if let Some(parent) = parent { + self.heads.shift_remove(&parent); + } + self.heads.insert(id); + + slog::debug!( + self.log, + "committed new state"; + "id" => %id, + "parent" => ?parent, + ); + } +} diff --git a/nexus/reconfigurator/simulation/src/state.rs b/nexus/reconfigurator/simulation/src/state.rs new file mode 100644 index 0000000000..9f6980c7b1 --- /dev/null +++ b/nexus/reconfigurator/simulation/src/state.rs @@ -0,0 +1,527 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +use anyhow::{bail, Context}; +use nexus_inventory::CollectionBuilder; +use nexus_reconfigurator_planning::system::SledHwInventory; +use nexus_types::deployment::{ + PlanningInput, SledFilter, SledLookupErrorKind, UnstableReconfiguratorState, +}; +use omicron_common::api::external::Generation; +use omicron_uuid_kinds::{CollectionUuid, ReconfiguratorSimUuid}; + +use crate::{ + config::SimConfig, MutableSimConfig, MutableSimSystem, SimConfigLogEntry, + SimRng, SimSystem, SimSystemLogEntry, Simulator, +}; + +/// A point-in-time snapshot of reconfigurator state. +/// +/// This snapshot consists of a system, along with a policy and a stateful RNG. +#[derive(Clone, Debug)] +pub struct SimState { + id: ReconfiguratorSimUuid, + // The parent state that this state was derived from. + parent: Option, + // The state's generation, starting from 0. + // + // XXX should this be its own type to avoid confusion with other Generation + // instances? + generation: Generation, + description: String, + system: SimSystem, + config: SimConfig, + rng: SimRng, + // A log of changes in this state compared to the parent state. + log: SimStateLog, +} + +impl SimState { + pub(crate) fn new_root(seed: String) -> Self { + Self { + id: Simulator::ROOT_ID, + parent: None, + // We don't normally use generation 0 in the production system, but + // having it here means that we can present a better user + // experience (first change is generation 1). + generation: Generation::from_u32(0), + description: "root state".to_string(), + system: SimSystem::new(), + config: SimConfig::new(), + rng: SimRng::from_seed(seed), + log: SimStateLog { system: Vec::new(), config: Vec::new() }, + } + } + + #[inline] + #[must_use] + pub fn id(&self) -> ReconfiguratorSimUuid { + self.id + } + + #[inline] + #[must_use] + pub fn parent(&self) -> Option { + self.parent + } + + #[inline] + #[must_use] + pub fn description(&self) -> &str { + &self.description + } + + #[inline] + #[must_use] + pub fn system(&self) -> &SimSystem { + &self.system + } + + #[inline] + #[must_use] + pub fn config(&self) -> &SimConfig { + &self.config + } + + #[inline] + #[must_use] + pub fn rng(&self) -> &SimRng { + &self.rng + } + + #[inline] + #[must_use] + pub fn log(&self) -> &SimStateLog { + &self.log + } + + /// Convert the state to a serializable form. + /// + /// Return a [`UnstableReconfiguratorState`] with information about the + /// current state. + pub fn to_serializable( + &self, + ) -> anyhow::Result { + let planning_input = self + .system() + .description() + .to_planning_input_builder() + .context("creating planning input builder")? + .build(); + + Ok(UnstableReconfiguratorState { + planning_input, + collections: self.system.all_collections().cloned().collect(), + blueprints: self.system.all_blueprints().cloned().collect(), + internal_dns: self + .system + .all_internal_dns() + .map(|params| { + ( + // XXX remove unwrap once DNS generations are fixed + Generation::try_from(params.generation).unwrap(), + params.clone(), + ) + }) + .collect(), + external_dns: self + .system + .all_external_dns() + .map(|params| { + ( + // XXX remove unwrap once DNS generations are fixed + Generation::try_from(params.generation).unwrap(), + params.clone(), + ) + }) + .collect(), + silo_names: self.config.silo_names().cloned().collect(), + external_dns_zone_names: vec![self + .config + .external_dns_zone_name() + .to_owned()], + }) + } + + pub fn to_mut(&self) -> MutableSimState { + MutableSimState { + parent: self.id, + parent_gen: self.generation, + system: self.system.to_mut(), + config: self.config.to_mut(), + rng: self.rng.clone(), + } + } +} + +/// Reconfigurator state that can be mutated. +/// +/// This is ephemeral and must be committed to a `SimStore` to be stored. This +/// means that this can be freely mutated without introducing errors. +#[derive(Clone, Debug)] +pub struct MutableSimState { + parent: ReconfiguratorSimUuid, + parent_gen: Generation, + system: MutableSimSystem, + config: MutableSimConfig, + rng: SimRng, +} + +impl MutableSimState { + #[inline] + #[must_use] + pub fn parent(&self) -> ReconfiguratorSimUuid { + self.parent + } + + #[inline] + #[must_use] + pub fn system_mut(&mut self) -> &mut MutableSimSystem { + &mut self.system + } + + #[inline] + #[must_use] + pub fn config_mut(&mut self) -> &mut MutableSimConfig { + &mut self.config + } + + #[inline] + #[must_use] + pub fn rng_mut(&mut self) -> &mut SimRng { + &mut self.rng + } + + /// Merge a serializable state into self. + /// + /// Missing sleds, blueprints, and collections are added. Existing sleds, + /// blueprints, and collections are not modified. + /// + /// The following data is overwritten: + /// + /// * Internal and external DNS. + /// * Silo names. + /// * The external DNS zone name. + pub fn merge_serializable( + &mut self, + state: UnstableReconfiguratorState, + primary_collection_id: Option, + ) -> anyhow::Result { + // TODO: Is merging even useful? Should we only allow loading + // serializable state on an empty system? + // + // Some of the logic here (particularly DNS) is dubious -- overwriting + // DNS means that blueprints in this state may refer to DNS generations + // that are different or even missing. + + let collection_id = + get_primary_collection_id(&state, primary_collection_id)?; + let current_planning_input = self + .system + .description() + .to_planning_input_builder() + .context("generating planning input")? + .build(); + + // NOTE: If more error cases are added, ensure that they're checked + // before merge_serializable_inner is called. This ensures that the + // system is not modified if there are errors. + let mut res = MergeResultBuilder::default(); + self.merge_serializable_inner( + state, + collection_id, + current_planning_input, + &mut res, + ); + + Ok(MergeResult { + primary_collection_id: collection_id, + notices: res.notices, + warnings: res.warnings, + }) + } + + // This method MUST be infallible. It should only be called after checking + // the invariant: the primary collection ID is valid. + fn merge_serializable_inner( + &mut self, + state: UnstableReconfiguratorState, + primary_collection_id: CollectionUuid, + current_planning_input: PlanningInput, + res: &mut MergeResultBuilder, + ) { + res.notices.push(format!( + "using collection {} as source of sled inventory data", + primary_collection_id, + )); + let primary_collection = state + .collections + .iter() + .find(|c| c.id == primary_collection_id) + .expect("invariant: primary collection ID is valid"); + + for (sled_id, sled_details) in + state.planning_input.all_sleds(SledFilter::Commissioned) + { + match current_planning_input + .sled_lookup(SledFilter::Commissioned, sled_id) + { + Ok(_) => { + res.notices.push(format!( + "sled {}: skipped (one with the same id is already loaded)", + sled_id + )); + continue; + } + Err(error) => match error.kind() { + SledLookupErrorKind::Filtered { .. } => { + // We tried to load a sled which has been marked + // decommissioned. We disallow this (it's a special + // case of skipping already loaded sleds), but it's a + // little more surprising to the user so treat it as a + // warning. + res.warnings.push(format!( + "sled {}: skipped (turning a decommissioned \ + sled into a commissioned one is not supported", + sled_id + )); + continue; + } + SledLookupErrorKind::Missing => { + // A sled being missing from the input is the only case + // in which we decide to load new sleds. The logic to + // do that is below. + } + }, + } + + let Some(inventory_sled_agent) = + primary_collection.sled_agents.get(&sled_id) + else { + res.warnings.push(format!( + "sled {}: skipped (no inventory found for sled agent in \ + collection {}", + sled_id, primary_collection_id + )); + continue; + }; + + let inventory_sp = inventory_sled_agent + .baseboard_id + .as_ref() + .and_then(|baseboard_id| { + let inv_sp = primary_collection.sps.get(baseboard_id); + let inv_rot = primary_collection.rots.get(baseboard_id); + if let (Some(inv_sp), Some(inv_rot)) = (inv_sp, inv_rot) { + Some(SledHwInventory { + baseboard_id: &baseboard_id, + sp: inv_sp, + rot: inv_rot, + }) + } else { + None + } + }); + + // XXX: Should this error ever happen? The only case where it + // errors is if the sled ID is already loaded, but didn't we + // already check it above via the current planning input? + let result = self.system.description_mut().sled_full( + sled_id, + sled_details.policy, + sled_details.state, + sled_details.resources.clone(), + inventory_sp, + inventory_sled_agent, + ); + + match result { + Ok(_) => { + res.notices.push(format!("sled {}: loaded", sled_id)); + } + Err(error) => { + // Failing to load a sled shouldn't really happen, but if + // it does, it is a non-fatal error. + res.warnings.push(format!("sled {}: {:#}", sled_id, error)); + } + }; + } + + for collection in state.collections { + let collection_id = collection.id; + match self.system.add_collection(collection) { + Ok(_) => { + res.notices + .push(format!("collection {}: loaded", collection_id)); + } + Err(_) => { + res.notices.push(format!( + "collection {}: skipped (one with the \ + same id is already loaded)", + collection_id, + )); + } + } + } + + for blueprint in state.blueprints { + let blueprint_id = blueprint.id; + match self.system.add_blueprint(blueprint) { + Ok(_) => { + res.notices + .push(format!("blueprint {}: loaded", blueprint_id)); + } + Err(_) => { + res.notices.push(format!( + "blueprint {}: skipped (one with the \ + same id is already loaded)", + blueprint_id, + )); + } + } + } + + self.system.description_mut().service_ip_pool_ranges( + state.planning_input.service_ip_pool_ranges().to_vec(), + ); + res.notices.push(format!( + // TODO: better output format? + "loaded service IP pool ranges: {:?}", + state.planning_input.service_ip_pool_ranges() + )); + + // TODO: This doesn't seem right. See the comment at the top of + // merge_serializable. + self.system.set_internal_dns(state.internal_dns); + self.system.set_external_dns(state.external_dns); + + let nnames = state.external_dns_zone_names.len(); + if nnames > 0 { + if nnames > 1 { + res.warnings.push(format!( + "found {} external DNS names; using only the first one", + nnames + )); + } + self.config.set_external_dns_zone_name( + state.external_dns_zone_names[0].clone(), + ); + } + + // TODO: Currently this doesn't return notices for DNS and silo names. + // The only caller of this function prints them separately after + // committing this state. We may want to record this information in the + // MergeResult instead. + + // TODO: log what happened here. This is a cross-cutting change so we + // may want to log it as a single big entry (like + // MutableSimSystem::load_example) rather than lots of little ones. + } + + /// Commit the current state to the store, returning the new state's UUID. + #[must_use = "you should update your state with the new UUID"] + pub fn commit( + self, + description: String, + sim: &mut Simulator, + ) -> ReconfiguratorSimUuid { + let id = sim.next_sim_uuid(); + let (system, system_log) = self.system.into_parts(); + let (config, config_log) = self.config.into_parts(); + let log = SimStateLog { system: system_log, config: config_log }; + let state = SimState { + id, + description, + parent: Some(self.parent), + generation: self.parent_gen.next(), + system, + config, + rng: self.rng, + log, + }; + sim.add_state(state); + id + } + + // TODO: should probably enforce that RNG is set, maybe by hiding the + // SystemDescription struct? + pub fn to_collection_builder( + &mut self, + ) -> anyhow::Result { + let mut builder = self + .system + .description() + .to_collection_builder() + .context("generating inventory")?; + + let rng = self.rng.next_collection_rng(); + builder.set_rng(rng); + + Ok(builder) + } +} + +/// A log of changes made to a state compared to the parent. +#[derive(Clone, Debug)] +pub struct SimStateLog { + pub system: Vec, + pub config: Vec, +} + +/// The output of merging a serializable state into a mutable state. +#[derive(Clone, Debug)] +#[must_use] +pub struct MergeResult { + // TODO: Storing notices and warnings as strings is a carryover from + // reconfigurator-cli. We may wish to store data in a more structured form. + // For example, store a map of sled IDs to their statuses, etc. + /// The primary collection ID. + pub primary_collection_id: CollectionUuid, + + /// Notices for the caller to display. + pub notices: Vec, + + /// Non-fatal warnings that occurred. + pub warnings: Vec, +} + +/// Check and get the primary collection ID for a serialized state. +fn get_primary_collection_id( + state: &UnstableReconfiguratorState, + provided: Option, +) -> anyhow::Result { + match provided { + Some(id) => { + // Check that the collection ID is valid. + if state.collections.iter().any(|c| c.id == id) { + Ok(id) + } else { + bail!("collection {} not found in data", id) + } + } + None => match state.collections.len() { + 1 => Ok(state.collections[0].id), + 0 => bail!( + "no collection_id specified and file contains 0 collections" + ), + count => bail!( + "no collection_id specified and file contains {} \ + collections: {}", + count, + state + .collections + .iter() + .map(|c| c.id.to_string()) + .collect::>() + .join(", ") + ), + }, + } +} + +#[derive(Debug, Default)] +struct MergeResultBuilder { + notices: Vec, + warnings: Vec, +} diff --git a/nexus/reconfigurator/simulation/src/system.rs b/nexus/reconfigurator/simulation/src/system.rs new file mode 100644 index 0000000000..fe4456038a --- /dev/null +++ b/nexus/reconfigurator/simulation/src/system.rs @@ -0,0 +1,424 @@ +// This Source Code Form is subject to the terms of the Mozilla Public +// License, v. 2.0. If a copy of the MPL was not distributed with this +// file, You can obtain one at https://mozilla.org/MPL/2.0/. + +//! A simulated reconfigurator system. + +use std::{collections::BTreeMap, sync::Arc}; + +use chrono::Utc; +use indexmap::IndexMap; +use nexus_reconfigurator_planning::{ + example::ExampleSystem, system::SystemDescription, +}; +use nexus_types::{ + deployment::Blueprint, + internal_api::params::{DnsConfigParams, DnsConfigZone}, + inventory::Collection, +}; +use omicron_common::api::external::Generation; +use omicron_uuid_kinds::CollectionUuid; +use uuid::Uuid; + +use crate::errors::{DuplicateError, MissingError, NonEmptySystemError}; + +/// A versioned, simulated reconfigurator system. +#[derive(Clone, Debug)] +pub struct SimSystem { + // Implementation note: an alternative way to store data would be for + // `Simulator` to carry a global store with it, and then each system only + // stores the presence of blueprints/collections/etc rather than the + // objects themselves. In other words, a simulator-wide object store. A few + // things become easier that way, such as being able to iterate over all + // known objects of a given type. + // + // However, there are a few issues with this approach in practice: + // + // 1. The blueprints and collections are not guaranteed to be unique per + // UUID. Unlike (say) source control commit hashes, UUIDs are not + // content-hashed, so the same UUID can be associated with different + // blueprints/collections. + // 2. DNS configs are absolutely not unique per generation! Again, not + // content-hashed. + // 3. The mutable system may wish to not add objects to the store until + // it's committed. This means that the mutable system would probably + // have to maintain a list of pending objects to add to the store. That + // complicates some of the internals, if not the API. + // 4. We'll have to figure out how to manage the store (so MutableSimStore + // can access the blueprints/collections while they're). Storing a &mut + // reference is not an option, and we probably want it to be + // thread-safe, so the options seem to be either `&Mutex` or + // `Arc>`. Our current approach is more simplistic, but + // also lock-free. + // + // None of these are insurmountable, but they do make the global store a + // bit less appealing than it might seem at first glance. + // + /// Describes the sleds in the system. + /// + /// This resembles what we get from the `sled` table in a real system. It + /// also contains enough information to generate inventory collections that + /// describe the system. + description: SystemDescription, + + /// Inventory collections created by the user. + /// + /// Stored with `Arc` to allow cheap cloning. + collections: IndexMap>, + + /// Blueprints created by the user. + /// + /// Stored with `Arc` to allow cheap cloning. + blueprints: IndexMap>, + + /// Internal DNS configurations. + /// + /// Stored with `Arc` to allow cheap cloning. + internal_dns: BTreeMap>, + + /// External DNS configurations. + /// + /// Stored with `Arc` to allow cheap cloning. + external_dns: BTreeMap>, +} + +impl SimSystem { + pub fn new() -> Self { + Self { + description: SystemDescription::new(), + collections: IndexMap::new(), + blueprints: IndexMap::new(), + internal_dns: BTreeMap::new(), + external_dns: BTreeMap::new(), + } + } + + pub fn is_empty(&self) -> bool { + !self.description.has_sleds() + && self.collections.is_empty() + && self.blueprints.is_empty() + && self.internal_dns.is_empty() + && self.external_dns.is_empty() + } + + #[inline] + pub fn description(&self) -> &SystemDescription { + &self.description + } + + pub fn get_collection( + &self, + id: CollectionUuid, + ) -> Result<&Collection, MissingError> { + match self.collections.get(&id) { + Some(c) => Ok(&**c), + None => Err(MissingError::collection(id)), + } + } + + pub fn all_collections( + &self, + ) -> impl ExactSizeIterator { + self.collections.values().map(|c| &**c) + } + + pub fn get_blueprint(&self, id: Uuid) -> Result<&Blueprint, MissingError> { + match self.blueprints.get(&id) { + Some(b) => Ok(&**b), + None => Err(MissingError::blueprint(id)), + } + } + + pub fn all_blueprints(&self) -> impl ExactSizeIterator { + self.blueprints.values().map(|b| &**b) + } + + pub fn get_internal_dns( + &self, + generation: Generation, + ) -> Option<&DnsConfigParams> { + self.internal_dns.get(&generation).map(|d| &**d) + } + + pub fn all_internal_dns( + &self, + ) -> impl ExactSizeIterator { + self.internal_dns.values().map(|d| &**d) + } + + pub fn get_external_dns( + &self, + generation: Generation, + ) -> Option<&DnsConfigParams> { + self.external_dns.get(&generation).map(|d| &**d) + } + + pub fn all_external_dns( + &self, + ) -> impl ExactSizeIterator { + self.external_dns.values().map(|d| &**d) + } + + pub(crate) fn to_mut(&self) -> MutableSimSystem { + MutableSimSystem { system: self.clone(), log: Vec::new() } + } +} + +/// A mutable version of [`SimSystem`]. +/// +/// This is exposed by [`MutableSimState`]. +/// +/// [`MutableSimState`]: crate::MutableSimState +#[derive(Clone, Debug)] +pub struct MutableSimSystem { + // The underlying `SimSystem`. + system: SimSystem, + // Operation log on the system. + log: Vec, +} + +impl MutableSimSystem { + // These methods are duplicated from `SimSystem`. We don't use `Deref` + // because it isn't the right tool. + // + // The forwarding is all valid because we don't cache any changes in this + // struct, instead making them directly to the underlying system. + + #[inline] + pub fn is_empty(&self) -> bool { + self.system.is_empty() + } + + #[inline] + pub fn description(&self) -> &SystemDescription { + &self.system.description() + } + + #[inline] + pub fn get_collection( + &self, + id: CollectionUuid, + ) -> Result<&Collection, MissingError> { + self.system.get_collection(id) + } + + #[inline] + pub fn all_collections( + &self, + ) -> impl ExactSizeIterator { + self.system.all_collections() + } + + #[inline] + pub fn get_blueprint(&self, id: Uuid) -> Result<&Blueprint, MissingError> { + self.system.get_blueprint(id) + } + + #[inline] + pub fn all_blueprints(&self) -> impl ExactSizeIterator { + self.system.all_blueprints() + } + + // XXX return errors + #[inline] + pub fn get_internal_dns( + &self, + generation: Generation, + ) -> Option<&DnsConfigParams> { + self.system.get_internal_dns(generation) + } + + #[inline] + pub fn all_internal_dns( + &self, + ) -> impl ExactSizeIterator { + self.system.all_internal_dns() + } + + #[inline] + pub fn get_external_dns( + &self, + generation: Generation, + ) -> Option<&DnsConfigParams> { + self.system.get_external_dns(generation) + } + + #[inline] + pub fn all_external_dns( + &self, + ) -> impl ExactSizeIterator { + self.system.all_external_dns() + } + + // TODO: track changes to the SystemDescription -- we'll probably want to + // have a separation between a type that represents a read-only system + // description and a type that can mutate it. + pub fn description_mut(&mut self) -> &mut SystemDescription { + &mut self.system.description + } + + pub fn load_example( + &mut self, + example: ExampleSystem, + blueprint: Blueprint, + internal_dns: DnsConfigZone, + external_dns: DnsConfigZone, + ) -> Result<(), NonEmptySystemError> { + if !self.system.is_empty() { + return Err(NonEmptySystemError::new()); + } + + // NOTE: If more error cases are added, ensure that they're checked + // before load_example_inner is called. This ensures that the system is + // not modified if there are errors. + self.load_example_inner(example, blueprint, internal_dns, external_dns); + Ok(()) + } + + // This method MUST be infallible. It should only be called after checking + // the invariant: the system must be empty. + fn load_example_inner( + &mut self, + example: ExampleSystem, + blueprint: Blueprint, + internal_dns: DnsConfigZone, + external_dns: DnsConfigZone, + ) { + self.log.push(SimSystemLogEntry::LoadExample { + collection_id: example.collection.id, + blueprint_id: blueprint.id, + internal_dns_version: blueprint.internal_dns_version, + external_dns_version: blueprint.external_dns_version, + }); + + self.system.description = example.system; + self.system + .collections + .insert(example.collection.id, Arc::new(example.collection)); + self.system.internal_dns.insert( + blueprint.internal_dns_version, + Arc::new(DnsConfigParams { + generation: blueprint.internal_dns_version.into(), + // TODO: probably want to make time controllable by the caller. + time_created: Utc::now(), + zones: vec![internal_dns], + }), + ); + self.system.external_dns.insert( + blueprint.external_dns_version, + Arc::new(DnsConfigParams { + generation: blueprint.external_dns_version.into(), + // TODO: probably want to make time controllable by the caller. + time_created: Utc::now(), + zones: vec![external_dns], + }), + ); + self.system.blueprints.insert(blueprint.id, Arc::new(blueprint)); + } + + pub fn add_collection( + &mut self, + collection: impl Into>, + ) -> Result<(), DuplicateError> { + let collection = collection.into(); + self.add_collection_inner(collection) + } + + fn add_collection_inner( + &mut self, + collection: Arc, + ) -> Result<(), DuplicateError> { + let collection_id = collection.id; + match self.system.collections.entry(collection_id) { + indexmap::map::Entry::Vacant(entry) => { + entry.insert(collection); + self.log.push(SimSystemLogEntry::AddCollection(collection_id)); + Ok(()) + } + indexmap::map::Entry::Occupied(entry) => { + Err(DuplicateError::collection(entry.get().clone())) + } + } + } + + pub fn add_blueprint( + &mut self, + blueprint: impl Into>, + ) -> Result<(), DuplicateError> { + let blueprint = blueprint.into(); + self.add_blueprint_inner(blueprint) + } + + fn add_blueprint_inner( + &mut self, + blueprint: Arc, + ) -> Result<(), DuplicateError> { + let blueprint_id = blueprint.id; + match self.system.blueprints.entry(blueprint_id) { + indexmap::map::Entry::Vacant(entry) => { + entry.insert(blueprint); + self.log.push(SimSystemLogEntry::AddBlueprint(blueprint_id)); + Ok(()) + } + indexmap::map::Entry::Occupied(entry) => { + Err(DuplicateError::blueprint(entry.get().clone())) + } + } + } + + pub fn wipe(&mut self) { + self.system = SimSystem::new(); + self.log.push(SimSystemLogEntry::Wipe); + } + + // Not public: the only users that want to replace DNS wholesale are + // internal to this crate. + // + // TODO: We probably don't want to replace DNS wholesale at all. See + // comment at the top of merge_serializable for more. + pub(crate) fn set_internal_dns( + &mut self, + dns: impl IntoIterator, + ) { + let internal_dns = dns + .into_iter() + .map(|(generation, params)| (generation, Arc::new(params))) + .collect(); + self.system.internal_dns = internal_dns; + } + + // Not public: the only users that want to replace DNS wholesale are + // internal to this crate. + // + // TODO: We probably don't want to replace DNS wholesale at all. See + // comment at the top of merge_serializable for more. + pub(crate) fn set_external_dns( + &mut self, + dns: impl IntoIterator, + ) { + let external_dns = dns + .into_iter() + .map(|(generation, params)| (generation, Arc::new(params))) + .collect(); + self.system.external_dns = external_dns; + } + + pub(crate) fn into_parts(self) -> (SimSystem, Vec) { + (self.system, self.log) + } +} + +/// A log entry corresponding to an individual operation on a +/// [`MutableSimSystem`]. +#[derive(Clone, Debug)] +pub enum SimSystemLogEntry { + LoadExample { + collection_id: CollectionUuid, + blueprint_id: Uuid, + internal_dns_version: Generation, + external_dns_version: Generation, + }, + AddCollection(CollectionUuid), + AddBlueprint(Uuid), + Wipe, +} diff --git a/nexus/types/src/deployment/execution/dns.rs b/nexus/types/src/deployment/execution/dns.rs index a813452ccd..4535e29c81 100644 --- a/nexus/types/src/deployment/execution/dns.rs +++ b/nexus/types/src/deployment/execution/dns.rs @@ -134,9 +134,9 @@ pub fn blueprint_internal_dns_config( Ok(dns_builder.build_zone()) } -pub fn blueprint_external_dns_config( +pub fn blueprint_external_dns_config<'a>( blueprint: &Blueprint, - silos: &[Name], + silos: impl IntoIterator, external_dns_zone_name: String, ) -> DnsConfigZone { let nexus_external_ips = blueprint_nexus_external_ips(blueprint); diff --git a/uuid-kinds/src/lib.rs b/uuid-kinds/src/lib.rs index ba586c03a5..32b62b9bfa 100644 --- a/uuid-kinds/src/lib.rs +++ b/uuid-kinds/src/lib.rs @@ -62,6 +62,7 @@ impl_typed_uuid_kind! { Propolis => "propolis", RackInit => "rack_init", RackReset => "rack_reset", + ReconfiguratorSim => "reconfigurator_sim", Region => "region", Sled => "sled", TufRepo => "tuf_repo",