Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 5 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -107,6 +108,7 @@ members = [
"nexus/external-api",
"nexus/fm",
"nexus/fm/preparation",
"nexus/fm/simulation",
"nexus/fm/slippy",
"nexus/internal-api",
"nexus/inventory",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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" }
Expand Down
38 changes: 38 additions & 0 deletions dev-tools/fm-sim/Cargo.toml
Original file line number Diff line number Diff line change
@@ -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
58 changes: 58 additions & 0 deletions dev-tools/fm-sim/src/command/mod.rs
Original file line number Diff line number Diff line change
@@ -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<String, Error>;
}

/// 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<String, Error>;
}

/// 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<Q: Subcommand + Query, M: Subcommand + Mutate> {
#[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<TimeQuery, TimeMutate>),
// TODO(#10209): add the rest of the vocabulary.
}
97 changes: 97 additions & 0 deletions dev-tools/fm-sim/src/command/time.rs
Original file line number Diff line number Diff line change
@@ -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<String, Error> {
match self {
TimeQuery::Show => Ok(render_show(state.now())),
}
}
}

impl Mutate for TimeMutate {
fn apply(&self, builder: &mut SimStateBuilder) -> Result<String, Error> {
match self {
TimeMutate::Advance { duration } => {
let now = time::advance(builder, Duration::from(*duration))?;
Ok(render_advance(*duration, now))
}
}
}
}

fn render_show(now: DateTime<Utc>) -> String {
now.to_rfc3339_opts(SecondsFormat::AutoSi, true)
}

fn render_advance(duration: humantime::Duration, now: DateTime<Utc>) -> 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",
]);
}
}
19 changes: 19 additions & 0 deletions dev-tools/fm-sim/src/error.rs
Original file line number Diff line number Diff line change
@@ -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),
}
69 changes: 69 additions & 0 deletions dev-tools/fm-sim/src/lib.rs
Original file line number Diff line number Diff line change
@@ -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<CliCommand, Error> {
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"))
);
}
}
Loading
Loading