From 7c93ac797be76ad35a27828d403c7ad70116f241 Mon Sep 17 00:00:00 2001 From: Dan Rosen Date: Wed, 30 Sep 2026 14:03:15 -0400 Subject: [PATCH] [fm-sim] add the simulator skeleton: a clock, a time noun, and a REPL nexus-fm-simulation holds the simulated world (so far a clock) and typed operations on it, with no command-line types. fm-sim holds the clap vocabulary, a parser for one line, and a simulator that runs lines against a single current state; a history of states comes next. The fm-sim binary reads a script or runs interactively and is tested against golden output. --- Cargo.lock | 32 +++++ Cargo.toml | 5 + dev-tools/fm-sim/Cargo.toml | 38 ++++++ dev-tools/fm-sim/src/command/mod.rs | 58 +++++++++ dev-tools/fm-sim/src/command/time.rs | 97 ++++++++++++++ dev-tools/fm-sim/src/error.rs | 19 +++ dev-tools/fm-sim/src/lib.rs | 69 ++++++++++ dev-tools/fm-sim/src/main.rs | 43 +++++++ dev-tools/fm-sim/src/simulator.rs | 119 ++++++++++++++++++ dev-tools/fm-sim/src/test_utils.rs | 22 ++++ dev-tools/fm-sim/tests/input/cmds-time.txt | 22 ++++ .../fm-sim/tests/output/cmds-time-stderr | 14 +++ .../fm-sim/tests/output/cmds-time-stdout | 43 +++++++ dev-tools/fm-sim/tests/test-scripts.rs | 33 +++++ nexus/fm/simulation/Cargo.toml | 15 +++ nexus/fm/simulation/src/error.rs | 13 ++ nexus/fm/simulation/src/lib.rs | 16 +++ nexus/fm/simulation/src/state.rs | 83 ++++++++++++ nexus/fm/simulation/src/time.rs | 53 ++++++++ 19 files changed, 794 insertions(+) create mode 100644 dev-tools/fm-sim/Cargo.toml create mode 100644 dev-tools/fm-sim/src/command/mod.rs create mode 100644 dev-tools/fm-sim/src/command/time.rs create mode 100644 dev-tools/fm-sim/src/error.rs create mode 100644 dev-tools/fm-sim/src/lib.rs create mode 100644 dev-tools/fm-sim/src/main.rs create mode 100644 dev-tools/fm-sim/src/simulator.rs create mode 100644 dev-tools/fm-sim/src/test_utils.rs create mode 100644 dev-tools/fm-sim/tests/input/cmds-time.txt create mode 100644 dev-tools/fm-sim/tests/output/cmds-time-stderr create mode 100644 dev-tools/fm-sim/tests/output/cmds-time-stdout create mode 100644 dev-tools/fm-sim/tests/test-scripts.rs create mode 100644 nexus/fm/simulation/Cargo.toml create mode 100644 nexus/fm/simulation/src/error.rs create mode 100644 nexus/fm/simulation/src/lib.rs create mode 100644 nexus/fm/simulation/src/state.rs create mode 100644 nexus/fm/simulation/src/time.rs diff --git a/Cargo.lock b/Cargo.lock index 99892b2d379..8a5eadda012 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4131,6 +4131,28 @@ dependencies = [ "spin", ] +[[package]] +name = "fm-sim" +version = "0.1.0" +dependencies = [ + "anyhow", + "assert_matches", + "camino", + "camino-tempfile", + "chrono", + "clap", + "datatest-stable", + "expectorate", + "humantime", + "nexus-fm-simulation", + "omicron-repl-utils", + "omicron-test-utils", + "omicron-workspace-hack", + "shell-words", + "subprocess", + "thiserror 2.0.18", +] + [[package]] name = "fmd-adm" version = "0.3.0" @@ -7807,6 +7829,16 @@ dependencies = [ "uuid", ] +[[package]] +name = "nexus-fm-simulation" +version = "0.1.0" +dependencies = [ + "assert_matches", + "chrono", + "omicron-workspace-hack", + "thiserror 2.0.18", +] + [[package]] name = "nexus-fm-slippy" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 4c99ccbbbf8..a4649312e03 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -41,6 +41,7 @@ members = [ "dev-tools/db-dev", "dev-tools/downloader", "dev-tools/dropshot-apis", + "dev-tools/fm-sim", "dev-tools/ls-apis", "dev-tools/mgs-dev", "dev-tools/omdb", @@ -107,6 +108,7 @@ members = [ "nexus/external-api", "nexus/fm", "nexus/fm/preparation", + "nexus/fm/simulation", "nexus/fm/slippy", "nexus/internal-api", "nexus/inventory", @@ -240,6 +242,7 @@ default-members = [ "dev-tools/db-dev", "dev-tools/downloader", "dev-tools/dropshot-apis", + "dev-tools/fm-sim", "dev-tools/ls-apis", "dev-tools/mgs-dev", "dev-tools/omdb", @@ -308,6 +311,7 @@ default-members = [ "nexus/external-api", "nexus/fm", "nexus/fm/preparation", + "nexus/fm/simulation", "nexus/fm/slippy", "nexus/internal-api", "nexus/inventory", @@ -663,6 +667,7 @@ nexus-db-schema = { path = "nexus/db-schema" } nexus-defaults = { path = "nexus/defaults" } nexus-external-api = { path = "nexus/external-api" } nexus-fm = { path = "nexus/fm" } +nexus-fm-simulation = { path = "nexus/fm/simulation" } nexus-fm-slippy = { path = "nexus/fm/slippy" } nexus-fm-preparation = { path = "nexus/fm/preparation" } nexus-inventory = { path = "nexus/inventory" } diff --git a/dev-tools/fm-sim/Cargo.toml b/dev-tools/fm-sim/Cargo.toml new file mode 100644 index 00000000000..55e919c7340 --- /dev/null +++ b/dev-tools/fm-sim/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "fm-sim" +version = "0.1.0" +edition.workspace = true +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +camino.workspace = true +chrono.workspace = true +clap.workspace = true +humantime.workspace = true +nexus-fm-simulation.workspace = true +omicron-repl-utils.workspace = true +omicron-workspace-hack.workspace = true +shell-words.workspace = true +thiserror.workspace = true + +[dev-dependencies] +assert_matches.workspace = true +camino-tempfile.workspace = true +datatest-stable.workspace = true +expectorate.workspace = true +omicron-test-utils.workspace = true +subprocess.workspace = true + +# Disable doc builds by default for our binaries to work around issue +# rust-lang/cargo#8373. These docs would not be very useful anyway. +[[bin]] +name = "fm-sim" +doc = false + +[[test]] +name = "test-scripts" +harness = false diff --git a/dev-tools/fm-sim/src/command/mod.rs b/dev-tools/fm-sim/src/command/mod.rs new file mode 100644 index 00000000000..4a51502a8a2 --- /dev/null +++ b/dev-tools/fm-sim/src/command/mod.rs @@ -0,0 +1,58 @@ +// 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/. + +//! The simulator's command vocabulary. +//! +//! Commands are `clap` subcommands, organized noun-first with the verbs +//! under each noun. Each noun's verbs are split into two enums: queries +//! implementing the [`Query`] trait, and mutations implementing the [`Mutate`] +//! trait. [`Verb`] glues the queries and mutations together for clap, and the +//! [`Simulator`](crate::Simulator) matches on it to run the half it got. + +// TODO(#10209): So far there's just one noun, `time`. Subsequent PRs will add +// the rest of the vocabulary described in RFD 726. + +use std::fmt; + +use clap::Subcommand; +use nexus_fm_simulation::{SimState, SimStateBuilder}; + +use crate::Error; + +mod time; + +pub(crate) use time::{TimeMutate, TimeQuery}; + +/// A command that reads some aspect of simulator state, as a string. +pub(crate) trait Query: fmt::Debug { + fn run(&self, state: &SimState) -> Result; +} + +/// A command that mutates simulator state using a given `SimStateBuilder`, and +/// produces some output string. +pub(crate) trait Mutate: fmt::Debug { + fn apply(&self, builder: &mut SimStateBuilder) -> Result; +} + +/// A verb subcommand is either a query or a mutation. +/// +/// We do a bit of clap-fu here, taking the query `Q` and mutation `M` as type +/// parameters and flattening them both into a single subcommand so clap parses +/// them uniformly. +#[derive(Debug, PartialEq, Subcommand)] +pub(crate) enum Verb { + #[command(flatten)] + Query(Q), + #[command(flatten)] + Mutate(M), +} + +/// The full simulation command vocabulary. +#[derive(Debug, PartialEq, Subcommand)] +pub(crate) enum SimCommand { + /// The simulated clock. + #[command(subcommand)] + Time(Verb), + // TODO(#10209): add the rest of the vocabulary. +} diff --git a/dev-tools/fm-sim/src/command/time.rs b/dev-tools/fm-sim/src/command/time.rs new file mode 100644 index 00000000000..c0faaf04165 --- /dev/null +++ b/dev-tools/fm-sim/src/command/time.rs @@ -0,0 +1,97 @@ +// 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/. + +//! The `time` noun's clap commands, and how their results print. See +//! [`nexus_fm_simulation::time`] for the core implementation. + +use std::time::Duration; + +use chrono::{DateTime, SecondsFormat, Utc}; +use clap::Subcommand; +use nexus_fm_simulation::{SimState, SimStateBuilder, time}; + +use super::{Mutate, Query}; +use crate::Error; + +/// `time` verbs that read state. +#[derive(Debug, PartialEq, Subcommand)] +pub(crate) enum TimeQuery { + /// Show the simulated clock. + Show, +} + +/// `time` verbs that change state. +#[derive(Debug, PartialEq, Subcommand)] +pub(crate) enum TimeMutate { + /// Move the simulated clock forward. + Advance { + /// How far, for example `30s`, `5m`, or `1h30m`. + duration: humantime::Duration, + }, +} + +impl Query for TimeQuery { + fn run(&self, state: &SimState) -> Result { + match self { + TimeQuery::Show => Ok(render_show(state.now())), + } + } +} + +impl Mutate for TimeMutate { + fn apply(&self, builder: &mut SimStateBuilder) -> Result { + match self { + TimeMutate::Advance { duration } => { + let now = time::advance(builder, Duration::from(*duration))?; + Ok(render_advance(*duration, now)) + } + } + } +} + +fn render_show(now: DateTime) -> String { + now.to_rfc3339_opts(SecondsFormat::AutoSi, true) +} + +fn render_advance(duration: humantime::Duration, now: DateTime) -> String { + format!("advanced time by {duration} to {}", render_show(now)) +} + +#[cfg(test)] +mod tests { + use std::time::Duration; + + use super::*; + use crate::CliCommand; + use crate::command::{SimCommand, Verb}; + use crate::test_utils::{parse_ok, rejects}; + + #[test] + fn time_commands_parse() { + let show = + CliCommand::Sim(SimCommand::Time(Verb::Query(TimeQuery::Show))); + let advance = |secs: u64| { + let duration = humantime::Duration::from(Duration::from_secs(secs)); + CliCommand::Sim(SimCommand::Time(Verb::Mutate( + TimeMutate::Advance { duration }, + ))) + }; + + assert_eq!(parse_ok("time show"), show); + assert_eq!(parse_ok("time advance 30s"), advance(30)); + assert_eq!(parse_ok("time advance 5m"), advance(300)); + assert_eq!(parse_ok("time advance '1h 30m'"), advance(5400)); + + rejects(&[ + "time", + "time show extra", + "time advance", + "time advance soon", + "time advance 5", + "time advance 1h 30m", + "time advance -5m", + "time rewind 5m", + ]); + } +} diff --git a/dev-tools/fm-sim/src/error.rs b/dev-tools/fm-sim/src/error.rs new file mode 100644 index 00000000000..5a1b429114b --- /dev/null +++ b/dev-tools/fm-sim/src/error.rs @@ -0,0 +1,19 @@ +// 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/. + +/// The ways in which a command can fail, one variant per layer it can fail in. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// The input line could not be split into words (e.g., an unclosed quote). + #[error(transparent)] + Split(#[from] shell_words::ParseError), + /// The input line was split into words but did not parse as a command. This + /// includes `--help`, which clap reports as an error. + #[error(transparent)] + Parse(#[from] clap::Error), + /// The input line parsed as a command successfully but failed when run + /// against the simulated environment. + #[error(transparent)] + Sim(#[from] nexus_fm_simulation::Error), +} diff --git a/dev-tools/fm-sim/src/lib.rs b/dev-tools/fm-sim/src/lib.rs new file mode 100644 index 00000000000..37a76c591c4 --- /dev/null +++ b/dev-tools/fm-sim/src/lib.rs @@ -0,0 +1,69 @@ +// 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/. + +//! Fault Management simulator command line library. +//! +//! This crate builds on top of [`nexus_fm_simulation`] to provide a command +//! line interface for running fault management in a simulated environment, +//! including session history. It's designed to be used both as a library in +//! scripted integration tests and interactively via the `fm-sim` binary. + +mod command; +mod error; +mod simulator; +#[cfg(test)] +pub(crate) mod test_utils; + +pub use error::Error; +pub use simulator::Simulator; + +use clap::Parser; + +use command::SimCommand; + +/// fm-sim: Fault Management Simulator +#[derive(Debug, PartialEq, Parser)] +#[command(multicall = true)] +pub(crate) enum CliCommand { + #[command(flatten)] + Sim(SimCommand), + // TODO(#10209): Add support for session commands: `state`, `op`, `script`. +} + +/// Parses one line of input into a command. +/// +/// The line is split with shell-style quoting, so for example +/// `time advance '1h 30m'` is three tokens input to clap. +pub(crate) fn parse(line: &str) -> Result { + let words = shell_words::split(line)?; + Ok(CliCommand::try_parse_from(words)?) +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + + use super::*; + + #[test] + fn empty_line_is_parse_error() { + assert_matches!(parse(""), Err(Error::Parse(_))); + } + + #[test] + fn unclosed_quote_is_split_error() { + assert_matches!(parse("time advance '1h"), Err(Error::Split(_))); + } + + #[test] + fn about() { + use clap::CommandFactory; + use clap::builder::StyledStr; + + assert_eq!( + CliCommand::command().get_about(), + Some(&StyledStr::from("fm-sim: Fault Management Simulator")) + ); + } +} diff --git a/dev-tools/fm-sim/src/main.rs b/dev-tools/fm-sim/src/main.rs new file mode 100644 index 00000000000..709ebb9f808 --- /dev/null +++ b/dev-tools/fm-sim/src/main.rs @@ -0,0 +1,43 @@ +// 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/. + +//! Fault Management simulator command line binary. +//! +//! This is a thin shell around [`Simulator`]. It reads lines (from a script or +//! interactively), hands each one to the simulator, and prints the results. + +use camino::Utf8PathBuf; +use clap::Parser; +use fm_sim::{Error, Simulator}; +use omicron_repl_utils::{run_repl_from_file, run_repl_on_stdin}; + +/// fm-sim: Fault Management Simulator +#[derive(Debug, Parser)] +struct FmSim { + /// Path to a script of commands to run. If absent, commands are read + /// interactively from standard input. + script: Option, +} + +fn main() -> anyhow::Result<()> { + let args = FmSim::parse(); + let mut sim = Simulator::new(); + + let run_line = |line: &str| { + sim.run(line).or_else(|error| match error { + // For clap parse errors specifically, use clap's own `Error::print` + // method to show them, and then return `Ok` to continue the REPL. + Error::Parse(error) => { + error.print()?; + Ok(String::new()) + } + // For all other errors, return them to `repl-utils`. + error => Err(error.into()), + }) + }; + match &args.script { + Some(script) => run_repl_from_file(script, run_line), + None => run_repl_on_stdin(run_line), + } +} diff --git a/dev-tools/fm-sim/src/simulator.rs b/dev-tools/fm-sim/src/simulator.rs new file mode 100644 index 00000000000..66b9c66a04b --- /dev/null +++ b/dev-tools/fm-sim/src/simulator.rs @@ -0,0 +1,119 @@ +// 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 clap::Subcommand; +use nexus_fm_simulation::{SimState, SimStateBuilder}; + +use crate::command::{Mutate, Query, SimCommand, Verb}; +use crate::{CliCommand, Error, parse}; + +/// Holds the current state of the simulated world, and parses and executes +/// commands (queries and mutations) over that state. +#[derive(Debug)] +pub struct Simulator { + state: SimState, + // TODO(#10209): Add session history and op log, so we're not just storing + // the current state. +} + +impl Simulator { + /// Constructs a simulator starting from the initial empty state. + pub fn new() -> Self { + Self { state: SimState::new() } + } + + /// Returns the current state. + pub fn current(&self) -> &SimState { + &self.state + } + + /// Parses `line` as a command and executes it. + pub fn run(&mut self, line: &str) -> Result { + match parse(line)? { + CliCommand::Sim(SimCommand::Time(verb)) => self.verb(verb), + } + } + + /// Runs one verb command against the current state. + /// + /// A query leaves the state as it was. A successful mutation replaces the + /// current state with the new successor state the mutation produced. + fn verb(&mut self, verb: Verb) -> Result + where + Q: Subcommand + Query, + M: Subcommand + Mutate, + { + match verb { + // TODO(#10209): When we implement history, record query commands + // on the current state node (the state the query ran against). + Verb::Query(query) => query.run(&self.state), + // TODO(#10209): When we implement history, record each mutation + // command on the result state node that the mutation produced. + Verb::Mutate(mutate) => { + let mut builder = SimStateBuilder::from_state(&self.state); + let output = mutate.apply(&mut builder)?; + self.state = builder.build(); + Ok(output) + } + } + } +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + use nexus_fm_simulation::Error as SimError; + + use super::*; + + /// We use simulated time (how many minutes past the epoch) as a sort of + /// proxy in the tests below for observing state changes, although nothing + /// in the tests is really about simulated time. The `time` noun just + /// happens to be minimal and convenient, with one query and one mutation + /// that can fail. + fn minutes(sim: &Simulator) -> i64 { + (sim.current().now() - SimState::EPOCH).num_minutes() + } + + #[test] + fn query_returns_command_output() { + let output = Simulator::new().run("time show").unwrap(); + assert_eq!(output, "1970-01-01T00:00:00Z"); + } + + #[test] + fn mutate_returns_command_output() { + let output = Simulator::new().run("time advance 1m").unwrap(); + assert_eq!(output, "advanced time by 1m to 1970-01-01T00:01:00Z"); + } + + #[test] + fn mutate_replaces_current_state() { + let mut sim = Simulator::new(); + assert_eq!(minutes(&sim), 0); + + sim.run("time advance 1m").unwrap(); + assert_eq!(minutes(&sim), 1); + } + + #[test] + fn mutate_error_preserves_state() { + let mut sim = Simulator::new(); + let error = sim + .run("time advance 1000000years") + .expect_err("a million years is past the end of the clock"); + assert_matches!(error, Error::Sim(SimError::TimeOutOfRange)); + assert_eq!(minutes(&sim), 0); + } + + #[test] + fn parse_errors_arent_panics() { + let mut sim = Simulator::new(); + assert_matches!(sim.run(""), Err(Error::Parse(_))); + assert_matches!(sim.run(" "), Err(Error::Parse(_))); + assert_matches!(sim.run("time advance soon"), Err(Error::Parse(_))); + assert_matches!(sim.run("time advance '1h"), Err(Error::Split(_))); + assert_eq!(minutes(&sim), 0); + } +} diff --git a/dev-tools/fm-sim/src/test_utils.rs b/dev-tools/fm-sim/src/test_utils.rs new file mode 100644 index 00000000000..d722cf5a468 --- /dev/null +++ b/dev-tools/fm-sim/src/test_utils.rs @@ -0,0 +1,22 @@ +// 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/. + +//! Helpers shared by the unit tests: feed a line to the parser and expect a +//! result. + +use crate::{CliCommand, parse}; + +/// Parses `line`, panicking with the error if it does not parse. +pub(crate) fn parse_ok(line: &str) -> CliCommand { + parse(line).unwrap_or_else(|e| panic!("`{line}` should parse: {e}")) +} + +/// Asserts that none of `lines` parse. +pub(crate) fn rejects(lines: &[&str]) { + for line in lines { + if let Ok(command) = parse(line) { + panic!("`{line}` should not parse, got {command:?}"); + } + } +} diff --git a/dev-tools/fm-sim/tests/input/cmds-time.txt b/dev-tools/fm-sim/tests/input/cmds-time.txt new file mode 100644 index 00000000000..db71a067318 --- /dev/null +++ b/dev-tools/fm-sim/tests/input/cmds-time.txt @@ -0,0 +1,22 @@ +# The simulated clock starts at the epoch, and moves only when told to. +time show +time advance 90s +time show + +# A duration with a space in it has to be quoted. +time advance '1h 30m' +time advance 1h 30m + +# Fractions of a second show up only when there are some. +time advance 250ms +time advance 750ms + +# Durations that don't parse, and a quote that never closes. +time advance +time advance soon +time advance '1h + +# A duration that parses, but that the clock can't reach. The clock stays +# where it was. +time advance 1000000years +time show diff --git a/dev-tools/fm-sim/tests/output/cmds-time-stderr b/dev-tools/fm-sim/tests/output/cmds-time-stderr new file mode 100644 index 00000000000..c066d453976 --- /dev/null +++ b/dev-tools/fm-sim/tests/output/cmds-time-stderr @@ -0,0 +1,14 @@ +error: unexpected argument '30m' found + +Usage: time advance + +For more information, try '--help'. +error: the following required arguments were not provided: + + +Usage: time advance + +For more information, try '--help'. +error: invalid value 'soon' for '': expected number at 0 + +For more information, try '--help'. diff --git a/dev-tools/fm-sim/tests/output/cmds-time-stdout b/dev-tools/fm-sim/tests/output/cmds-time-stdout new file mode 100644 index 00000000000..1a851ea4f2f --- /dev/null +++ b/dev-tools/fm-sim/tests/output/cmds-time-stdout @@ -0,0 +1,43 @@ +> # The simulated clock starts at the epoch, and moves only when told to. +> time show +1970-01-01T00:00:00Z + +> time advance 90s +advanced time by 1m 30s to 1970-01-01T00:01:30Z + +> time show +1970-01-01T00:01:30Z + + +> # A duration with a space in it has to be quoted. +> time advance '1h 30m' +advanced time by 1h 30m to 1970-01-01T01:31:30Z + +> time advance 1h 30m + + +> # Fractions of a second show up only when there are some. +> time advance 250ms +advanced time by 250ms to 1970-01-01T01:31:30.250Z + +> time advance 750ms +advanced time by 750ms to 1970-01-01T01:31:31Z + + +> # Durations that don't parse, and a quote that never closes. +> time advance + +> time advance soon + +> time advance '1h +error: missing closing quote + + +> # A duration that parses, but that the clock can't reach. The clock stays +> # where it was. +> time advance 1000000years +error: the simulated clock cannot go that far + +> time show +1970-01-01T01:31:31Z + diff --git a/dev-tools/fm-sim/tests/test-scripts.rs b/dev-tools/fm-sim/tests/test-scripts.rs new file mode 100644 index 00000000000..1552dca672f --- /dev/null +++ b/dev-tools/fm-sim/tests/test-scripts.rs @@ -0,0 +1,33 @@ +// 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/. + +//! Script-based tests for `fm-sim`. + +use camino::Utf8Path; +use expectorate::assert_contents; +use omicron_test_utils::dev::test_cmds::{ + EXIT_SUCCESS, assert_exit_code, path_to_executable, run_command, +}; +use subprocess::Exec; + +fn script(path: &Utf8Path) -> datatest_stable::Result<()> { + let fm_sim = path_to_executable(env!("CARGO_BIN_EXE_fm-sim")); + let script = path.canonicalize_utf8()?; + let tmpdir = camino_tempfile::tempdir()?; + let (exit_status, stdout_text, stderr_text) = + run_command(Exec::cmd(fm_sim).arg(script).cwd(tmpdir.path())); + assert_exit_code(exit_status, EXIT_SUCCESS, &stderr_text); + + let test_name = path.file_stem().unwrap(); + assert_contents(format!("tests/output/{test_name}-stdout"), &stdout_text); + assert_contents(format!("tests/output/{test_name}-stderr"), &stderr_text); + + Ok(()) +} + +// Run every script in `tests/input` through the `fm-sim` binary and compare +// its output against the corresponding expected results in `tests/output`. +datatest_stable::harness! { + { test = script, root = "tests/input", pattern = r"cmds.*\.txt" } +} diff --git a/nexus/fm/simulation/Cargo.toml b/nexus/fm/simulation/Cargo.toml new file mode 100644 index 00000000000..952ec7c8ab1 --- /dev/null +++ b/nexus/fm/simulation/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "nexus-fm-simulation" +version = "0.1.0" +edition.workspace = true + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +omicron-workspace-hack.workspace = true +thiserror.workspace = true + +[dev-dependencies] +assert_matches.workspace = true diff --git a/nexus/fm/simulation/src/error.rs b/nexus/fm/simulation/src/error.rs new file mode 100644 index 00000000000..e58600237a0 --- /dev/null +++ b/nexus/fm/simulation/src/error.rs @@ -0,0 +1,13 @@ +// 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/. + +/// Why an operation failed. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// [`time::advance`](crate::time::advance) would put the clock past what + /// chrono can represent, either because the duration itself is too long to + /// convert or because the sum overflows. + #[error("the simulated clock cannot go that far")] + TimeOutOfRange, +} diff --git a/nexus/fm/simulation/src/lib.rs b/nexus/fm/simulation/src/lib.rs new file mode 100644 index 00000000000..170441cc919 --- /dev/null +++ b/nexus/fm/simulation/src/lib.rs @@ -0,0 +1,16 @@ +// 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/. + +//! Fault Management simulator core library. +//! +//! This crate provides the core data types and operations for running fault +//! management in a simulated environment. It's designed to be used both in unit +//! tests and as the basis for the simulator CLI in `dev-tools/fm-sim`. + +mod error; +mod state; +pub mod time; + +pub use error::Error; +pub use state::{SimState, SimStateBuilder}; diff --git a/nexus/fm/simulation/src/state.rs b/nexus/fm/simulation/src/state.rs new file mode 100644 index 00000000000..c1c8a6cc112 --- /dev/null +++ b/nexus/fm/simulation/src/state.rs @@ -0,0 +1,83 @@ +// 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/. + +//! The state of the simulated system. [`SimState`] is an immutable snapshot; +//! states are never edited in place, so users can hang onto them and use them +//! for before/after comparisons in tests. [`SimStateBuilder`] enables +//! functional-style mutations, producing new state snapshots from old ones. + +use std::ops::Deref; + +use chrono::{DateTime, Utc}; + +/// The whole simulated world, as an immutable value. +#[derive(Clone, Debug)] +pub struct SimState { + now: DateTime, + // TODO(#10209): Add more state here to support the full set of things the + // DEs and FM system care about; see https://726.rfd.oxide.computer. +} + +impl SimState { + /// The initial time for a new simulation. + pub const EPOCH: DateTime = DateTime::UNIX_EPOCH; + + /// Constructs an initial empty state. + pub fn new() -> Self { + Self { now: Self::EPOCH } + } + + /// Returns the current simulated time. + pub fn now(&self) -> DateTime { + self.now + } +} + +/// Provides functional-style mutation for [`SimState`], allowing callers to +/// safely make changes to a copy of some given base state without affecting +/// the original. +/// +/// This only provides the most basic primitive operations necessary to mutate +/// the simulated state; higher-level operations are defined in their respective +/// modules. +#[derive(Clone, Debug)] +pub struct SimStateBuilder { + state: SimState, +} + +impl SimStateBuilder { + /// Constructs a new builder initialized from [`SimState::new`]. + pub fn new() -> Self { + Self { state: SimState::new() } + } + + /// Constructs a new builder from a copy of `state`. + pub fn from_state(state: &SimState) -> Self { + Self { state: state.clone() } + } + + /// Returns the state. + pub fn build(self) -> SimState { + self.state + } + + /// Primitive setter for the simulated clock. For the "advance" operation, + /// see [`time::advance`](crate::time::advance). + pub fn set_now(&mut self, now: DateTime) { + self.state.now = now; + } +} + +// We implement `Deref` on the builder for convenient syntactic sugar, so users +// can transparently call accessor methods on the underlying `SimState`. Yes, +// this violates the Rust API guideline on `Deref` (see +// https://rust-lang.github.io/api-guidelines/predictability#c-deref), but we +// don't generally follow that guideline in Omicron. +impl Deref for SimStateBuilder { + type Target = SimState; + + fn deref(&self) -> &SimState { + &self.state + } +} diff --git a/nexus/fm/simulation/src/time.rs b/nexus/fm/simulation/src/time.rs new file mode 100644 index 00000000000..bd1787bd48d --- /dev/null +++ b/nexus/fm/simulation/src/time.rs @@ -0,0 +1,53 @@ +// 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/. + +//! The `time` noun: the simulated clock. + +use std::time::Duration; + +use chrono::{DateTime, TimeDelta, Utc}; + +use crate::{Error, SimStateBuilder}; + +/// Moves the simulated clock forward by `duration`, and returns the new time. +/// +/// If the result is later than the clock can represent, this returns +/// `Error::TimeOutOfRange` and leaves the clock unchanged. +pub fn advance( + builder: &mut SimStateBuilder, + duration: Duration, +) -> Result, Error> { + let delta = + TimeDelta::from_std(duration).map_err(|_| Error::TimeOutOfRange)?; + let now = + builder.now().checked_add_signed(delta).ok_or(Error::TimeOutOfRange)?; + builder.set_now(now); + Ok(now) +} + +#[cfg(test)] +mod tests { + use assert_matches::assert_matches; + + use super::*; + use crate::SimState; + + #[test] + fn advance_moves_the_clock_forward() { + let mut builder = SimStateBuilder::new(); + let now = advance(&mut builder, Duration::from_millis(123)) + .expect("123ms is within the clock's range"); + assert_eq!(now, SimState::EPOCH + TimeDelta::milliseconds(123)); + assert_eq!(builder.build().now(), now); + } + + #[test] + fn advance_rejects_a_bad_duration() { + let mut builder = SimStateBuilder::new(); + let error = advance(&mut builder, Duration::MAX) + .expect_err("Duration::MAX is longer than chrono can hold"); + assert_matches!(error, Error::TimeOutOfRange); + assert_eq!(builder.now(), SimState::EPOCH); + } +}