diff --git a/Cargo.lock b/Cargo.lock index 5ad442791a..c528a25ab7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6499,9 +6499,9 @@ dependencies = [ [[package]] name = "newtype-uuid" -version = "1.3.1" +version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "74d1216f62e63be5fb25a9ecd1e2b37b1556a9b8c02f4831770f5d01df85c226" +checksum = "5c012d14ef788ab066a347d19e3dda699916c92293b05b85ba2c76b8c82d2830" dependencies = [ "proptest", "schemars 0.8.22", @@ -7285,6 +7285,7 @@ dependencies = [ "anyhow", "camino", "chrono", + "indent_write", "indexmap 2.11.4", "itertools 0.14.0", "nexus-inventory", @@ -7295,6 +7296,7 @@ dependencies = [ "omicron-uuid-kinds", "omicron-workspace-hack", "petname", + "sapling-renderdag", "slog", "strum 0.27.2", "swrite", @@ -12119,6 +12121,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "sapling-renderdag" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edffb89cab87bd0901c5749d576f5d37a1f34e05160e936f463f4e94cc447b61" +dependencies = [ + "bitflags 2.9.4", +] + [[package]] name = "schannel" version = "0.1.26" diff --git a/Cargo.toml b/Cargo.toml index 0aa02a2e81..a8b28e9cf4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -799,7 +799,7 @@ zone = { version = "0.3.1", default-features = false, features = ["async"] } # the kinds). However, uses of omicron-uuid-kinds _within omicron_ will have # std and the other features enabled because they'll refer to it via # omicron-uuid-kinds.workspace = true. -newtype-uuid = { version = "1.3.1", default-features = false } +newtype-uuid = { version = "1.3.2", default-features = false } newtype-uuid-macros = "0.1.0" omicron-uuid-kinds = { path = "uuid-kinds", features = ["serde", "schemars08", "uuid-v4"] } diff --git a/dev-tools/reconfigurator-cli/src/lib.rs b/dev-tools/reconfigurator-cli/src/lib.rs index ad5ff37f31..edebf2855f 100644 --- a/dev-tools/reconfigurator-cli/src/lib.rs +++ b/dev-tools/reconfigurator-cli/src/lib.rs @@ -27,7 +27,9 @@ use nexus_reconfigurator_planning::planner::Planner; use nexus_reconfigurator_planning::system::{ RotStateOverrides, SledBuilder, SledInventoryVisibility, SystemDescription, }; -use nexus_reconfigurator_simulation::{BlueprintId, CollectionId, SimState}; +use nexus_reconfigurator_simulation::{ + BlueprintId, CollectionId, GraphRenderOptions, SimState, +}; use nexus_reconfigurator_simulation::{SimStateBuilder, SimTufRepoSource}; use nexus_reconfigurator_simulation::{SimTufRepoDescription, Simulator}; use nexus_sled_agent_shared::inventory::ZoneKind; @@ -322,6 +324,8 @@ fn process_command( Commands::LoadExample(args) => cmd_load_example(sim, args), Commands::FileContents(args) => cmd_file_contents(args), Commands::Save(args) => cmd_save(sim, args), + Commands::State(StateArgs::Log(args)) => cmd_state_log(sim, args), + Commands::State(StateArgs::Switch(args)) => cmd_state_switch(sim, args), Commands::Wipe(args) => cmd_wipe(sim, args), }; @@ -420,6 +424,9 @@ enum Commands { LoadExample(LoadExampleArgs), /// show information about what's in a saved file FileContents(FileContentsArgs), + /// state-related commands + #[command(flatten)] + State(StateArgs), /// reset the state of the REPL Wipe(WipeArgs), } @@ -1432,6 +1439,40 @@ struct SaveArgs { filename: Utf8PathBuf, } +#[derive(Debug, Subcommand)] +enum StateArgs { + /// display a log of simulator states + /// + /// Shows the history of states from the current state back to the root. + Log(StateLogArgs), + /// switch to a different state + /// + /// Changes the current working state to the specified state. All subsequent + /// commands will operate from this state. + Switch(StateSwitchArgs), +} + +#[derive(Debug, Args)] +struct StateLogArgs { + /// Starting state ID (defaults to current state) + #[clap(long)] + from: Option, + + /// Limit number of states to display + #[clap(long, short = 'n')] + limit: Option, + + /// Show changes in each state (verbose mode) + #[clap(long, short = 'v')] + verbose: bool, +} + +#[derive(Debug, Args)] +struct StateSwitchArgs { + /// The state ID or unique prefix to switch to + state_id: String, +} + #[derive(Debug, Args)] struct WipeArgs { /// What to wipe @@ -2765,6 +2806,49 @@ fn cmd_save( ))) } +fn cmd_state_log( + sim: &mut ReconfiguratorSim, + args: StateLogArgs, +) -> anyhow::Result> { + let StateLogArgs { from, limit, verbose } = args; + + // Build rendering options. + let options = GraphRenderOptions::new(sim.current) + .with_verbose(verbose) + .with_limit(limit) + .with_from(from); + + // Render the graph. + let output = sim.sim.render_graph(&options); + + Ok(Some(output)) +} + +fn cmd_state_switch( + sim: &mut ReconfiguratorSim, + args: StateSwitchArgs, +) -> anyhow::Result> { + // Try parsing as a full UUID first, then fall back to prefix matching. + let target_id = match args.state_id.parse::() { + Ok(id) => id, + Err(_) => sim.sim.get_state_by_prefix(&args.state_id)?, + }; + + let state = sim + .sim + .get_state(target_id) + .ok_or_else(|| anyhow!("state {} not found", target_id))?; + + sim.current = target_id; + + Ok(Some(format!( + "switched to state {} (generation {}): {}", + target_id, + state.generation(), + state.description() + ))) +} + fn cmd_wipe( sim: &mut ReconfiguratorSim, args: WipeArgs, @@ -2776,7 +2860,7 @@ fn cmd_wipe( state.config_mut().wipe(); state.rng_mut().reset_state(); format!( - "- wiped system, reconfigurator-sim config, and RNG state\n + "- wiped system, reconfigurator-sim config, and RNG state\n\ - reset seed to {}", state.rng_mut().seed() ) diff --git a/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt b/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt index 913ebb88b8..085bb754ff 100644 --- a/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt +++ b/dev-tools/reconfigurator-cli/tests/input/cmds-example.txt @@ -67,3 +67,34 @@ set planner-config --add-zones-with-mupdate-override true # Sled index out of bounds, will error out. wipe all load-example --seed test-basic --nsleds 3 --sled-policy 3:non-provisionable + +log +log --verbose + +# Switch states with a full UUID. +switch 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 +log -n 3 + +# Switch states with a unique prefix. +switch 860f +log -n 3 + +# Switch states with an ambiguous prefix (should fail). +switch 4 + +# Switch states with a non-existent prefix (should fail). +switch zzzz + +# Make a new branch. +sled-add +sled-add + +# Make an additional branch based on the previous branch. +switch 4d8d6725 +sled-add + +# Go back to the previous branch. +switch 5c53cf4b + +log +log --verbose diff --git a/dev-tools/reconfigurator-cli/tests/output/cmds-example-stdout b/dev-tools/reconfigurator-cli/tests/output/cmds-example-stdout index 0d468fe589..546e62848c 100644 --- a/dev-tools/reconfigurator-cli/tests/output/cmds-example-stdout +++ b/dev-tools/reconfigurator-cli/tests/output/cmds-example-stdout @@ -620,8 +620,7 @@ blueprint source: constructed by an automated test > wipe all - wiped system, reconfigurator-sim config, and RNG state - - - reset seed to test-basic +- reset seed to test-basic > load-example --seed test-basic --nsleds 1 --ndisks-per-sled 4 loaded example system with: @@ -1133,8 +1132,7 @@ external DNS: > # Load an example with a non-provisionable and an expunged sled. > wipe all - wiped system, reconfigurator-sim config, and RNG state - - - reset seed to test-basic +- reset seed to test-basic > load-example --seed test-basic --nsleds 3 --sled-policy 1:non-provisionable --sled-policy 2:expunged --ndisks-per-sled 3 loaded example system with: @@ -2101,9 +2099,399 @@ no changes to planner config: > # Sled index out of bounds, will error out. > wipe all - wiped system, reconfigurator-sim config, and RNG state - - - reset seed to test-basic +- reset seed to test-basic > load-example --seed test-basic --nsleds 3 --sled-policy 3:non-provisionable error: setting sled policy: sled index 3 out of range (0..3) + +> log +@ 1d89a28e (generation 14) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ 4d8d6725 (generation 13) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +○ 860f14d3 (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true +│ +○ 953857be (generation 11) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: false +│ +○ 0a37e015 (generation 10) +│ reconfigurator-cli blueprint-plan +│ +○ 4a3b153b (generation 9) +│ reconfigurator-cli load-example +│ +○ 2f40b35e (generation 8) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ 9d1367b1 (generation 7) +│ reconfigurator-cli blueprint-plan +│ +○ 2d296288 (generation 6) +│ reconfigurator-cli load-example +│ +○ bc89da4b (generation 5) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ a887277f (generation 4) +│ reconfigurator-cli load-example +│ +○ c7f48fbf (generation 3) +│ wiped system +│ +○ ac7a3cd0 (generation 2) +│ reconfigurator-cli inventory-generate +│ +○ a6dcc37c (generation 1) +│ reconfigurator-cli load-example +│ +○ 00000000 (generation 0) + root state + + +> log --verbose +@ 1d89a28e-fc1e-4a6c-9ef7-ea21ce7779e5 (generation 14) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ details: +│ system: wipe +│ config: wipe +│ +○ 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 (generation 13) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +○ 860f14d3-e719-4fa8-a801-73164bd6463d (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true +│ +○ 953857be-1ce7-453f-b6cc-4e2ff2c0eee2 (generation 11) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: false +│ +○ 0a37e015-bb94-4767-a992-54e3848079a6 (generation 10) +│ reconfigurator-cli blueprint-plan +│ details: +│ system: add blueprint 86db3308-f817-4626-8838-4085949a6a41 +│ rng: next planner rng +│ +○ 4a3b153b-fc8a-485a-baee-badaad53d2fc (generation 9) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ 2f40b35e-54e0-4c08-bf62-cd2315fffd1e (generation 8) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ details: +│ system: wipe +│ config: wipe +│ +○ 9d1367b1-bb1e-4d68-8111-db413ca7e4f1 (generation 7) +│ reconfigurator-cli blueprint-plan +│ details: +│ system: add blueprint 86db3308-f817-4626-8838-4085949a6a41 +│ rng: next planner rng +│ +○ 2d296288-6fbc-4acb-8669-97fb8f38e5b7 (generation 6) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ bc89da4b-35d9-47fc-9f8c-d73c26d65971 (generation 5) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ details: +│ system: wipe +│ config: wipe +│ +○ a887277f-bbb6-46b8-814c-efcf7199fb3a (generation 4) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ c7f48fbf-15c8-47d0-ba9b-2709b1f206a7 (generation 3) +│ wiped system +│ details: +│ system: wipe +│ +○ ac7a3cd0-0719-43f4-9bb1-1308ce04c2af (generation 2) +│ reconfigurator-cli inventory-generate +│ details: +│ system: add collection 972ca69a-384c-4a9c-a87d-c2cf21e114e0 +│ rng: next collection rng +│ +○ a6dcc37c-7fba-49fa-972e-f9743d94f447 (generation 1) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ 00000000-0000-0000-0000-000000000000 (generation 0) + root state + + + +> # Switch states with a full UUID. +> switch 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 +switched to state 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 (generation 13): reconfigurator-cli set: no changes to planner config: + add zones with mupdate override: true + + +> log -n 3 +○ 1d89a28e (generation 14) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +@ 4d8d6725 (generation 13) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +○ 860f14d3 (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true + + + +> # Switch states with a unique prefix. +> switch 860f +switched to state 860f14d3-e719-4fa8-a801-73164bd6463d (generation 12): reconfigurator-cli set: planner config updated: +* add zones with mupdate override: false -> true + + +> log -n 3 +○ 1d89a28e (generation 14) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ 4d8d6725 (generation 13) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +@ 860f14d3 (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true + + + +> # Switch states with an ambiguous prefix (should fail). +> switch 4 +error: prefix '4' is ambiguous: matches 2 states + - 4a3b153b-fc8a-485a-baee-badaad53d2fc generation 9: + reconfigurator-cli load-example + - 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 generation 13: + reconfigurator-cli set: no changes to planner config: + add zones with mupdate override: true + + + +> # Switch states with a non-existent prefix (should fail). +> switch zzzz +error: no state found with prefix 'zzzz' + + +> # Make a new branch. +> sled-add +added sled 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) + +> sled-add +added sled 99cff2b9-4293-4a1f-bd94-f1ca9ae0dba0 (serial: serial4) + + +> # Make an additional branch based on the previous branch. +> switch 4d8d6725 +switched to state 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 (generation 13): reconfigurator-cli set: no changes to planner config: + add zones with mupdate override: true + + +> sled-add +added sled 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) + + +> # Go back to the previous branch. +> switch 5c53cf4b +switched to state 5c53cf4b-26c3-4437-87e1-459fc8f152fe (generation 14): reconfigurator-cli sled-add: 99cff2b9-4293-4a1f-bd94-f1ca9ae0dba0 (serial: serial4) + + +> log +@ 5c53cf4b (generation 14) +│ reconfigurator-cli sled-add: 99cff2b9-4293-4a1f-bd94-f1ca9ae0dba0 (serial: serial4) +│ +○ afbfc525 (generation 13) +│ reconfigurator-cli sled-add: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) +│ +│ ○ 1d89a28e (generation 14) +│ │ - wiped system, reconfigurator-sim config, and RNG state +│ │ - reset seed to test-basic +│ │ +│ │ ○ 810c9381 (generation 14) +│ ├─╯ reconfigurator-cli sled-add: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) +│ │ +│ ○ 4d8d6725 (generation 13) +├─╯ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +○ 860f14d3 (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true +│ +○ 953857be (generation 11) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: false +│ +○ 0a37e015 (generation 10) +│ reconfigurator-cli blueprint-plan +│ +○ 4a3b153b (generation 9) +│ reconfigurator-cli load-example +│ +○ 2f40b35e (generation 8) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ 9d1367b1 (generation 7) +│ reconfigurator-cli blueprint-plan +│ +○ 2d296288 (generation 6) +│ reconfigurator-cli load-example +│ +○ bc89da4b (generation 5) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ +○ a887277f (generation 4) +│ reconfigurator-cli load-example +│ +○ c7f48fbf (generation 3) +│ wiped system +│ +○ ac7a3cd0 (generation 2) +│ reconfigurator-cli inventory-generate +│ +○ a6dcc37c (generation 1) +│ reconfigurator-cli load-example +│ +○ 00000000 (generation 0) + root state + + +> log --verbose +@ 5c53cf4b-26c3-4437-87e1-459fc8f152fe (generation 14) +│ reconfigurator-cli sled-add: 99cff2b9-4293-4a1f-bd94-f1ca9ae0dba0 (serial: serial4) +│ details: +│ rng: next sled id: 99cff2b9-4293-4a1f-bd94-f1ca9ae0dba0 +│ +○ afbfc525-c81c-4923-adf6-c75f60b6f590 (generation 13) +│ reconfigurator-cli sled-add: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) +│ details: +│ rng: next sled id: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 +│ +│ ○ 1d89a28e-fc1e-4a6c-9ef7-ea21ce7779e5 (generation 14) +│ │ - wiped system, reconfigurator-sim config, and RNG state +│ │ - reset seed to test-basic +│ │ details: +│ │ system: wipe +│ │ config: wipe +│ │ +│ │ ○ 810c9381-92f1-43b9-9660-e935c34459e5 (generation 14) +│ ├─╯ reconfigurator-cli sled-add: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 (serial: serial3) +│ │ details: +│ │ rng: next sled id: 9b8b43e1-5ce7-4094-87b1-0be0416174a4 +│ │ +│ ○ 4d8d6725-1ae3-4f88-b9cb-a34db82d7439 (generation 13) +├─╯ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: true +│ +○ 860f14d3-e719-4fa8-a801-73164bd6463d (generation 12) +│ reconfigurator-cli set: planner config updated: +│ * add zones with mupdate override: false -> true +│ +○ 953857be-1ce7-453f-b6cc-4e2ff2c0eee2 (generation 11) +│ reconfigurator-cli set: no changes to planner config: +│ add zones with mupdate override: false +│ +○ 0a37e015-bb94-4767-a992-54e3848079a6 (generation 10) +│ reconfigurator-cli blueprint-plan +│ details: +│ system: add blueprint 86db3308-f817-4626-8838-4085949a6a41 +│ rng: next planner rng +│ +○ 4a3b153b-fc8a-485a-baee-badaad53d2fc (generation 9) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ 2f40b35e-54e0-4c08-bf62-cd2315fffd1e (generation 8) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ details: +│ system: wipe +│ config: wipe +│ +○ 9d1367b1-bb1e-4d68-8111-db413ca7e4f1 (generation 7) +│ reconfigurator-cli blueprint-plan +│ details: +│ system: add blueprint 86db3308-f817-4626-8838-4085949a6a41 +│ rng: next planner rng +│ +○ 2d296288-6fbc-4acb-8669-97fb8f38e5b7 (generation 6) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ bc89da4b-35d9-47fc-9f8c-d73c26d65971 (generation 5) +│ - wiped system, reconfigurator-sim config, and RNG state +│ - reset seed to test-basic +│ details: +│ system: wipe +│ config: wipe +│ +○ a887277f-bbb6-46b8-814c-efcf7199fb3a (generation 4) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ c7f48fbf-15c8-47d0-ba9b-2709b1f206a7 (generation 3) +│ wiped system +│ details: +│ system: wipe +│ +○ ac7a3cd0-0719-43f4-9bb1-1308ce04c2af (generation 2) +│ reconfigurator-cli inventory-generate +│ details: +│ system: add collection 972ca69a-384c-4a9c-a87d-c2cf21e114e0 +│ rng: next collection rng +│ +○ a6dcc37c-7fba-49fa-972e-f9743d94f447 (generation 1) +│ reconfigurator-cli load-example +│ details: +│ system: load example: collection 9e187896-7809-46d0-9210-d75be1b3c4d4, blueprint ade5749d-bdf3-4fab-a8ae-00bea01b3a5a, internal dns 1, external dns 1 +│ rng: set seed: test-basic +│ rng: next example rng +│ +○ 00000000-0000-0000-0000-000000000000 (generation 0) + root state + + diff --git a/nexus/reconfigurator/simulation/Cargo.toml b/nexus/reconfigurator/simulation/Cargo.toml index 2e3234c5c1..e78634e0a7 100644 --- a/nexus/reconfigurator/simulation/Cargo.toml +++ b/nexus/reconfigurator/simulation/Cargo.toml @@ -10,6 +10,7 @@ workspace = true anyhow.workspace = true camino.workspace = true chrono.workspace = true +indent_write.workspace = true indexmap.workspace = true itertools.workspace = true nexus-inventory.workspace = true @@ -20,6 +21,7 @@ omicron-common.workspace = true omicron-uuid-kinds.workspace = true omicron-workspace-hack.workspace = true petname = { workspace = true, default-features = false } +sapling-renderdag = "0.1.0" slog.workspace = true strum.workspace = true swrite.workspace = true diff --git a/nexus/reconfigurator/simulation/src/config.rs b/nexus/reconfigurator/simulation/src/config.rs index ba680052a2..6eb3fa1c85 100644 --- a/nexus/reconfigurator/simulation/src/config.rs +++ b/nexus/reconfigurator/simulation/src/config.rs @@ -247,6 +247,71 @@ pub enum SimConfigLogEntry { Wipe, } +impl fmt::Display for SimConfigLogEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimConfigLogEntry::LoadSerialized(result) => { + write!(f, "load serialized:\n{}", result) + } + SimConfigLogEntry::AddSilo(name) => { + write!(f, "add silo {}", name) + } + SimConfigLogEntry::RemoveSilo(name) => { + write!(f, "remove silo {}", name) + } + SimConfigLogEntry::SetSiloNames(names) => { + write!( + f, + "set silo names: {}", + names + .iter() + .map(|n| n.as_str()) + .collect::>() + .join(", ") + ) + } + SimConfigLogEntry::SetExternalDnsZoneName(name) => { + write!(f, "set external dns zone name: {}", name) + } + SimConfigLogEntry::SetNumNexus(num) => { + write!(f, "set num nexus: {}", num) + } + SimConfigLogEntry::SetActiveNexusZoneGeneration(gen) => { + write!(f, "set active nexus zone generation: {}", gen) + } + SimConfigLogEntry::SetExplicitActiveNexusZones(zones) => { + match zones { + Some(zones) => write!( + f, + "set explicit active nexus zones: {}", + zones + .iter() + .map(|z| z.to_string()) + .collect::>() + .join(", ") + ), + None => write!(f, "clear explicit active nexus zones"), + } + } + SimConfigLogEntry::SetExplicitNotYetNexusZones(zones) => { + match zones { + Some(zones) => write!( + f, + "set explicit not-yet nexus zones: {}", + zones + .iter() + .map(|z| z.to_string()) + .collect::>() + .join(", ") + ), + None => write!(f, "clear explicit not-yet nexus zones"), + } + } + SimConfigLogEntry::Wipe => write!(f, "wipe"), + } + } +} + /// The output of loading a serializable state into a [`SimConfigBuilder`]. #[derive(Clone, Debug)] #[must_use] diff --git a/nexus/reconfigurator/simulation/src/errors.rs b/nexus/reconfigurator/simulation/src/errors.rs index 2f59821b86..cab7dc18ef 100644 --- a/nexus/reconfigurator/simulation/src/errors.rs +++ b/nexus/reconfigurator/simulation/src/errors.rs @@ -4,8 +4,11 @@ use std::collections::BTreeSet; +use indent_write::indentable::Indentable as _; use itertools::Itertools; use omicron_common::api::external::{Generation, Name}; +use omicron_uuid_kinds::ReconfiguratorSimUuid; +use swrite::{SWrite, swriteln}; use thiserror::Error; use crate::{ @@ -162,3 +165,44 @@ impl UnknownZoneNamesError { Self { unknown, known } } } + +/// A state that matched a prefix query. +#[derive(Clone, Debug)] +pub struct StateMatch { + /// The state ID. + pub id: ReconfiguratorSimUuid, + /// The state generation. + pub generation: Generation, + /// The state description. + pub description: String, +} + +/// Error when resolving a state ID by prefix. +#[derive(Clone, Debug, Error)] +pub enum StateIdPrefixError { + /// No state found with the given prefix. + #[error("no state found with prefix '{0}'")] + NoMatch(String), + + /// Multiple states found with the given prefix. + #[error("prefix '{prefix}' is ambiguous: matches {count} states\n{}", format_matches(.matches))] + Ambiguous { prefix: String, count: usize, matches: Vec }, +} + +fn format_matches(matches: &[StateMatch]) -> String { + let mut output = String::new(); + for state_match in matches { + swriteln!( + output, + " - {} generation {}:", + state_match.id, + state_match.generation + ); + swriteln!( + output, + "{}", + state_match.description.trim_end().indented(" ") + ); + } + output +} diff --git a/nexus/reconfigurator/simulation/src/lib.rs b/nexus/reconfigurator/simulation/src/lib.rs index 30ba3fdc80..2a2bea9675 100644 --- a/nexus/reconfigurator/simulation/src/lib.rs +++ b/nexus/reconfigurator/simulation/src/lib.rs @@ -49,6 +49,7 @@ mod config; pub mod errors; +mod render_graph; mod rng; mod sim; mod state; @@ -57,6 +58,7 @@ mod utils; mod zone_images; pub use config::*; +pub use render_graph::GraphRenderOptions; pub use rng::*; pub use sim::*; pub use state::*; diff --git a/nexus/reconfigurator/simulation/src/render_graph.rs b/nexus/reconfigurator/simulation/src/render_graph.rs new file mode 100644 index 0000000000..2ef603fc24 --- /dev/null +++ b/nexus/reconfigurator/simulation/src/render_graph.rs @@ -0,0 +1,252 @@ +// 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/. + +//! Graph rendering for simulator states. + +use std::collections::{HashMap, HashSet}; + +use omicron_uuid_kinds::ReconfiguratorSimUuid; +use renderdag::{Ancestor, GraphRowRenderer, Renderer}; +use swrite::{SWrite, swrite, swriteln}; + +use crate::{SimState, Simulator, utils::DisplayUuidPrefix}; + +/// Options for rendering the state graph. +#[derive(Clone, Debug)] +pub struct GraphRenderOptions { + verbose: bool, + limit: Option, + from: Option, + current: ReconfiguratorSimUuid, +} + +impl GraphRenderOptions { + /// Create new render options with the current state. + pub fn new(current: ReconfiguratorSimUuid) -> Self { + Self { verbose: false, limit: None, from: None, current } + } + + /// Set true to show verbose details. + pub fn with_verbose(mut self, verbose: bool) -> Self { + self.verbose = verbose; + self + } + + /// Set the generation limit. + pub fn with_limit(mut self, limit: Option) -> Self { + self.limit = limit; + self + } + + /// Set the starting state for ancestry filtering. + pub fn with_from(mut self, from: Option) -> Self { + self.from = from; + self + } +} + +impl Simulator { + /// Render the state graph as text. + pub fn render_graph(&self, options: &GraphRenderOptions) -> String { + let states = self.collect_states_dfs(options); + render_graph(states, options) + } + + /// Collect all states in depth-first order, preserving linear sequences + /// while showing branches at their merge points. + fn collect_states_dfs( + &self, + options: &GraphRenderOptions, + ) -> Vec<&SimState> { + let mut remaining_heads: Vec = + if let Some(from_id) = options.from { + vec![from_id] + } else { + self.heads().iter().copied().collect() + }; + + // Precompute which heads can reach each node. This allows us to find + // merge points (nodes reachable from multiple heads). + let mut node_to_heads: HashMap<_, Vec<_>> = HashMap::new(); + for head_id in &remaining_heads { + let mut current = *head_id; + loop { + node_to_heads.entry(current).or_default().push(*head_id); + match self.get_state(current).and_then(|s| s.parent()) { + Some(parent_id) => current = parent_id, + None => break, + } + } + } + + // Sort the heads so that one of the heads containing the current state + // comes last (this is in reverse order so that .pop() returns it + // first). + remaining_heads.sort_by_key(|head_id| { + node_to_heads + .get(&options.current) + .map(|heads| heads.contains(&head_id)) + .unwrap_or(false) + }); + + let mut walk_state = WalkState::new(remaining_heads); + + while let Some(start_id) = walk_state.remaining_heads.pop() { + self.walk_branch_recursive( + start_id, + &node_to_heads, + &mut walk_state, + ); + } + + walk_state.states + } + + /// Recursively walk a branch, processing merge points along the way. + fn walk_branch_recursive<'a>( + &'a self, + mut current_id: ReconfiguratorSimUuid, + node_to_heads: &HashMap< + ReconfiguratorSimUuid, + Vec, + >, + walk_state: &mut WalkState<'a>, + ) { + loop { + if walk_state.visited.contains(¤t_id) { + break; + } + + walk_state.visited.insert(current_id); + let state = self.get_state(current_id).expect("state should exist"); + walk_state.states.push(state); + + if let Some(parent_id) = state.parent() { + // If there are any branches that merge at the parent, then + // process them before continuing. + let merging: Vec<_> = node_to_heads[&parent_id] + .iter() + .filter(|h| walk_state.remaining_heads.contains(h)) + .copied() + .collect(); + + for other_head in merging { + walk_state.remaining_heads.retain(|&h| h != other_head); + self.walk_branch_recursive( + other_head, + node_to_heads, + walk_state, + ); + } + + current_id = parent_id; + } else { + break; + } + } + } +} + +/// State accumulated during graph traversal. +struct WalkState<'a> { + states: Vec<&'a SimState>, + visited: HashSet, + remaining_heads: Vec, +} + +impl<'a> WalkState<'a> { + fn new(heads: Vec) -> Self { + Self { + states: Vec::new(), + visited: HashSet::new(), + remaining_heads: heads, + } + } +} + +/// Render a state graph using sapling-renderdag. +fn render_graph( + states: Vec<&SimState>, + options: &GraphRenderOptions, +) -> String { + let mut output = String::new(); + + let mut renderer = GraphRowRenderer::new() + .output() + .with_min_row_height(0) + .build_box_drawing(); + + // Apply the limit if specified. + let limited_states = if let Some(limit) = options.limit { + &states[..limit.min(states.len())] + } else { + &states[..] + }; + + for (index, state) in limited_states.iter().enumerate() { + let is_last = index == limited_states.len() - 1; + + let parents = match state.parent() { + Some(parent_id) => vec![Ancestor::Parent(parent_id)], + None => Vec::new(), + }; + + let glyph = if state.id() == options.current { "@" } else { "○" }; + + let mut message = format_message(&state, options); + // Ensure there's exactly two newlines at the end for most commits, and + // one for the last one. This results in a single blank line between + // commit messages. + while message.ends_with('\n') { + message.pop(); + } + message.push('\n'); + if !is_last { + message.push('\n'); + } + + let row = renderer.next_row(state.id(), parents, glyph.into(), message); + + output.push_str(&row); + } + + output +} + +fn format_message(state: &SimState, options: &GraphRenderOptions) -> String { + let mut message = String::new(); + + swriteln!( + message, + "{} (generation {})", + DisplayUuidPrefix::new(state.id(), options.verbose), + state.generation() + ); + + // Description lines. + swrite!(message, "{}", state.description().trim_end()); + + // If verbose, add log details if present. + if options.verbose { + let log = state.log(); + if !log.system.is_empty() + || !log.config.is_empty() + || !log.rng.is_empty() + { + swriteln!(message); + swriteln!(message, "details:"); + for entry in &log.system { + swriteln!(message, " system: {}", entry); + } + for entry in &log.config { + swriteln!(message, " config: {}", entry); + } + for entry in &log.rng { + swriteln!(message, " rng: {}", entry); + } + } + } + + message +} diff --git a/nexus/reconfigurator/simulation/src/rng.rs b/nexus/reconfigurator/simulation/src/rng.rs index 5926eeb24f..915177e055 100644 --- a/nexus/reconfigurator/simulation/src/rng.rs +++ b/nexus/reconfigurator/simulation/src/rng.rs @@ -4,6 +4,8 @@ //! Versioned random number generation for the simulator. +use std::fmt; + use nexus_inventory::CollectionBuilderRng; use nexus_reconfigurator_planning::{ example::{ExampleSystemRng, SimRngState}, @@ -134,6 +136,34 @@ pub enum SimRngLogEntry { NextSledId(SledUuid), } +impl fmt::Display for SimRngLogEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimRngLogEntry::SetSeed(seed) => { + write!(f, "set seed: {}", seed) + } + SimRngLogEntry::ResetState { existing_seed } => { + write!(f, "reset state (existing seed: {})", existing_seed) + } + SimRngLogEntry::RegenerateSeedFromEntropy(seed) => { + write!(f, "regenerate seed from entropy: {}", seed) + } + SimRngLogEntry::NextExampleRng => { + write!(f, "next example rng") + } + SimRngLogEntry::NextCollectionRng => { + write!(f, "next collection rng") + } + SimRngLogEntry::NextPlannerRng => { + write!(f, "next planner rng") + } + SimRngLogEntry::NextSledId(id) => { + write!(f, "next sled id: {}", id) + } + } + } +} + pub(crate) fn seed_from_entropy() -> String { // Each of the word lists petname uses are drawn from a pool of roughly // 1000 words, so 3 words gives us around 30 bits of entropy. That should diff --git a/nexus/reconfigurator/simulation/src/sim.rs b/nexus/reconfigurator/simulation/src/sim.rs index 41b28e58da..5ee39ac1c4 100644 --- a/nexus/reconfigurator/simulation/src/sim.rs +++ b/nexus/reconfigurator/simulation/src/sim.rs @@ -11,7 +11,11 @@ use indexmap::IndexSet; use omicron_uuid_kinds::{ReconfiguratorSimKind, ReconfiguratorSimUuid}; use typed_rng::TypedUuidRng; -use crate::{SimState, seed_from_entropy}; +use crate::{ + SimState, + errors::{StateIdPrefixError, StateMatch}, + seed_from_entropy, +}; /// A store to track reconfigurator states: the main entrypoint for /// reconfigurator simulation. @@ -115,6 +119,56 @@ impl Simulator { &self.root_state } + /// Get a state by UUID prefix. + /// + /// Returns the unique state ID that matches the given prefix. + /// Returns an error if zero or multiple states match the prefix. + pub fn get_state_by_prefix( + &self, + prefix: &str, + ) -> Result { + let mut matching_ids = Vec::new(); + + if Self::ROOT_ID.to_string().starts_with(prefix) { + matching_ids.push(Self::ROOT_ID); + } + + for id in self.states.keys() { + if id.to_string().starts_with(prefix) { + matching_ids.push(*id); + } + } + + match matching_ids.len() { + 0 => Err(StateIdPrefixError::NoMatch(prefix.to_string())), + 1 => Ok(matching_ids[0]), + n => { + // Sort for deterministic output. + matching_ids.sort(); + + let matches = matching_ids + .iter() + .map(|id| { + let state = self + .get_state(*id) + .expect("matching ID should have a state"); + StateMatch { + id: *id, + generation: state.generation(), + description: state.description().to_string(), + } + }) + .collect(); + + Err(StateIdPrefixError::Ambiguous { + prefix: prefix.to_string(), + count: n, + matches, + }) + } + } + } + #[inline] pub(crate) fn next_sim_uuid(&mut self) -> ReconfiguratorSimUuid { self.sim_uuid_rng.next() diff --git a/nexus/reconfigurator/simulation/src/state.rs b/nexus/reconfigurator/simulation/src/state.rs index 737814bafd..d469849512 100644 --- a/nexus/reconfigurator/simulation/src/state.rs +++ b/nexus/reconfigurator/simulation/src/state.rs @@ -89,6 +89,12 @@ impl SimState { self.parent } + #[inline] + #[must_use] + pub fn generation(&self) -> Generation { + self.generation + } + #[inline] #[must_use] pub fn description(&self) -> &str { diff --git a/nexus/reconfigurator/simulation/src/system.rs b/nexus/reconfigurator/simulation/src/system.rs index 72954971c0..752cb03e32 100644 --- a/nexus/reconfigurator/simulation/src/system.rs +++ b/nexus/reconfigurator/simulation/src/system.rs @@ -579,6 +579,45 @@ pub enum SimSystemLogEntry { Wipe, } +impl fmt::Display for SimSystemLogEntry { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + SimSystemLogEntry::LoadExample { + collection_id, + blueprint_id, + internal_dns_version, + external_dns_version, + } => { + write!( + f, + "load example: collection {}, blueprint {}, \ + internal dns {}, external dns {}", + collection_id, + blueprint_id, + internal_dns_version, + external_dns_version + ) + } + SimSystemLogEntry::LoadSerialized(result) => { + write!(f, "load serialized:\n{}", result) + } + SimSystemLogEntry::AddCollection(id) => { + write!(f, "add collection {}", id) + } + SimSystemLogEntry::AddBlueprint(id) => { + write!(f, "add blueprint {}", id) + } + SimSystemLogEntry::AddInternalDns(gen) => { + write!(f, "add internal dns {}", gen) + } + SimSystemLogEntry::AddExternalDns(gen) => { + write!(f, "add external dns {}", gen) + } + SimSystemLogEntry::Wipe => write!(f, "wipe"), + } + } +} + /// The result of loading a serialized system state. /// /// Returned by [`LoadSerializedResult`](crate::LoadSerializedResult), as well diff --git a/nexus/reconfigurator/simulation/src/utils.rs b/nexus/reconfigurator/simulation/src/utils.rs index 639afdef74..446cbe851a 100644 --- a/nexus/reconfigurator/simulation/src/utils.rs +++ b/nexus/reconfigurator/simulation/src/utils.rs @@ -7,6 +7,7 @@ use std::{fmt, hash::Hash}; use indexmap::IndexMap; +use omicron_uuid_kinds::{TypedUuid, TypedUuidKind}; use swrite::{SWrite, swrite}; pub(crate) fn join_comma_or_none(iter: I) -> String @@ -56,6 +57,35 @@ where Ok(()) } +/// Displays a prefix of the given UUID string based on whether `verbose` is +/// true. +pub struct DisplayUuidPrefix { + uuid: TypedUuid, + verbose: bool, +} + +impl DisplayUuidPrefix { + pub fn new(uuid: TypedUuid, verbose: bool) -> Self { + Self { uuid, verbose } + } +} + +impl fmt::Display for DisplayUuidPrefix { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.verbose { + self.uuid.fmt(f) + } else { + // We have a pretty small number of states, so for all practical + // purposes, the first component of the UUID (8 hex digits, 32 bits) + // are sufficient. We could potentially improve this to determine + // unique prefixes using a trie and highlight them like Jujutsu + // does. + let bytes = self.uuid.as_fields(); + write!(f, "{:08x}", bytes.0) + } + } +} + #[derive(Debug)] pub(crate) struct DuplicateKey(pub(crate) K); diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index 5f6201a019..f73192914e 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -83,7 +83,7 @@ libc = { version = "0.2.174", features = ["extra_traits"] } log = { version = "0.4.27", default-features = false, features = ["kv_unstable", "std"] } managed = { version = "0.8.0", default-features = false, features = ["alloc", "map"] } memchr = { version = "2.7.4" } -newtype-uuid = { version = "1.3.1", features = ["proptest1"] } +newtype-uuid = { version = "1.3.2", features = ["proptest1"] } nix = { version = "0.29.0", features = ["feature", "net", "uio"] } num-bigint-dig = { version = "0.8.4", default-features = false, features = ["i128", "prime", "serde", "u64_digit", "zeroize"] } num-integer = { version = "0.1.46", features = ["i128"] } @@ -224,7 +224,7 @@ libc = { version = "0.2.174", features = ["extra_traits"] } log = { version = "0.4.27", default-features = false, features = ["kv_unstable", "std"] } managed = { version = "0.8.0", default-features = false, features = ["alloc", "map"] } memchr = { version = "2.7.4" } -newtype-uuid = { version = "1.3.1", features = ["proptest1"] } +newtype-uuid = { version = "1.3.2", features = ["proptest1"] } nix = { version = "0.29.0", features = ["feature", "net", "uio"] } num-bigint-dig = { version = "0.8.4", default-features = false, features = ["i128", "prime", "serde", "u64_digit", "zeroize"] } num-integer = { version = "0.1.46", features = ["i128"] }