diff --git a/dev-tools/reconfigurator-cli/src/lib.rs b/dev-tools/reconfigurator-cli/src/lib.rs index e9bd95d67e4..75ca8b6887c 100644 --- a/dev-tools/reconfigurator-cli/src/lib.rs +++ b/dev-tools/reconfigurator-cli/src/lib.rs @@ -28,7 +28,7 @@ use nexus_reconfigurator_planning::system::{ RotStateOverrides, SledBuilder, SledInventoryVisibility, SystemDescription, }; use nexus_reconfigurator_simulation::{ - BlueprintId, CollectionId, GraphRenderOptions, SimState, + BlueprintId, CollectionId, DisplayUuidPrefix, GraphRenderOptions, SimState, }; use nexus_reconfigurator_simulation::{SimStateBuilder, SimTufRepoSource}; use nexus_reconfigurator_simulation::{SimTufRepoDescription, Simulator}; @@ -61,6 +61,7 @@ use omicron_repl_utils::run_repl_from_file; use omicron_repl_utils::run_repl_on_stdin; use omicron_uuid_kinds::GenericUuid; use omicron_uuid_kinds::OmicronZoneUuid; +use omicron_uuid_kinds::ReconfiguratorSimOpUuid; use omicron_uuid_kinds::ReconfiguratorSimStateUuid; use omicron_uuid_kinds::SledUuid; use omicron_uuid_kinds::VnicUuid; @@ -88,30 +89,23 @@ mod log_capture; struct ReconfiguratorSim { // The simulator currently being used. sim: Simulator, - // The current state. - current: ReconfiguratorSimStateUuid, // The current system state log: slog::Logger, } impl ReconfiguratorSim { fn new(log: slog::Logger, seed: Option) -> Self { - Self { - sim: Simulator::new(&log, seed), - current: Simulator::ROOT_ID, - log, - } + Self { sim: Simulator::new(&log, seed), log } } fn current_state(&self) -> &SimState { self.sim - .get_state(self.current) + .get_state(self.sim.current()) .expect("current state should always exist") } fn commit_and_bump(&mut self, description: String, state: SimStateBuilder) { - let new_id = state.commit(description, &mut self.sim); - self.current = new_id; + state.commit_and_bump(description, &mut self.sim); } fn planning_input( @@ -326,6 +320,13 @@ fn process_command( 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::Op(OpArgs::Log(args)) => cmd_op_log(sim, args), + Commands::Op(OpArgs::Undo) => cmd_op_undo(sim), + Commands::Op(OpArgs::Redo) => cmd_op_redo(sim), + Commands::Op(OpArgs::Restore(args)) => cmd_op_restore(sim, args), + Commands::Op(OpArgs::Wipe) => cmd_op_wipe(sim), + Commands::Undo => cmd_op_undo(sim), + Commands::Redo => cmd_op_redo(sim), Commands::Wipe(args) => cmd_wipe(sim, args), }; @@ -427,6 +428,13 @@ enum Commands { /// state-related commands #[command(flatten)] State(StateArgs), + /// operation log commands (undo, redo, restore) + #[command(subcommand)] + Op(OpArgs), + /// undo the last operation (alias for `op undo`) + Undo, + /// redo the last undone operation (alias for `op redo`) + Redo, /// reset the state of the REPL Wipe(WipeArgs), } @@ -1473,6 +1481,50 @@ struct StateSwitchArgs { state_id: String, } +#[derive(Debug, Subcommand)] +enum OpArgs { + /// display the operation log + /// + /// Shows the history of operations, similar to `jj op log`. + Log(OpLogArgs), + /// undo the most recent operation + /// + /// Creates a new restore operation that goes back to the previous state. + Undo, + /// redo a previously undone operation + /// + /// Creates a new restore operation that goes forward to a previously + /// undone state. + Redo, + /// restore to a specific operation + /// + /// Creates a new restore operation that sets the heads to match those + /// of the specified operation. + Restore(OpRestoreArgs), + /// wipe the operation log + /// + /// Clears all operation history and resets to just the root operation. + /// This is the only operation that violates the append-only principle. + Wipe, +} + +#[derive(Debug, Args)] +struct OpLogArgs { + /// Limit number of operations to display + #[clap(long, short = 'n')] + limit: Option, + + /// Verbose mode: show full UUIDs and heads at each operation + #[clap(long, short = 'v')] + verbose: bool, +} + +#[derive(Debug, Args)] +struct OpRestoreArgs { + /// The operation ID or unique prefix to restore to + operation_id: String, +} + #[derive(Debug, Args)] struct WipeArgs { /// What to wipe @@ -2813,7 +2865,7 @@ fn cmd_state_log( let StateLogArgs { from, limit, verbose } = args; // Build rendering options. - let options = GraphRenderOptions::new(sim.current) + let options = GraphRenderOptions::new(sim.sim.current()) .with_verbose(verbose) .with_limit(limit) .with_from(from); @@ -2834,21 +2886,83 @@ fn cmd_state_switch( 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))?; + let (generation, description) = { + let state = sim + .sim + .get_state(target_id) + .ok_or_else(|| anyhow!("state {} not found", target_id))?; + (state.generation(), state.description().to_string()) + }; - sim.current = target_id; + sim.sim.switch_state(target_id)?; Ok(Some(format!( "switched to state {} (generation {}): {}", - target_id, - state.generation(), - state.description() + target_id, generation, description + ))) +} + +fn cmd_op_log( + sim: &mut ReconfiguratorSim, + args: OpLogArgs, +) -> anyhow::Result> { + let output = sim.sim.render_operation_graph(args.limit, args.verbose); + Ok(Some(output)) +} + +fn cmd_op_undo(sim: &mut ReconfiguratorSim) -> anyhow::Result> { + sim.sim.operation_undo()?; + + let current_op = sim.sim.operation_current(); + Ok(Some(format!( + "created operation {}: {}", + DisplayUuidPrefix::new(current_op.id(), false), + current_op.description(false) + ))) +} + +fn cmd_op_redo(sim: &mut ReconfiguratorSim) -> anyhow::Result> { + sim.sim.operation_redo()?; + + let current_op = sim.sim.operation_current(); + Ok(Some(format!( + "created operation {}: {}", + DisplayUuidPrefix::new(current_op.id(), false), + current_op.description(false) ))) } +fn cmd_op_restore( + sim: &mut ReconfiguratorSim, + args: OpRestoreArgs, +) -> anyhow::Result> { + // Try parsing as a full UUID first, then fall back to prefix matching. + let target_id = match args.operation_id.parse::() { + Ok(id) => id, + Err(_) => sim.sim.operation_get_by_prefix(&args.operation_id)?, + }; + + let target_op = sim + .sim + .operation_get(target_id) + .ok_or_else(|| anyhow!("operation {} not found", target_id))?; + + let description = target_op.description(false); + + sim.sim.operation_restore(target_id)?; + + Ok(Some(format!( + "created operation {}: {}", + DisplayUuidPrefix::new(target_id, false), + description + ))) +} + +fn cmd_op_wipe(sim: &mut ReconfiguratorSim) -> anyhow::Result> { + sim.sim.operation_wipe(); + Ok(Some("wiped operation log".to_string())) +} + fn cmd_wipe( sim: &mut ReconfiguratorSim, args: WipeArgs, diff --git a/dev-tools/reconfigurator-cli/tests/input/cmds-undo-redo.txt b/dev-tools/reconfigurator-cli/tests/input/cmds-undo-redo.txt new file mode 100644 index 00000000000..cddb5f048d9 --- /dev/null +++ b/dev-tools/reconfigurator-cli/tests/input/cmds-undo-redo.txt @@ -0,0 +1,60 @@ +# Test undo/redo functionality for the operation log + +load-example --seed test-undo-redo --nsleds 2 --ndisks-per-sled 3 + +op log + +# Make some changes to create an operation history. +sled-add +sled-add +silo-add test-silo +log +op log + +# First, undo the silo-add. +undo +log +op log + +# Verify we can undo multiple times: undo the sled-add. +undo +op log + +# Redo the sled-add we just undid. +redo +op log + +# Redo again to restore the silo-add. +redo +op log + +# Make a new change. +silo-add another-silo +op log + +# Try to undo when we're already at the root. This should fail. +op wipe +op log +undo + +# Try to undo twice with one operation available. +load-example --seed test-undo-redo --nsleds 1 +op log +undo +undo + +# This redo should work. +redo +op log + +# We're out of undos, so this redo should fail. +redo + +# Do another undo and redo. +undo +redo +op log + +# Test op log with the --verbose flag. +op log -n 5 --verbose +log diff --git a/dev-tools/reconfigurator-cli/tests/output/cmds-undo-redo-stderr b/dev-tools/reconfigurator-cli/tests/output/cmds-undo-redo-stderr new file mode 100644 index 00000000000..e69de29bb2d diff --git a/dev-tools/reconfigurator-cli/tests/output/cmds-undo-redo-stdout b/dev-tools/reconfigurator-cli/tests/output/cmds-undo-redo-stdout new file mode 100644 index 00000000000..4bf2361db06 --- /dev/null +++ b/dev-tools/reconfigurator-cli/tests/output/cmds-undo-redo-stdout @@ -0,0 +1,292 @@ +using provided RNG seed: reconfigurator-cli-test +> # Test undo/redo functionality for the operation log + +> load-example --seed test-undo-redo --nsleds 2 --ndisks-per-sled 3 +loaded example system with: +- collection: 44ce0631-f53e-4d2e-83ec-f8d6f99f6915 +- blueprint: 2c015447-ddb0-4885-99f5-f8c0022e63da + + +> op log +@ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Make some changes to create an operation history. +> sled-add +added sled 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) + +> sled-add +added sled 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) + +> silo-add test-silo + +> log +@ a887277f (generation 4) +│ reconfigurator-cli silo-add +│ +○ c7f48fbf (generation 3) +│ reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +│ +○ ac7a3cd0 (generation 2) +│ reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +│ +○ a6dcc37c (generation 1) +│ reconfigurator-cli load-example +│ +○ 00000000 (generation 0) + root state + + +> op log +@ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # First, undo the silo-add. +> undo +created operation 5c4684eb: undo to 05930b3c + +> log +@ c7f48fbf (generation 3) +│ reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +│ +○ ac7a3cd0 (generation 2) +│ reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +│ +○ a6dcc37c (generation 1) +│ reconfigurator-cli load-example +│ +○ 00000000 (generation 0) + root state + + +> op log +@ 5c4684eb +│ undo to 05930b3c +○ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Verify we can undo multiple times: undo the sled-add. +> undo +created operation e2abb4bc: undo to 71a23fa4 + +> op log +@ e2abb4bc +│ undo to 71a23fa4 +○ 5c4684eb +│ undo to 05930b3c +○ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Redo the sled-add we just undid. +> redo +created operation 5ec33797: redo to 5c4684eb + +> op log +@ 5ec33797 +│ redo to 5c4684eb +○ e2abb4bc +│ undo to 71a23fa4 +○ 5c4684eb +│ undo to 05930b3c +○ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Redo again to restore the silo-add. +> redo +created operation 52f67a5a: redo to c5958a67 + +> op log +@ 52f67a5a +│ redo to c5958a67 +○ 5ec33797 +│ redo to 5c4684eb +○ e2abb4bc +│ undo to 71a23fa4 +○ 5c4684eb +│ undo to 05930b3c +○ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Make a new change. +> silo-add another-silo + +> op log +@ f7c311c2 +│ add state: reconfigurator-cli silo-add +○ 52f67a5a +│ redo to c5958a67 +○ 5ec33797 +│ redo to 5c4684eb +○ e2abb4bc +│ undo to 71a23fa4 +○ 5c4684eb +│ undo to 05930b3c +○ c5958a67 +│ add state: reconfigurator-cli silo-add +○ 05930b3c +│ add state: reconfigurator-cli sled-add: 164b2c79-0330-4c99-9de9-82d20209ae24 (serial: serial3) +○ 71a23fa4 +│ add state: reconfigurator-cli sled-add: 1d4e6291-2272-4c6b-b1c8-161404173e5d (serial: serial2) +○ 22608af0 +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Try to undo when we're already at the root. This should fail. +> op wipe +wiped operation log + +> op log +@ 00000000 + initialize simulator + + +> undo +error: cannot undo: already at root operation + + +> # Try to undo twice with one operation available. +> load-example --seed test-undo-redo --nsleds 1 +loaded example system with: +- collection: 44ce0631-f53e-4d2e-83ec-f8d6f99f6915 +- blueprint: 2c015447-ddb0-4885-99f5-f8c0022e63da + +> op log +@ f1a9356d +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + +> undo +created operation ac313fb0: undo to 00000000 + +> undo +error: cannot undo: already at root operation + + +> # This redo should work. +> redo +created operation 10d122d4: redo to f1a9356d + +> op log +@ 10d122d4 +│ redo to f1a9356d +○ ac313fb0 +│ undo to 00000000 +○ f1a9356d +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # We're out of undos, so this redo should fail. +> redo +error: cannot redo: no redo available + + +> # Do another undo and redo. +> undo +created operation 02e35bfb: undo to 00000000 + +> redo +created operation 03e83a20: redo to f1a9356d + +> op log +@ 03e83a20 +│ redo to f1a9356d +○ 02e35bfb +│ undo to 00000000 +○ 10d122d4 +│ redo to f1a9356d +○ ac313fb0 +│ undo to 00000000 +○ f1a9356d +│ add state: reconfigurator-cli load-example +○ 00000000 + initialize simulator + + + +> # Test op log with the --verbose flag. +> op log -n 5 --verbose +@ 03e83a20-ce0d-4bb5-a34a-1f3c8f51961a +│ redo to f1a9356d-1aa0-4ea4-9609-5ba747f8ea01 +│ @ 2d296288-6fbc-4acb-8669-97fb8f38e5b7 (gen 1) +○ 02e35bfb-f9d0-4cec-8ad1-4c74dff01aaa +│ undo to 00000000-0000-0000-0000-000000000000 +○ 10d122d4-5e75-4ffc-99f0-4f3d884894bd +│ redo to f1a9356d-1aa0-4ea4-9609-5ba747f8ea01 +│ @ 2d296288-6fbc-4acb-8669-97fb8f38e5b7 (gen 1) +○ ac313fb0-b627-4b81-82c5-432e6ca46836 +│ undo to 00000000-0000-0000-0000-000000000000 +○ f1a9356d-1aa0-4ea4-9609-5ba747f8ea01 + add state: reconfigurator-cli load-example + @ 2d296288-6fbc-4acb-8669-97fb8f38e5b7 (gen 1) + + +> log +@ 2d296288 (generation 1) +│ reconfigurator-cli load-example +│ +○ 00000000 (generation 0) + root state + + diff --git a/nexus/reconfigurator/simulation/src/errors.rs b/nexus/reconfigurator/simulation/src/errors.rs index e7082c731dc..5d64ed45cef 100644 --- a/nexus/reconfigurator/simulation/src/errors.rs +++ b/nexus/reconfigurator/simulation/src/errors.rs @@ -4,10 +4,11 @@ use std::collections::BTreeSet; +use chrono::{DateTime, Utc}; use indent_write::indentable::Indentable as _; use itertools::Itertools; use omicron_common::api::external::{Generation, Name}; -use omicron_uuid_kinds::ReconfiguratorSimStateUuid; +use omicron_uuid_kinds::{ReconfiguratorSimOpUuid, ReconfiguratorSimStateUuid}; use swrite::{SWrite, swriteln}; use thiserror::Error; @@ -206,3 +207,60 @@ fn format_matches(matches: &[StateMatch]) -> String { } output } + +/// An operation that matched a prefix query. +#[derive(Clone, Debug)] +pub struct OpMatch { + /// The operation ID. + pub id: ReconfiguratorSimOpUuid, + /// The operation description. + pub description: String, + /// The operation timestamp. + pub timestamp: DateTime, +} + +/// Error when resolving an operation ID by prefix. +#[derive(Clone, Debug, Error)] +pub enum OperationIdPrefixError { + /// No operation found with the given prefix. + #[error("no operation found with prefix '{0}'")] + NoMatch(String), + + /// Multiple operations found with the given prefix. + #[error("prefix '{prefix}' is ambiguous: matches {count} operations\n{}", format_op_matches(.matches))] + Ambiguous { prefix: String, count: usize, matches: Vec }, +} + +fn format_op_matches(matches: &[OpMatch]) -> String { + let mut output = String::new(); + for op_match in matches { + swriteln!( + output, + " - {} ({}): {}", + op_match.id, + op_match.timestamp, + op_match.description + ); + } + output +} + +/// Error when performing operation log operations (undo/redo/restore). +#[derive(Clone, Debug, Error)] +pub enum OperationError { + /// Operation not found. + #[error("operation not found: {0}")] + NotFound(ReconfiguratorSimOpUuid), + + /// State not found. + #[error("state not found: {0}")] + StateNotFound(ReconfiguratorSimStateUuid), + + /// Cannot undo: already at root operation. + #[error("cannot undo: already at root operation")] + AtRoot, + + /// Cannot redo: no operation to redo to. + #[error("cannot redo: no redo available")] + NoRedo, +} diff --git a/nexus/reconfigurator/simulation/src/lib.rs b/nexus/reconfigurator/simulation/src/lib.rs index 2a2bea96753..82dfb0da3a3 100644 --- a/nexus/reconfigurator/simulation/src/lib.rs +++ b/nexus/reconfigurator/simulation/src/lib.rs @@ -39,7 +39,7 @@ //! //! Mutating states is done by calling [`SimState::to_mut`], which returns a //! [`SimStateBuilder`]. Once changes are made, the state can be committed back -//! to the system with [`SimStateBuilder::commit`]. +//! to the system with [`SimStateBuilder::commit_and_bump`]. //! //! ## Determinism //! @@ -49,6 +49,7 @@ mod config; pub mod errors; +mod operation; mod render_graph; mod rng; mod sim; @@ -58,9 +59,11 @@ mod utils; mod zone_images; pub use config::*; +pub use operation::*; pub use render_graph::GraphRenderOptions; pub use rng::*; pub use sim::*; pub use state::*; pub use system::*; +pub use utils::*; pub use zone_images::*; diff --git a/nexus/reconfigurator/simulation/src/operation.rs b/nexus/reconfigurator/simulation/src/operation.rs new file mode 100644 index 00000000000..04b2c79883b --- /dev/null +++ b/nexus/reconfigurator/simulation/src/operation.rs @@ -0,0 +1,235 @@ +// 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/. + +//! Operations in the simulator's history. +//! +//! Operations track how the set of heads changes over time, similar to +//! `jj op log`. This enables undo, redo, and restore functionality. + +use chrono::{DateTime, Utc}; +use indexmap::IndexSet; +use omicron_uuid_kinds::{ReconfiguratorSimOpUuid, ReconfiguratorSimStateUuid}; +use renderdag::{Ancestor, GraphRowRenderer, Renderer}; +use swrite::{SWrite, swrite, swriteln}; + +use crate::{Simulator, utils::DisplayUuidPrefix}; + +/// A single operation in the simulator's history. +#[derive(Clone, Debug)] +pub struct SimOperation { + id: ReconfiguratorSimOpUuid, + heads: IndexSet, + current: ReconfiguratorSimStateUuid, + kind: SimOperationKind, + timestamp: DateTime, +} + +impl SimOperation { + /// Create the root operation. + pub(crate) fn root() -> Self { + Self { + id: Simulator::ROOT_OPERATION_ID, + heads: IndexSet::new(), + current: Simulator::ROOT_ID, + kind: SimOperationKind::Initialize, + timestamp: Utc::now(), + } + } + + /// Create a new operation. + pub(crate) fn new( + id: ReconfiguratorSimOpUuid, + heads: IndexSet, + current: ReconfiguratorSimStateUuid, + kind: SimOperationKind, + ) -> Self { + Self { id, heads, current, kind, timestamp: Utc::now() } + } + + /// Get the operation ID. + pub fn id(&self) -> ReconfiguratorSimOpUuid { + self.id + } + + /// Get the heads at this operation. + pub fn heads(&self) -> &IndexSet { + &self.heads + } + + /// Get the current state at this operation. + pub fn current(&self) -> ReconfiguratorSimStateUuid { + self.current + } + + /// Get the operation kind. + pub fn kind(&self) -> &SimOperationKind { + &self.kind + } + + /// Get the timestamp. + pub fn timestamp(&self) -> DateTime { + self.timestamp + } + + /// Format a description of this operation for display. + pub fn description(&self, verbose: bool) -> String { + match &self.kind { + SimOperationKind::Initialize => "initialize simulator".to_string(), + SimOperationKind::StateAdd { description, .. } => { + format!("add state: {}", description) + } + SimOperationKind::StateSwitch { state_id } => { + format!( + "switch to state: {}", + DisplayUuidPrefix::new(*state_id, verbose) + ) + } + SimOperationKind::Restore { target: to, kind } => { + let prefix = match kind { + RestoreKind::Undo => "undo to", + RestoreKind::Redo => "redo to", + RestoreKind::Explicit => "restore to", + }; + format!("{} {}", prefix, DisplayUuidPrefix::new(*to, verbose)) + } + } + } +} + +/// The kind of restore operation, part of [`SimOperationKind`]. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum RestoreKind { + /// An undo operation, moving backward in time. + Undo, + /// A redo operation, moving forward in time. + Redo, + /// An explicit restore to a specific operation. + Explicit, +} + +/// The kind of [`SimOperation`]. +#[derive(Clone, Debug)] +pub enum SimOperationKind { + /// Initial operation. + Initialize, + + /// Add a new state. + StateAdd { state_id: ReconfiguratorSimStateUuid, description: String }, + + /// Switch to an existing state. + StateSwitch { state_id: ReconfiguratorSimStateUuid }, + + /// Restore to a target operation. + Restore { target: ReconfiguratorSimOpUuid, kind: RestoreKind }, +} + +impl SimOperationKind { + pub fn is_undo(&self) -> bool { + matches!( + self, + SimOperationKind::Restore { kind: RestoreKind::Undo, .. } + ) + } + + pub fn is_redo(&self) -> bool { + matches!( + self, + SimOperationKind::Restore { kind: RestoreKind::Redo, .. } + ) + } +} + +/// Render the operation log as a graph. +/// +/// Operations are shown in reverse chronological order (newest first) as a +/// linear chain (branches are not allowed or supported in the operation graph). +pub(crate) fn render_operation_graph( + simulator: &Simulator, + limit: Option, + verbose: bool, +) -> String { + let mut output = String::new(); + + let mut renderer = GraphRowRenderer::new() + .output() + .with_min_row_height(0) + .build_box_drawing(); + + let start = if let Some(limit) = limit { + simulator.operations().count().saturating_sub(limit) + } else { + 0 + }; + let limited_ops: Vec<_> = simulator.operations().skip(start).collect(); + + // Process in reverse order. + for (op_index, op) in limited_ops.iter().enumerate().rev() { + let shown_first = op_index == limited_ops.len() - 1; + let shown_last = op_index == 0; + + let parents = if shown_last { + Vec::new() + } else { + let next_op = limited_ops[op_index - 1]; + vec![Ancestor::Parent(next_op.id())] + }; + + let glyph = if shown_first { "@" } else { "○" }; + + let mut message = String::new(); + let timestamp = + op.timestamp().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); + swrite!( + message, + "{} {}\n{}", + DisplayUuidPrefix::new(op.id(), verbose), + timestamp, + op.description(verbose), + ); + // Remove trailing newlines and add one at the end. + while message.ends_with('\n') { + message.pop(); + } + message.push('\n'); + + // If verbose is true, show heads at this operation. + if verbose && !op.heads().is_empty() { + let current = op.current(); + + // Show the current head first with an @ marker. + if let Some(state) = simulator.get_state(current) { + let head_str = current.to_string(); + swriteln!( + message, + " @ {} (gen {})", + head_str, + state.generation() + ); + } + + // Show other heads with *. + for head in op.heads() { + if *head != current { + let head_str = head.to_string(); + if let Some(state) = simulator.get_state(*head) { + swriteln!( + message, + " * {} (gen {})", + head_str, + state.generation() + ); + } + } + } + } + + // We choose to have a more compact view than render_graph here, without + // a blank line between operations. + + let row = renderer.next_row(op.id(), parents, glyph.into(), message); + output.push_str(&row); + } + + output +} diff --git a/nexus/reconfigurator/simulation/src/sim.rs b/nexus/reconfigurator/simulation/src/sim.rs index c026cd04658..f1e071aef8b 100644 --- a/nexus/reconfigurator/simulation/src/sim.rs +++ b/nexus/reconfigurator/simulation/src/sim.rs @@ -7,15 +7,19 @@ use std::{collections::HashMap, sync::Arc}; -use indexmap::IndexSet; +use indexmap::{IndexMap, IndexSet}; use omicron_uuid_kinds::{ + ReconfiguratorSimOpKind, ReconfiguratorSimOpUuid, ReconfiguratorSimStateKind, ReconfiguratorSimStateUuid, }; use typed_rng::TypedUuidRng; use crate::{ - SimState, - errors::{StateIdPrefixError, StateMatch}, + RestoreKind, SimOperation, SimOperationKind, SimState, + errors::{ + OpMatch, OperationError, OperationIdPrefixError, StateIdPrefixError, + StateMatch, + }, seed_from_entropy, }; @@ -42,15 +46,6 @@ use crate::{ #[derive(Clone, Debug)] pub struct Simulator { log: slog::Logger, - // The set of terminal nodes in the tree -- all states are reachable from - // one or more of these. - // - // Similar to the list of Git branches or Jujutsu/Mercurial heads. - // - // In the future, it would be interesting to store a chain of every set of - // heads over time, similar to `jj op log`. That would let us implement undo - // and restore operations. - heads: IndexSet, states: HashMap>, // This state corresponds to `ROOT_ID`. // @@ -62,6 +57,14 @@ pub struct Simulator { root_state: Arc, // Top-level (unversioned) RNG. sim_uuid_rng: TypedUuidRng, + // Append-only operation log tracking how heads change over time, similar to + // `jj op log`. + // + // The last entry is the current operation, and its heads field contains the + // current set of heads. + operations: IndexMap, + // RNG for generating operation UUIDs. + op_uuid_rng: TypedUuidRng, } impl Simulator { @@ -72,6 +75,12 @@ impl Simulator { pub const ROOT_ID: ReconfiguratorSimStateUuid = ReconfiguratorSimStateUuid::nil(); + /// The root operation ID. + /// + /// This is always the first operation in the log. + pub const ROOT_OPERATION_ID: ReconfiguratorSimOpUuid = + ReconfiguratorSimOpUuid::nil(); + /// Create a new simulator with the given initial seed. pub fn new(log: &slog::Logger, seed: Option) -> Self { let seed = seed.unwrap_or_else(|| seed_from_entropy()); @@ -84,13 +93,20 @@ impl Simulator { // Retain the old name in the seed for generated ID compatibility. let sim_uuid_rng = TypedUuidRng::from_seed(&seed, "ReconfiguratorSimUuid"); + let op_uuid_rng = + TypedUuidRng::from_seed(&seed, "ReconfiguratorSimOpUuid"); let root_state = SimState::new_root(seed); + + let mut operations = IndexMap::new(); + operations.insert(Self::ROOT_OPERATION_ID, SimOperation::root()); + Self { log, - heads: IndexSet::new(), states: HashMap::new(), root_state, sim_uuid_rng, + operations, + op_uuid_rng, } } @@ -105,7 +121,13 @@ impl Simulator { /// Get the current heads of the store. #[inline] pub fn heads(&self) -> &IndexSet { - &self.heads + self.operation_current().heads() + } + + /// Get the current state ID. + #[inline] + pub fn current(&self) -> ReconfiguratorSimStateUuid { + self.operation_current().current() } /// Get the state for the given UUID. @@ -182,23 +204,45 @@ impl Simulator { self.sim_uuid_rng.next() } - // Invariant: the ID should be not present in the store, having been - // generated by next_sim_uuid. + /// Render the operation log as a graph. + /// + /// Operations are shown in reverse chronological order (newest first) as a + /// linear chain. + pub fn render_operation_graph( + &self, + limit: Option, + verbose: bool, + ) -> String { + crate::operation::render_operation_graph(self, limit, verbose) + } + pub(crate) fn add_state(&mut self, state: Arc) { let id = state.id(); let parent = state.parent(); - if self.states.insert(id, state).is_some() { + let description = state.description().to_string(); + + if self.states.insert(id, Arc::clone(&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 + // Compute the new heads: remove the parent if it exists, and add the + // new state. 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. + let mut new_heads = self.operation_current().heads().clone(); if let Some(parent) = parent { - self.heads.shift_remove(&parent); + new_heads.shift_remove(&parent); } - self.heads.insert(id); + new_heads.insert(id); + + // Record this operation in the log. + let op = SimOperation::new( + self.op_uuid_rng.next(), + new_heads, + id, + SimOperationKind::StateAdd { state_id: id, description }, + ); + self.operations.insert(op.id(), op); slog::debug!( self.log, @@ -207,4 +251,287 @@ impl Simulator { "parent" => ?parent, ); } + + /// Switch to an existing state. + /// + /// This creates a new operation that changes the current state pointer + /// without adding a new state. + pub fn switch_state( + &mut self, + target_id: ReconfiguratorSimStateUuid, + ) -> Result<(), OperationError> { + // Verify the target state exists. + if target_id != Self::ROOT_ID && !self.states.contains_key(&target_id) { + return Err(OperationError::StateNotFound(target_id)); + } + + // The heads don't change - only the current pointer changes. + let new_heads = self.operation_current().heads().clone(); + + // Create a new SwitchState operation. + let switch_op = SimOperation::new( + self.op_uuid_rng.next(), + new_heads, + target_id, + SimOperationKind::StateSwitch { state_id: target_id }, + ); + self.operations.insert(switch_op.id(), switch_op); + + slog::debug!( + self.log, + "switched to state"; + "to" => %target_id, + ); + + Ok(()) + } + + /// Get the current operation. + pub fn operation_current(&self) -> &SimOperation { + self.operations.last().expect("operations should never be empty").1 + } + + /// Get all operations in the log. + pub fn operations(&self) -> impl Iterator { + self.operations.values() + } + + /// Get an operation by ID. + pub fn operation_get( + &self, + id: ReconfiguratorSimOpUuid, + ) -> Option<&SimOperation> { + self.operations.get(&id) + } + + /// Get an operation by UUID prefix. + /// + /// Returns the unique operation ID that matches the given prefix. + /// Returns an error if zero or multiple operations match the prefix. + pub fn operation_get_by_prefix( + &self, + prefix: &str, + ) -> Result { + let mut matching_ids = Vec::new(); + + for op in self.operations.values() { + if op.id().to_string().starts_with(prefix) { + matching_ids.push(op.id()); + } + } + + match matching_ids.len() { + 0 => Err(OperationIdPrefixError::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 op = self + .operation_get(*id) + .expect("matching ID should have an operation"); + OpMatch { + id: *id, + description: op.description(false), + timestamp: op.timestamp(), + } + }) + .collect(); + + Err(OperationIdPrefixError::Ambiguous { + prefix: prefix.to_string(), + count: n, + matches, + }) + } + } + } + + /// Undo an operation. + /// + /// This restores the heads to an earlier state. + pub fn operation_undo(&mut self) -> Result<(), OperationError> { + let current_op = self.operation_current(); + + // Follow the undo stack algorithm described in + // https://github.com/jj-vcs/jj/blob/fd5accb/cli/src/commands/undo.rs#L100: + // + // - If the operation to undo is a regular one (not an undo-operation), simply + // undo it (== restore its parent). + // - If the operation to undo is an undo-operation itself, undo that operation + // to which the previous undo-operation restored the repo. + // - If the operation to restore to is an undo-operation, restore directly to + // the original operation. This avoids creating a linked list of + // undo-operations, which subsequently may have to be walked with an + // inefficient loop. + + let op_to_undo_index = match current_op.kind() { + SimOperationKind::Restore { target, kind: RestoreKind::Undo } => { + self.operations + .get_index_of(target) + .expect("restored-to operation should exist") + } + _ => { + // The current operation is at self.operations.len() - 1 (there + // should always be at least one operation). + self.operations.len() - 1 + } + }; + if op_to_undo_index == 0 { + return Err(OperationError::AtRoot); + } + + let (_, mut op_to_restore) = self + .operations + .get_index(op_to_undo_index - 1) + .expect("op should exist"); + + // If target_op is an undo operation, go to its target. + if let SimOperationKind::Restore { target, kind: RestoreKind::Undo } = + op_to_restore.kind() + { + op_to_restore = self + .operation_get(*target) + .expect("target op undo target should exist"); + } + + let target = op_to_restore.id(); + let new_heads = op_to_restore.heads().clone(); + let new_current = op_to_restore.current(); + + // Create the undo operation. + let undo_op = SimOperation::new( + self.op_uuid_rng.next(), + new_heads, + new_current, + SimOperationKind::Restore { target, kind: RestoreKind::Undo }, + ); + self.operations.insert(undo_op.id(), undo_op); + + Ok(()) + } + + /// Redo a previously undone operation. + /// + /// Only works after an undo or redo operation, and only works if there is a + /// operation to undo available. + pub fn operation_redo(&mut self) -> Result<(), OperationError> { + let current_op = self.operation_current(); + + // Follow the redo stack algorithm described at + // https://github.com/jj-vcs/jj/blob/fd5accb/cli/src/commands/redo.rs#L47: + // + // - If the operation to redo is a regular one (neither an undo- or + // redo-operation): Fail, because there is nothing to redo. + // - If the operation to redo is an undo-operation, try to redo it (by restoring + // its parent operation). + // - If the operation to redo is a redo-operation itself, redo the operation the + // early redo-operation restored to. + // - If the operation to restore to is a redo-operation itself, restore directly + // to the original operation. This avoids creating a linked list of + // redo-operations, which subsequently may have to be walked with an + // inefficient loop. + + let (op_to_redo_index, op_to_redo) = match current_op.kind() { + SimOperationKind::Restore { target, kind: RestoreKind::Redo } => { + let (ix, _, op) = self + .operations + .get_full(target) + .expect("restored-to operation should exist"); + (ix, op) + } + _ => { + // The current operation is at self.operations.len() - 1 (there + // should always be at least one operation). + (self.operations.len() - 1, current_op) + } + }; + + // The operation to redo must be an undo operation. (This is the one + // difference between the otherwise symmetric undo and redo methods.) + if !op_to_redo.kind().is_undo() { + return Err(OperationError::NoRedo); + } + assert_ne!( + op_to_redo_index, 0, + "operation 0 is never an undo operation" + ); + + let (_, mut op_to_restore) = self + .operations + .get_index(op_to_redo_index - 1) + .expect("op should exist"); + + // If target_op is a redo operation, go to its target. + if let SimOperationKind::Restore { target, kind: RestoreKind::Redo } = + op_to_restore.kind() + { + op_to_restore = self + .operation_get(*target) + .expect("target op undo target should exist"); + } + + let target = op_to_restore.id(); + let new_heads = op_to_restore.heads().clone(); + let new_current = op_to_restore.current(); + + // Create the redo operation. + let redo_op = SimOperation::new( + self.op_uuid_rng.next(), + new_heads, + new_current, + SimOperationKind::Restore { target, kind: RestoreKind::Redo }, + ); + self.operations.insert(redo_op.id(), redo_op); + + Ok(()) + } + + /// Restore to a specific operation by ID. + /// + /// This creates a new Restore operation that sets the heads and current + /// pointers to older values. + pub fn operation_restore( + &mut self, + target: ReconfiguratorSimOpUuid, + ) -> Result<(), OperationError> { + let target_op = self + .operation_get(target) + .ok_or(OperationError::NotFound(target))?; + let new_heads = target_op.heads().clone(); + let new_current = target_op.current(); + + // Create the restore operation. + let restore_op = SimOperation::new( + self.op_uuid_rng.next(), + new_heads, + new_current, + SimOperationKind::Restore { target, kind: RestoreKind::Explicit }, + ); + self.operations.insert(restore_op.id(), restore_op); + + slog::debug!( + self.log, + "restored to operation"; + "to" => %target, + ); + + Ok(()) + } + + /// Wipe the operation log and all states, resetting to just the root. + /// + /// This completely clears the operation log and the graph of states, and + /// starts over from the root state. + pub fn operation_wipe(&mut self) { + let mut operations = IndexMap::new(); + operations.insert(Self::ROOT_OPERATION_ID, SimOperation::root()); + self.operations = operations; + self.states.clear(); + + slog::debug!(self.log, "wiped states and operation log"); + } } diff --git a/nexus/reconfigurator/simulation/src/state.rs b/nexus/reconfigurator/simulation/src/state.rs index 1b3fae36ce1..4ad39398209 100644 --- a/nexus/reconfigurator/simulation/src/state.rs +++ b/nexus/reconfigurator/simulation/src/state.rs @@ -179,7 +179,7 @@ impl SimState { /// /// `SimStateBuilder` is ephemeral, so it can be freely mutated without /// affecting anything else about the system. To store it into a system, call -/// [`Self::commit`]. +/// [`Self::commit_and_bump`]. #[derive(Clone, Debug)] pub struct SimStateBuilder { // Used to check that the simulator is the same as the one that created @@ -258,20 +258,15 @@ impl SimStateBuilder { }) } - /// Commit the current state to the simulator, returning the new state's - /// UUID. + /// Commit the current state to the simulator, and update the current + /// pointer. /// /// # Panics /// /// Panics if `sim` is not the same simulator that created this state. /// This should ordinarily never happen and always indicates a /// programming error. - #[must_use = "callers should update their pointers with the returned UUID"] - pub fn commit( - self, - description: String, - sim: &mut Simulator, - ) -> ReconfiguratorSimStateUuid { + pub fn commit_and_bump(self, description: String, sim: &mut Simulator) { // Check for unrelated histories. if !std::ptr::eq(sim.root_state(), self.root_state.inner()) { panic!( @@ -301,7 +296,6 @@ impl SimStateBuilder { log, }; sim.add_state(Arc::new(state)); - id } // TODO: should probably enforce that RNG is set, maybe by hiding the diff --git a/uuid-kinds/src/lib.rs b/uuid-kinds/src/lib.rs index 295fbfc6805..d1feef0ae5e 100644 --- a/uuid-kinds/src/lib.rs +++ b/uuid-kinds/src/lib.rs @@ -70,6 +70,7 @@ impl_typed_uuid_kinds! { Rack = {}, RackInit = {}, RackReset = {}, + ReconfiguratorSimOp = {}, ReconfiguratorSimState = {}, Region = {}, SiloGroup = {},