diff --git a/.github/buildomat/packet-test-common.sh b/.github/buildomat/packet-test-common.sh index 6df876f4..44855110 100755 --- a/.github/buildomat/packet-test-common.sh +++ b/.github/buildomat/packet-test-common.sh @@ -3,25 +3,27 @@ export RUST_BACKTRACE=1 source .github/buildomat/common.sh source .github/buildomat/linux.sh -wd=`pwd` +wd=$(pwd) export WS=$wd MODEL_STARTUP_TIMEOUT=${MODEL_STARTUP_TIMEOUT:=5} STARTUP_TIMEOUT=${STARTUP_TIMEOUT:=120} - -if [ x$MULTICAST == x ]; then - BUILD_FEATURES=tofino_asic - CODEGEN_FEATURES= - SWADM_FEATURES= - else - BUILD_FEATURES=tofino_asic,multicast - CODEGEN_FEATURES=--multicast - SWADM_FEATURES=--features=multicast +DENDRITE_TEST_HOST=${DENDRITE_TEST_HOST:="[::1]"} +DENDRITE_TEST_VERBOSITY=${DENDRITE_TEST_VERBOSITY:=3} + +if [ "$MULTICAST" == "" ]; then + BUILD_FEATURES=tofino_asic + CODEGEN_FEATURES=() + SWADM_FEATURES=() +else + BUILD_FEATURES=tofino_asic,multicast + CODEGEN_FEATURES=(--multicast) + SWADM_FEATURES=(--features=multicast) fi - + function cleanup { set +o errexit set +o pipefail - cd $wd + cd "$wd" sudo -E pkill -9 dpd sudo -E pkill -9 tofino-model sudo -E ./tools/veth_teardown.sh @@ -62,57 +64,39 @@ export SDE=/opt/oxide/tofino_sde banner "Build" if [[ $NOBUILD -ne 1 ]]; then cargo build --features=$BUILD_FEATURES --bin dpd --bin swadm - cargo xtask codegen --stages $TOFINO_STAGES $CODEGEN_FEATURES + cargo xtask codegen --stages "$TOFINO_STAGES" "${CODEGEN_FEATURES[@]}" fi banner "Test" sudo -E ./tools/veth_setup.sh -id=`id -un` -gr=`id -gn` +id=$(id -un) +gr=$(id -gn) sudo -E mkdir -p /work -sudo -E chown $id:$gr /work +sudo -E chown "$id":"$gr" /work sudo -E ./tools/run_tofino_model.sh &> /work/simulator.log & -sleep $MODEL_STARTUP_TIMEOUT +sleep "$MODEL_STARTUP_TIMEOUT" sudo -E ./tools/run_dpd.sh -m 127.0.0.1 &> /work/dpd.log & echo "waiting for dpd to come online" set +o errexit SLEEP_TIME=5 -iters=$(( $STARTUP_TIMEOUT / $SLEEP_TIME )) -while [ 1 ] ; do - ./target/debug/swadm --host '[::1]' build-info 2> /dev/null - if [ $? == 0 ]; then - break - fi - iters=$(($iters - 1)) - if [ $iters = 0 ]; then - echo "dpd failed to come online in $STARTUP_TIMEOUT seconds" - exit 1 - fi - sleep $SLEEP_TIME +iters=$(( STARTUP_TIMEOUT / SLEEP_TIME )) +while true ; do + if ./target/debug/swadm --host '[::1]' build-info 2> /dev/null; then + break + fi + iters=$((iters - 1)) + if [ $iters = 0 ]; then + echo "dpd failed to come online in $STARTUP_TIMEOUT seconds" + exit 1 + fi + sleep $SLEEP_TIME done -set -o errexit banner "Links" ./target/debug/swadm --host '[::1]' link ls || echo "failed to list links" -banner "swadm Checks" - -pushd swadm - -DENDRITE_TEST_HOST='[::1]' \ - DENDRITE_TEST_VERBOSITY=3 \ - cargo test \ - --no-fail-fast \ - $SWADM_FEATURES \ - --test \ - counters \ - -- \ - --ignored - -popd - banner "Packet Tests" set +o errexit @@ -121,14 +105,32 @@ stty sane set -o errexit set -o pipefail +export DENDRITE_TEST_HOST DENDRITE_TEST_VERBOSITY + pushd dpd-client -DENDRITE_TEST_HOST='[::1]' \ - DENDRITE_TEST_VERBOSITY=3 \ - cargo test \ +cargo test \ --features $BUILD_FEATURES \ --no-fail-fast \ $TESTNAME \ -- \ --ignored \ --skip succeeds_when_table_fragmented + +popd + +banner "swadm checks" + +pushd swadm + +cargo test \ + --no-fail-fast \ + "${SWADM_FEATURES[@]}" \ + -- \ + --ignored + +cargo test \ + --no-fail-fast \ + "${SWADM_FEATURES[@]}" + +popd diff --git a/Cargo.lock b/Cargo.lock index e4f7141f..68b9d48c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -946,7 +946,7 @@ version = "3.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "faf9468729b8cbcea668e36183cb69d317348c2e08e994829fb56ebfdfbaac34" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.0", ] [[package]] @@ -1011,15 +1011,14 @@ checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" [[package]] name = "console" -version = "0.15.11" +version = "0.16.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "054ccb5b10f9f2cbf51eb355ca1d05c2d279ce1804688d0db74b4733a5aeafd8" +checksum = "4fe5f465a4f6fee88fad41b85d990f84c835335e85b5d9e6e63e0d06d28cba7c" dependencies = [ "encode_unicode", "libc", - "once_cell", "unicode-width 0.2.1", - "windows-sys 0.59.0", + "windows-sys 0.61.0", ] [[package]] @@ -1545,6 +1544,12 @@ version = "0.1.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "56254986775e3233ffa9c4d7d3faaf6d36a2c09d30b20687e9f88bc8bafc16c8" +[[package]] +name = "difflib" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" + [[package]] name = "digest" version = "0.10.7" @@ -1943,7 +1948,7 @@ dependencies = [ "semver 1.0.28", "serde_json", "sha2", - "similar", + "similar 2.7.0", "supports-color", "textwrap", "thiserror 2.0.18", @@ -2136,19 +2141,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] name = "expectorate" -version = "1.2.0" +version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cfe29c067b3dd398703f5cb05420a21c21079edfbcfa96c3ff2d9bde55cc8b3" +checksum = "b772da95a569ac0198446076713b5654aac569583123a7eb6e02d71d9588613b" dependencies = [ "atomicwrites", "console", "newline-converter", - "similar", + "predicates", + "similar 3.2.0", ] [[package]] @@ -2213,6 +2219,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "float-ord" version = "0.3.2" @@ -3067,7 +3082,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.4", "system-configuration", "tokio", "tower-layer", @@ -3497,7 +3512,7 @@ checksum = "e04d7f318608d35d4b61ddd75cbdaee86b023ebe2bd5a66ee0915f0bf93095a9" dependencies = [ "hermit-abi", "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3565,7 +3580,7 @@ dependencies = [ "portable-atomic", "portable-atomic-util", "serde", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -4272,6 +4287,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "normalize-line-endings" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61807f77802ff30975e01f4f071c8ba10c022052f98b3294119f3e615d13e5be" + [[package]] name = "num" version = "0.4.3" @@ -5297,12 +5318,16 @@ dependencies = [ [[package]] name = "predicates" -version = "3.1.3" +version = "3.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5d19ee57562043d37e82899fade9a22ebab7be9cef5026b07fda9cdd4293573" +checksum = "ada8f2932f28a27ee7b70dd6c1c39ea0675c55a36879ab92f3a715eaa1e63cfe" dependencies = [ "anstyle", + "difflib", + "float-cmp", + "normalize-line-endings", "predicates-core", + "regex", ] [[package]] @@ -5718,7 +5743,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27c6023962132f4b30eb4c172c91ce92d933da334c59c23cddee82358ddafb0b" dependencies = [ "anyhow", - "itertools 0.12.1", + "itertools 0.14.0", "proc-macro2", "quote", "syn 2.0.118", @@ -5784,7 +5809,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls 0.23.40", - "socket2 0.5.10", + "socket2 0.6.4", "thiserror 2.0.18", "tokio", "tracing", @@ -5821,9 +5846,9 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.4", "tracing", - "windows-sys 0.52.0", + "windows-sys 0.60.2", ] [[package]] @@ -6189,7 +6214,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.4.15", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -6202,7 +6227,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -6706,6 +6731,31 @@ dependencies = [ "unsafe-libyaml", ] +[[package]] +name = "serial_test" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "699f4197115b8a7e7ff19c9a315a4bd6fffec26cc4626ef45ecaea389e081c6d" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94e153fc76e1c6a068703d6d29c508a0b15c061c4b7e43da59cc097bc342673c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.118", +] + [[package]] name = "sha1" version = "0.10.6" @@ -6819,6 +6869,15 @@ dependencies = [ "bstr", ] +[[package]] +name = "similar" +version = "3.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f66ca1f7aca2474dc10c942eb22feffc897735f54cd1db90138c2fddb490987" +dependencies = [ + "bstr", +] + [[package]] name = "siphasher" version = "1.0.1" @@ -7070,7 +7129,7 @@ version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c1c97747dbf44bb1ca44a561ece23508e99cb592e862f22222dcf42f51d1e451" dependencies = [ - "heck 0.4.1", + "heck 0.5.0", "proc-macro2", "quote", "syn 2.0.118", @@ -7276,14 +7335,18 @@ dependencies = [ "colored", "common 0.1.0", "dpd-client 0.1.0", + "expectorate", "futures", "humantime", "oxide-tokio-rt", "oxnet", + "predicates", "regex", "reqwest 0.13.2", + "serial_test", "slog", "tabwriter", + "thiserror 2.0.18", "tokio", ] @@ -7438,7 +7501,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.52.0", + "windows-sys 0.61.0", ] [[package]] @@ -8986,7 +9049,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 9691a0d1..a8f11cfc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -74,7 +74,7 @@ curl = "0.4" dropshot = "0.17.1" dropshot-api-manager = "0.7.2" dropshot-api-manager-types = "0.7.2" -expectorate = "1" +expectorate = { version = "1.3.0", features = ["predicates"] } futures = "0.3" http = "1.4.2" humantime = "2.3" @@ -87,8 +87,8 @@ omicron-zone-package = "0.12" openssl = "0.10" oxide-tokio-rt = "0.1.2" parking_lot = "0.12" +predicates = "3.1.4" pretty_assertions = "1.4" -proc-macro2 = "1.0" progenitor = "0.14" rand = "0.9" regex = "1.12" @@ -100,6 +100,7 @@ scuffle = { version = "0.1.0", features = ["smf-by-instance"] } semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +serial_test = "3.5.0" signal-hook = "0.4" signal-hook-tokio = { version = "0.4", features = [ "futures-v0_3" ] } slog = { version = "2.7", features = [ "release_max_level_debug", "max_level_trace" ] } @@ -108,7 +109,6 @@ slog-bunyan = "2.5" slog-term = "2.9" socket2 = { version = "0.6", features = ["all"] } strum = { version = "0.28", features = [ "derive" ] } -syn = { version = "2.0", features = ["extra-traits"]} tabwriter = { version = "1", features = ["ansi_formatting"] } thiserror = "2.0" tokio = "1.37" diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 2c0bc18b..57ceccdf 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -24,3 +24,9 @@ reqwest.workspace = true slog.workspace = true tabwriter.workspace = true tokio.workspace = true + +[dev-dependencies] +expectorate.workspace = true +predicates.workspace = true +serial_test.workspace = true +thiserror.workspace = true diff --git a/swadm/README.md b/swadm/README.md new file mode 100644 index 00000000..df0ce963 --- /dev/null +++ b/swadm/README.md @@ -0,0 +1,17 @@ +# SW(itch) ADM(in) + +This is the management CLI for Oxide's rack switch. + +## Testing + +swadm is widely used across scripts and documentation. Changes +should generally be backward compatible. + +This is definitionally a string-typed interface, so regressions are +easily missed. The integration tests module has infra for testing +commands, and adding a test before making swadm changes might +help prevent drift. + +These tests are run in Linux CI but currently ignored in Illumos CI. +Illumos CI is blocked by tofino simulator support: https://github.com/oxidecomputer/tofino-sde/issues/21 + diff --git a/swadm/src/link.rs b/swadm/src/link.rs index c6be13c0..914f4ac5 100644 --- a/swadm/src/link.rs +++ b/swadm/src/link.rs @@ -436,7 +436,7 @@ pub enum Link { /// Whether the link is configured to autonegotiate with its peer during /// link training. /// - /// This is generally only true for backplane links, and defaults to + /// This is generally only true for backplane links and defaults to false. #[clap(long)] autoneg: bool, /// Whether the link is configured in KR mode, an electrical specification diff --git a/swadm/tests/cli/cmd.rs b/swadm/tests/cli/cmd.rs new file mode 100644 index 00000000..20d60e80 --- /dev/null +++ b/swadm/tests/cli/cmd.rs @@ -0,0 +1,306 @@ +// 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/ +// +// Copyright 2026 Oxide Computer Company + +//! # Cmd +//! +//! This module defines helpers for executing swadm CLI +//! commands and validating their output. +//! +//! They assume a test environment in which swadm can +//! reach dpd and control it without interference. + +use std::borrow::Cow; +use std::ffi::OsStr; +use std::path::PathBuf; +use std::process::Command; +use std::time::Duration; +use std::time::Instant; + +use anyhow::Context; +use anyhow::bail; +use predicates::Predicate; +use regex::Regex; + +const SWADM: &str = env!("CARGO_BIN_EXE_swadm"); +const SWADM_DIR: &str = env!("CARGO_MANIFEST_DIR"); + +/// Creates a [`Pattern`] that matches any of the given patterns. +/// +/// Inputs must implement `Into`. +#[macro_export] +macro_rules! among { + [$($p:expr),*] => { + $crate::cmd::Pattern::or([$($crate::cmd::Pattern::from($p)),*]) + }; +} + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("subprocess exited in error: {0:?}")] + Proc(Output), + + #[error("failed to spawn process: {0:?}")] + Exec(#[from] std::io::Error), + + #[error("failed to decode output as utf-8: {0:?}")] + Utf8(#[from] std::str::Utf8Error), +} + +/// Runs a `swadm` CLI command with the given args. +/// +/// ### Disclaimer +/// +/// This splits args by whitespace, which breaks multi word commands +/// like `echo "hello world"`. Use [`swadm_exact`] if more control +/// is needed. +pub fn swadm(input: impl AsRef) -> Result { + self::swadm_exact(input.as_ref().trim().split_ascii_whitespace()) +} + +/// Executes a `swadm` command where each group of args is +/// an entry in the iterator. +pub fn swadm_exact( + input: impl IntoIterator>, +) -> Result { + let host = std::env::var("DENDRITE_TEST_HOST") + .map(Cow::from) + .unwrap_or_else(|_| "[::1]".into()); + let port = std::env::var("DENDRITE_TEST_PORT") + .map(Cow::from) + .unwrap_or_else(|_| common::DEFAULT_DPD_PORT.to_string().into()); + + let output = Command::new(self::SWADM) + .args(["--host", host.as_ref(), "--port", port.as_ref()]) + .args(input) + .output()?; + + if !output.status.success() { + return Err(self::Error::Proc(Output::Stderr( + std::str::from_utf8(&output.stderr)?.into(), + ))); + } + + Ok(Output::Stdout(std::str::from_utf8(&output.stdout)?.into())) +} + +/// Wraps [`retry_with`] with sensible default sleep and timeout. +pub fn retry(f: impl FnMut() -> Result) -> Result { + const SLEEP: Duration = Duration::from_millis(100); + const TIMEOUT: Duration = Duration::from_secs(2); + + self::retry_with(SLEEP, TIMEOUT, f) +} + +/// Executes the closure in a loop until it returns `Ok` or +/// `timeout` is reached. Sleep between attempts. +/// +/// Returns the most recent result if timeout is reached. +/// +/// Neither the closure nor sleep are interrupted if execution +/// exceeds timeout. +/// +/// This is useful for the read part of write-then-read tests, +/// where a reconciler may need some time to converge. +/// +/// See [`retry`] if you don't care about sleep and timeout. +pub fn retry_with( + sleep: Duration, + timeout: Duration, + mut f: impl FnMut() -> Result, +) -> Result { + let timeout = Instant::now() + timeout; + let mut status = f(); + + while Instant::now() < timeout { + if status.is_ok() { + break; + } + + // Thread sleep in any test is suspicious. The intention here + // is only to smooth out variance in convergence time. + std::thread::sleep(sleep); + + status = f(); + } + + status +} + +/// Common regex patterns for searching swadm output. +pub mod re { + use super::Pattern; + use regex::Regex; + use std::sync::LazyLock; + + /// A block of parentheses with characters inside. Doesn't + /// support nested parentheses. + pub static PARENS: LazyLock = + LazyLock::new(|| Regex::new(r"\([^)]*\)").unwrap().into()); +} + +/// A wrapper type for regex inputs that can be +/// chained to validate swadm CLI output. +// +// PERF: If this were used in a performance-sensitive context, +// we could use late materialization to avoid redundant work when +// cutting and merging patterns together. But that's unwarranted +// complexity in test infra. +pub struct Pattern(Regex); + +impl Pattern { + /// Creates a text literal that will not engage regex semantics. + pub fn literal(lit: impl AsRef) -> Self { + Regex::new(®ex::escape(lit.as_ref())) + .map(Self) + .expect("An escaped regex literal should always compile") + } + + /// Merges an iterator of patterns into an anonymous regex + /// group that matches any of the patterns. + pub fn or( + patterns: impl IntoIterator>, + ) -> anyhow::Result { + let mut reg = "(?:".to_string(); + for (idx, pat) in patterns.into_iter().enumerate() { + if idx != 0 { + reg.push('|'); + } + reg.push_str(pat.as_ref()); + } + reg.push(')'); + + Regex::new(®) + .with_context(|| format!("Regex failed to compile: {reg:?}")) + } +} + +impl AsRef for Pattern { + fn as_ref(&self) -> &Regex { + &self.0 + } +} + +impl AsRef for Pattern { + fn as_ref(&self) -> &str { + self.0.as_str() + } +} + +impl From for Pattern { + fn from(value: Regex) -> Self { + Self(value) + } +} + +/// Conversion for a type that should be interpreted as +/// a regex literal. +macro_rules! from_literal { + ($t:ty) => { + impl From<$t> for $crate::cmd::Pattern { + fn from(literal: $t) -> Self { + Self::literal(&literal.to_string()) + } + } + }; +} + +// Extend as needed +from_literal!(&str); +from_literal!(String); +from_literal!(i8); +from_literal!(i32); +from_literal!(usize); + +/// This contains the output of a [`swadm`] command +/// and can be used for parsing CLI results. +#[derive(Debug)] +pub enum Output { + Stdout(String), + Stderr(String), +} + +impl Output { + /// Removes all instances of the pattern from the output text. + pub fn strip(&mut self, reg: impl AsRef) -> &mut Self { + *self.as_mut() = + reg.as_ref().replace_all(self.as_ref(), "").to_string(); + self + } + + /// Keeps only those lines of output text for which the pattern matches. + /// + /// This reformats line separators into a single newline. + pub fn retain_lines(&mut self, reg: &Regex) -> &mut Self { + *self.as_mut() = self + .as_ref() + .lines() + .filter(|line| reg.is_match(line)) + .fold(String::new(), |mut acc, line| { + acc.push_str(line); + acc.push('\n'); + acc + }); + self + } + + /// Removes from the text everything including and after + /// the first instance of the separator. + pub fn trunc_at(&mut self, sep: &str) -> &mut Self { + if let Some(idx) = self.as_ref().find(sep) { + self.as_mut().truncate(idx); + }; + self + } + + /// Compares the contained text against the target file + /// using [`expectorate`]. Returns error if there's no match. + pub fn try_expectorate(&self, cmp_file: &str) -> anyhow::Result<()> { + let path = [self::SWADM_DIR, "tests/cli/expect", cmp_file] + .into_iter() + .collect::(); + if !expectorate::eq_file(path).eval(self.as_ref()) { + // Diff is printed to stdout anyway. + bail!("Match failed"); + } + Ok(()) + } +} + +impl AsRef for Output { + fn as_ref(&self) -> &str { + let (Self::Stdout(txt) | Self::Stderr(txt)) = self; + txt + } +} + +impl AsMut for Output { + fn as_mut(&mut self) -> &mut String { + let (Self::Stdout(txt) | Self::Stderr(txt)) = self; + txt + } +} + +impl TryFrom for Output { + type Error = anyhow::Error; + + fn try_from(err: self::Error) -> anyhow::Result { + if let Error::Proc(out) = err { + return Ok(out); + } + bail!("Cannot parse cmd error: {err:?}"); + } +} + +#[cfg(test)] +mod test { + use super::re; + + /// Verify these unwraps don't panic. + #[test] + fn pattern_statics() { + let _ = &*re::PARENS; + } +} diff --git a/swadm/tests/cli/counters.rs b/swadm/tests/cli/counters.rs new file mode 100644 index 00000000..5eaf9078 --- /dev/null +++ b/swadm/tests/cli/counters.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/ +// +// Copyright 2026 Oxide Computer Company + +use serial_test::serial; + +use crate::cmd; + +#[test] +#[serial] +#[ignore] +fn counters_list() -> anyhow::Result<()> { + #[cfg(not(feature = "multicast"))] + const DIFF_FILE: &str = "counters.txt"; + + #[cfg(feature = "multicast")] + const DIFF_FILE: &str = "counters_multicast.txt"; + + cmd::swadm("counters list")?.try_expectorate(DIFF_FILE) +} diff --git a/swadm/tests/cli/expect/counters.txt b/swadm/tests/cli/expect/counters.txt new file mode 100644 index 00000000..138d1f14 --- /dev/null +++ b/swadm/tests/cli/expect/counters.txt @@ -0,0 +1 @@ +["Service", "Ingress", "Egress", "Packet", "Ingress_Drop_Port", "Ingress_Drop_Reason"] diff --git a/swadm/tests/cli/expect/counters_multicast.txt b/swadm/tests/cli/expect/counters_multicast.txt new file mode 100644 index 00000000..5cf15de9 --- /dev/null +++ b/swadm/tests/cli/expect/counters_multicast.txt @@ -0,0 +1 @@ +["Service", "Ingress", "Egress", "Packet", "Ingress_Drop_Port", "Ingress_Drop_Reason", "Egress_Drop_Port", "Egress_Drop_Reason", "Unicast", "Multicast", "Multicast_External", "Multicast_Link_Local", "Multicast_Underlay", "Multicast_Drop"] diff --git a/swadm/tests/cli/expect/delete_link_404.txt b/swadm/tests/cli/expect/delete_link_404.txt new file mode 100644 index 00000000..9eb8548f --- /dev/null +++ b/swadm/tests/cli/expect/delete_link_404.txt @@ -0,0 +1,4 @@ +Error: failed to get link + +Caused by: + Error Response: status: 404 Not Found; \ No newline at end of file diff --git a/swadm/tests/cli/expect/link_apply_tx_eq_all.txt b/swadm/tests/cli/expect/link_apply_tx_eq_all.txt new file mode 100644 index 00000000..1ae75e9f --- /dev/null +++ b/swadm/tests/cli/expect/link_apply_tx_eq_all.txt @@ -0,0 +1,6 @@ + lane 0 lane 1 lane 2 lane 3 +pre2 -1 -1 -1 -1 +pre1 -1 -1 -1 -1 +main -1 -1 -1 -1 +post1 -1 -1 -1 -1 +post2 -1 -1 -1 -1 diff --git a/swadm/tests/cli/expect/link_apply_tx_eq_custom.txt b/swadm/tests/cli/expect/link_apply_tx_eq_custom.txt new file mode 100644 index 00000000..51e95206 --- /dev/null +++ b/swadm/tests/cli/expect/link_apply_tx_eq_custom.txt @@ -0,0 +1,6 @@ + lane 0 lane 1 lane 2 lane 3 +pre2 0 0 0 0 +pre1 0 0 0 0 +main -22 -22 -22 -22 +post1 5 5 5 5 +post2 0 0 0 0 diff --git a/swadm/tests/cli/expect/link_apply_tx_eq_exclusive.txt b/swadm/tests/cli/expect/link_apply_tx_eq_exclusive.txt new file mode 100644 index 00000000..664f2fa4 --- /dev/null +++ b/swadm/tests/cli/expect/link_apply_tx_eq_exclusive.txt @@ -0,0 +1,5 @@ +error: the argument '--main
' cannot be used with '--tx-eq ' + +Usage: swadm link apply --link --tag --speed --fec --lane --main
--post1 + +For more information, try '--help'. diff --git a/swadm/tests/cli/expect/tx_eq_100g_link.txt b/swadm/tests/cli/expect/tx_eq_100g_link.txt new file mode 100644 index 00000000..83c0513b --- /dev/null +++ b/swadm/tests/cli/expect/tx_eq_100g_link.txt @@ -0,0 +1,3 @@ +Port/Link rear0/0 +State Up +Speed 100G diff --git a/swadm/tests/cli/expect/tx_eq_exact.txt b/swadm/tests/cli/expect/tx_eq_exact.txt new file mode 100644 index 00000000..94df3893 --- /dev/null +++ b/swadm/tests/cli/expect/tx_eq_exact.txt @@ -0,0 +1,6 @@ + lane 0 lane 1 lane 2 lane 3 +pre2 -1 -1 -1 -1 +pre1 0 0 0 0 +main 10 10 10 10 +post1 5 5 5 5 +post2 2 2 2 2 diff --git a/swadm/tests/cli/expect/tx_eq_partial.txt b/swadm/tests/cli/expect/tx_eq_partial.txt new file mode 100644 index 00000000..1d94b7d7 --- /dev/null +++ b/swadm/tests/cli/expect/tx_eq_partial.txt @@ -0,0 +1,6 @@ + lane 0 lane 1 lane 2 lane 3 +pre2 0 0 0 0 +pre1 0 0 0 0 +main 19 19 19 19 +post1 0 0 0 0 +post2 0 0 0 0 diff --git a/swadm/tests/cli/link_apply.rs b/swadm/tests/cli/link_apply.rs new file mode 100644 index 00000000..6a948178 --- /dev/null +++ b/swadm/tests/cli/link_apply.rs @@ -0,0 +1,110 @@ +// 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/ +// +// Copyright 2026 Oxide Computer Company + +use anyhow::bail; +use serial_test::serial; + +use crate::cmd; +use crate::cmd::re; + +const LINK: &str = "rear0/0"; + +/// Tests the `tx-eq` flag in link settings apply. Verifies that +/// every tap of every lane on the resulting 100g link has +/// the same value. +#[test] +#[serial] +#[ignore] +fn apply_tx_eq_all() -> anyhow::Result<()> { + const VAL: i32 = -1; + + self::delete_link(LINK)?; + + // See tx_eq tests for an output example. + cmd::swadm(format!( + "link apply + --link {LINK} + --tag test + --fec rs + --speed 100g + --lane 0 + --tx-eq={VAL}" + ))?; + + cmd::retry(|| { + cmd::swadm(format!("link serdes get txeq {LINK}"))? + .strip(&*re::PARENS) + .try_expectorate("link_apply_tx_eq_all.txt") + }) +} + +/// Tests the individual tx eq flags in link settings apply. +/// If one or more taps is explicitly declared, the other taps +/// should default to zero. +#[test] +#[serial] +#[ignore] +fn apply_tx_eq_custom() -> anyhow::Result<()> { + self::delete_link(LINK)?; + + cmd::swadm(format!( + "link apply + --link {LINK} + --tag test + --fec rs + --speed 100g + --lane 0 + --main=-22 + --post1 5" + ))?; + + cmd::retry(|| { + cmd::swadm(format!("link serdes get txeq {LINK}"))? + .strip(&*re::PARENS) + .try_expectorate("link_apply_tx_eq_custom.txt") + }) +} + +/// Verifies that the `tx-eq` shorthand and explicit +/// tap flags are mutually exclusive. +#[test] +fn tx_eq_exclusive() -> anyhow::Result<()> { + let out: cmd::Output = cmd::swadm(format!( + "link apply + --link {LINK} + --tag test + --fec rs + --speed 100g + --lane 0 + --main=-22 + --post1 5 + --tx-eq 1" + )) + .expect_err("Flags are exclusive") + .try_into()?; + + out.try_expectorate("link_apply_tx_eq_exclusive.txt") +} + +/// Calls `swadm link delete` and loops until seeing a 404. +/// +/// Returns error if the link isn't removed before timeout. +pub fn delete_link(link: &str) -> anyhow::Result<()> { + // This will fail if the link is already gone, which is fine. + let _ = cmd::swadm(format!("link delete {link}")); + + cmd::retry(|| { + let e = match cmd::swadm(format!("link get {link}")) { + Ok(got) => bail!("Get cmd on deleted link should fail: {got:?}"), + Err(e) => e, + }; + + cmd::Output::try_from(e)? + // Remove headers with UUID and timestamp + .trunc_at("headers:") + .try_expectorate("delete_link_404.txt") + }) +} diff --git a/swadm/tests/cli/main.rs b/swadm/tests/cli/main.rs new file mode 100644 index 00000000..722a07a6 --- /dev/null +++ b/swadm/tests/cli/main.rs @@ -0,0 +1,10 @@ +// 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/ +// +// Copyright 2026 Oxide Computer Company + +mod cmd; +mod counters; +mod link_apply; +mod tx_eq; diff --git a/swadm/tests/cli/tx_eq.rs b/swadm/tests/cli/tx_eq.rs new file mode 100644 index 00000000..154c2435 --- /dev/null +++ b/swadm/tests/cli/tx_eq.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/ +// +// Copyright 2026 Oxide Computer Company + +use anyhow::Context; +use serial_test::serial; + +use crate::cmd; +use crate::cmd::re; + +/// Sets some but not all tx eq taps on a port. +/// Unspecified taps default to zero. +// +// Aside: I would prefer different semantics, but +// docs/scripts in other repos expect this behavior. +#[test] +#[serial] +#[ignore] +fn set_partial_taps() -> anyhow::Result<()> { + let port = "rear0"; + let link = "rear0/0"; + + self::create_100g_link(port, link).context("link setup failed")?; + + cmd::swadm(format!("link serdes set txeq {link} --main 19"))?; + + cmd::retry(|| { + cmd::swadm(format!("link serdes get txeq {link}"))? + .strip(&*re::PARENS) + .try_expectorate("tx_eq_partial.txt") + }) +} + +/// Sets tx eq when all taps on a link are declared. +#[test] +#[serial] +#[ignore] +fn set_all_taps() -> anyhow::Result<()> { + let port = "rear0"; + let link = "rear0/0"; + + self::create_100g_link(port, link).context("link setup failed")?; + + cmd::swadm(format!( + "link serdes set tx-eq {link} --pre2=-1 --pre1 0 --main 10 --post1=5 --post2 2" + ))?; + + cmd::retry(|| { + cmd::swadm(format!("link serdes get txeq {link}"))? + .strip(&*re::PARENS) + .try_expectorate("tx_eq_exact.txt") + }) +} + +/// Creates the new link and runs a few validations on it. +fn create_100g_link(port: &str, link: &str) -> anyhow::Result<()> { + crate::link_apply::delete_link(link)?; + + cmd::swadm(format!("link create {port} -s 100g --fec rs"))?; + cmd::swadm(format!("link enable {link}"))?; + + cmd::retry(|| { + cmd::swadm(format!("link get {link} -v"))? + .retain_lines(&crate::among!["Port/Link", "State", "Speed"]?) + .try_expectorate("tx_eq_100g_link.txt") + }) +} diff --git a/swadm/tests/counters.rs b/swadm/tests/counters.rs deleted file mode 100644 index f01f7e15..00000000 --- a/swadm/tests/counters.rs +++ /dev/null @@ -1,72 +0,0 @@ -// 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/ -// -// Copyright 2026 Oxide Computer Company - -//! Integration test for swadm P4 counter functionality. - -use std::process::Command; - -// Path to `swadm` executable. -const SWADM: &str = env!("CARGO_BIN_EXE_swadm"); - -fn swadm() -> Command { - Command::new(SWADM) -} - -#[test] -#[ignore] -fn test_p4_counter_list() { - let output = swadm() - .arg("--host") - .arg("[::1]") - .arg("counters") - .arg("list") - .output() - .expect("Failed to execute swadm counters list"); - - assert!( - output.status.success(), - "swadm counters list failed with stderr: {}", - String::from_utf8_lossy(&output.stderr) - ); - - let stdout = String::from_utf8_lossy(&output.stdout); - - // Verify output is not empty and contains expected counter information - assert!(!stdout.is_empty(), "Counter list output should not be empty"); - - // Expected P4 counters from dpd/src/counters.rs COUNTERS array - let base_counters = [ - "Service", - "Ingress", - "Packet", - "Egress", - "Ingress_Drop_Port", - "Ingress_Drop_Reason", - ]; - - #[cfg(not(feature = "multicast"))] - let multicast_counters = Vec::new(); - - #[cfg(feature = "multicast")] - let multicast_counters = vec![ - "Egress_Drop_Port", - "Egress_Drop_Reason", - "Unicast", - "Multicast", - "Multicast_External", - "Multicast_Link_Local", - "Multicast_Underlay", - "Multicast_Drop", - ]; - - // Verify all expected counters are present in the output - for counter in base_counters.iter().chain(multicast_counters.iter()) { - assert!( - stdout.contains(counter), - "Counter list should contain '{counter}' counter. Output: {stdout}" - ); - } -} diff --git a/swadm/tests/port-link.rs b/swadm/tests/port-link.rs deleted file mode 100644 index a9b3bb80..00000000 --- a/swadm/tests/port-link.rs +++ /dev/null @@ -1,254 +0,0 @@ -// 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/ -// -// Copyright 2026 Oxide Computer Company - -//! Small integration test to verify that getting / setting properties either -//! via the port-based or link-based `swadm` APIs work as expected. -//! -//! This is a one-off test, and should be deleted once the port-v-link -//! conversion is merged. Note that the Dendrite server needs to be in a fresh -//! state for most of these tests to be valid. - -use std::net::IpAddr; -use std::process::Command; - -// Path to `swadm` executable. -const SWADM: &str = env!("CARGO_BIN_EXE_swadm"); - -// The name of the link we're operating on, in the new and old naming schemes -// respectively. -const LINK: &str = "rear0/0"; -const PORT: &str = "1:0"; - -fn swadm() -> Command { - Command::new(SWADM) -} - -#[derive(Debug)] -struct PropertyValue<'a> { - name: &'a str, - value: &'a str, -} - -#[derive(Debug)] -struct SetTest<'a> { - port: PropertyValue<'a>, - link: PropertyValue<'a>, -} - -impl SetTest<'_> { - fn run(self) { - // Check that properties fetched through link and port are the same, to - // start. - let port_val = get_port_prop(self.port.name); - let link_val = get_link_prop(self.link.name); - assert_eq!( - port_val, link_val, - "Property '{}'/'{}' differs between port and link schemes", - self.port.name, self.link.name, - ); - - // Check that we set the property via port, and fetch it via link. - set_port_prop(self.port.name, self.port.value); - let link_val = get_link_prop(self.link.name); - assert_eq!(self.port.value, link_val.trim()); - - // Check that we set the property via link, and fetch it via port. - set_link_prop(self.link.name, self.link.value); - let port_val = get_port_prop(self.port.name); - assert_eq!(self.link.value, port_val.trim()); - } -} - -fn get_link_prop(name: &str) -> String { - let link_val = swadm() - .arg("link") - .arg("get-prop") - .arg(LINK) - .arg(name) - .output() - .unwrap() - .stdout; - String::from_utf8(link_val).unwrap() -} - -fn get_port_prop(name: &str) -> String { - let port_val = swadm() - .arg("port") - .arg("get") - .arg(PORT) - .arg(name) - .output() - .unwrap() - .stdout; - String::from_utf8(port_val).unwrap() -} - -fn set_link_prop(name: &str, value: &str) { - swadm() - .arg("link") - .arg("set-prop") - .arg(LINK) - .arg(name) - .arg(value) - .output() - .unwrap(); -} - -fn set_port_prop(name: &str, value: &str) { - swadm() - .arg("port") - .arg("set") - .arg(PORT) - .arg(name) - .arg(value) - .output() - .unwrap(); -} - -#[test] -#[ignore] -fn test_mac() { - let test = SetTest { - port: PropertyValue { name: "mac", value: "a8:40:25:ff:ff:01" }, - link: PropertyValue { name: "mac", value: "a8:40:25:ff:ff:02" }, - }; - test.run(); -} - -#[test] -#[ignore] -fn test_an() { - let test = SetTest { - port: PropertyValue { name: "an", value: "true" }, - link: PropertyValue { name: "an", value: "false" }, - }; - test.run(); -} - -#[test] -#[ignore] -fn test_kr() { - let test = SetTest { - port: PropertyValue { name: "kr", value: "true" }, - link: PropertyValue { name: "kr", value: "false" }, - }; - test.run(); -} - -#[test] -#[ignore] -fn test_enable() { - let test = SetTest { - port: PropertyValue { name: "ena", value: "true" }, - link: PropertyValue { name: "ena", value: "false" }, - }; - test.run(); -} - -// Test getting/setting IP addresses on a port/link works correctly. -// -// This is a bit different, since there are multiple IP addresses on each link. -// Also, the port-based swadm API doesn't support operating on addresses; that's -// only available through `swadm addr`. -#[test] -#[ignore] -fn test_ip_addresses() { - let added_port_addrs: &[IpAddr] = - &["192.168.1.1".parse().unwrap(), "fd00::1".parse().unwrap()]; - let added_link_addrs: &[IpAddr] = - &["192.168.1.2".parse().unwrap(), "fd00::2".parse().unwrap()]; - - // Check that both schemes have the same addresses. - let port_addrs = String::from_utf8( - swadm().arg("addr").arg("list").arg(PORT).output().unwrap().stdout, - ) - .unwrap(); - let link_addrs = String::from_utf8( - swadm() - .arg("link") - .arg("get-prop") - .arg(LINK) - .arg("ip") - .output() - .unwrap() - .stdout, - ) - .unwrap(); - assert_eq!(port_addrs, link_addrs); - - // Add the IP addresses via the port scheme. Verify we get them back, and - // that they're also listed in the link scheme. - for addr in added_port_addrs.iter() { - swadm() - .arg("addr") - .arg("add") - .arg(PORT) - .arg(addr.to_string()) - .output() - .unwrap(); - } - let port_addrs: Vec = String::from_utf8( - swadm().arg("addr").arg("list").arg(PORT).output().unwrap().stdout, - ) - .unwrap() - .lines() - .map(|line| line.parse().unwrap()) - .collect(); - let link_addrs: Vec = String::from_utf8( - swadm() - .arg("link") - .arg("get-prop") - .arg(LINK) - .arg("ip") - .output() - .unwrap() - .stdout, - ) - .unwrap() - .lines() - .map(|line| line.parse().unwrap()) - .collect(); - assert_eq!(port_addrs, link_addrs); - assert_eq!(port_addrs, added_port_addrs); - - // Add the IP addresses via the link scheme. Verify we get them back, and - // that they're also listed in the port scheme. - for addr in added_link_addrs.iter() { - swadm() - .arg("link") - .arg("set-prop") - .arg(LINK) - .arg("ip") - .arg(addr.to_string()) - .output() - .unwrap(); - } - let port_addrs: Vec = String::from_utf8( - swadm().arg("addr").arg("list").arg(PORT).output().unwrap().stdout, - ) - .unwrap() - .lines() - .map(|line| line.parse().unwrap()) - .collect(); - let link_addrs: Vec = String::from_utf8( - swadm() - .arg("link") - .arg("get-prop") - .arg(LINK) - .arg("ip") - .output() - .unwrap() - .stdout, - ) - .unwrap() - .lines() - .map(|line| line.parse().unwrap()) - .collect(); - assert_eq!(port_addrs, link_addrs); - let mut all_addrs = [added_port_addrs, added_link_addrs].concat(); - all_addrs.sort(); - assert_eq!(port_addrs, all_addrs); -}