From 25cbcaec66bccdd311f7dbe86bfd33df67afd046 Mon Sep 17 00:00:00 2001 From: Nils Nieuwejaar Date: Fri, 20 Feb 2026 12:45:23 -0500 Subject: [PATCH] Fixes: 79 Add ls -> list alias for arp subcommand 121 swadm link history could report better timestamps for link events 221 swadm link ls filter matching --- Cargo.lock | 2 + Cargo.toml | 1 + common/src/lib.rs | 10 +- dpd-api/src/lib.rs | 20 ++ dpd-api/src/v11.rs | 16 ++ dpd-types/src/views.rs | 5 +- dpd/src/link.rs | 3 +- openapi/dpd/dpd-10.0.0-57485f.json.gitstub | 1 + ...0.0-57485f.json => dpd-11.0.0-b81c2a.json} | 10 +- openapi/dpd/dpd-latest.json | 2 +- swadm/Cargo.toml | 4 +- swadm/src/counters.rs | 1 + swadm/src/link.rs | 180 ++++++++++++++++-- swadm/src/main.rs | 8 +- tools/run_dpd.sh | 2 +- 15 files changed, 234 insertions(+), 31 deletions(-) create mode 100644 dpd-api/src/v11.rs create mode 100644 openapi/dpd/dpd-10.0.0-57485f.json.gitstub rename openapi/dpd/{dpd-10.0.0-57485f.json => dpd-11.0.0-b81c2a.json} (99%) diff --git a/Cargo.lock b/Cargo.lock index 3ca0fd3e..ba65962d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7019,8 +7019,10 @@ dependencies = [ "common 0.1.0", "dpd-client 0.1.0", "futures", + "humantime", "oxide-tokio-rt", "oxnet", + "regex", "reqwest 0.13.2", "slog", "tabwriter", diff --git a/Cargo.toml b/Cargo.toml index 2177b5fb..d66095ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -75,6 +75,7 @@ dropshot-api-manager-types = "0.5.2" expectorate = "1" futures = "0.3" http = "1.2.0" +humantime = "2.3" kstat-rs = "0.2.4" lazy_static = "1.5" libc = "0.2" diff --git a/common/src/lib.rs b/common/src/lib.rs index e83fcde3..25ed16b8 100644 --- a/common/src/lib.rs +++ b/common/src/lib.rs @@ -113,12 +113,18 @@ fn timestamp() -> Duration { Instant::now().duration_since(START_OF_DAY.unwrap()) } } -/// Return a timestamp in nanoseconds +/// Return a START_OF_DAY-relative timestamp in nanoseconds pub fn timestamp_ns() -> i64 { i64::try_from(timestamp().as_nanos()).unwrap() } -/// Return a timestamp in milliseconds +/// Return a START_OF_DAY-relative timestamp in milliseconds pub fn timestamp_ms() -> i64 { i64::try_from(timestamp().as_millis()).unwrap() } + +/// Return a time-of-day timestamp in milliseconds +pub fn wallclock_ms() -> i64 { + let now = chrono::Utc::now(); + now.timestamp_millis() +} diff --git a/dpd-api/src/lib.rs b/dpd-api/src/lib.rs index f26f1acf..d7d43721 100644 --- a/dpd-api/src/lib.rs +++ b/dpd-api/src/lib.rs @@ -7,6 +7,7 @@ //! DPD endpoint definitions. pub mod v1; +pub mod v11; pub mod v2; pub mod v7; @@ -63,6 +64,7 @@ api_versions!([ // | example for the next person. // v // (next_int, IDENT), + (11, WALLCLOCK_HISTORY), (10, ASIC_DETAILS), (9, SNAPSHOT), (8, MCAST_STRICT_UNDERLAY), @@ -1173,6 +1175,24 @@ pub trait DpdApi { #[endpoint { method = GET, path = "/ports/{port_id}/links/{link_id}/history", + versions = ..VERSION_WALLCLOCK_HISTORY + }] + async fn link_history_get_v11( + rqctx: RequestContext, + path: Path, + ) -> Result, HttpError> { + let history = Self::link_history_get(rqctx, path).await?.0; + Ok(HttpResponseOk(v11::LinkHistory { + timestamp: history.relative, + events: history.events, + })) + } + + /// Get the event history for the given link. + #[endpoint { + method = GET, + path = "/ports/{port_id}/links/{link_id}/history", + versions = VERSION_WALLCLOCK_HISTORY.. }] async fn link_history_get( rqctx: RequestContext, diff --git a/dpd-api/src/v11.rs b/dpd-api/src/v11.rs new file mode 100644 index 00000000..62be8d7f --- /dev/null +++ b/dpd-api/src/v11.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/ +// +// Copyright 2026 Oxide Computer Company + +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] +pub struct LinkHistory { + /// The timestamp in milliseconds at which this history was collected + pub timestamp: i64, + /// The set of historical events recorded + pub events: Vec, +} diff --git a/dpd-types/src/views.rs b/dpd-types/src/views.rs index 692f0842..03987109 100644 --- a/dpd-types/src/views.rs +++ b/dpd-types/src/views.rs @@ -99,8 +99,11 @@ pub struct LinkEvent { #[derive(Clone, Debug, Deserialize, JsonSchema, Serialize)] pub struct LinkHistory { - /// The timestamp in milliseconds at which this history was collected. + /// The wallclock time in milliseconds at which this history was collected. pub timestamp: i64, + /// The timestamp in milliseconds at which this history was collected, + /// relative to the time the switch management daemon started. + pub relative: i64, /// The set of historical events recorded pub events: Vec, } diff --git a/dpd/src/link.rs b/dpd/src/link.rs index 94a18d85..88c64e3e 100644 --- a/dpd/src/link.rs +++ b/dpd/src/link.rs @@ -995,7 +995,8 @@ impl Switch { let mut events = self.fetch_history(asic_ids); events.sort_by_key(|a| a.timestamp); Ok(views::LinkHistory { - timestamp: common::timestamp_ms(), + timestamp: common::wallclock_ms(), + relative: common::timestamp_ms(), events: events.iter().map(|er| er.into()).collect(), }) } diff --git a/openapi/dpd/dpd-10.0.0-57485f.json.gitstub b/openapi/dpd/dpd-10.0.0-57485f.json.gitstub new file mode 100644 index 00000000..4ce17ea1 --- /dev/null +++ b/openapi/dpd/dpd-10.0.0-57485f.json.gitstub @@ -0,0 +1 @@ +78fa4e7c745169e48c7a1d4a0aaa5e9ac47d8057:openapi/dpd/dpd-10.0.0-57485f.json diff --git a/openapi/dpd/dpd-10.0.0-57485f.json b/openapi/dpd/dpd-11.0.0-b81c2a.json similarity index 99% rename from openapi/dpd/dpd-10.0.0-57485f.json rename to openapi/dpd/dpd-11.0.0-b81c2a.json index 2059350f..103020d2 100644 --- a/openapi/dpd/dpd-10.0.0-57485f.json +++ b/openapi/dpd/dpd-11.0.0-b81c2a.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "10.0.0" + "version": "11.0.0" }, "paths": { "/all-settings": { @@ -7758,14 +7758,20 @@ "$ref": "#/components/schemas/LinkEvent" } }, + "relative": { + "description": "The timestamp in milliseconds at which this history was collected, relative to the time the switch management daemon started.", + "type": "integer", + "format": "int64" + }, "timestamp": { - "description": "The timestamp in milliseconds at which this history was collected.", + "description": "The wallclock time in milliseconds at which this history was collected.", "type": "integer", "format": "int64" } }, "required": [ "events", + "relative", "timestamp" ] }, diff --git a/openapi/dpd/dpd-latest.json b/openapi/dpd/dpd-latest.json index 79a120a3..e4833550 120000 --- a/openapi/dpd/dpd-latest.json +++ b/openapi/dpd/dpd-latest.json @@ -1 +1 @@ -dpd-10.0.0-57485f.json \ No newline at end of file +dpd-11.0.0-b81c2a.json \ No newline at end of file diff --git a/swadm/Cargo.toml b/swadm/Cargo.toml index 9c88e020..2c0bc18b 100644 --- a/swadm/Cargo.toml +++ b/swadm/Cargo.toml @@ -16,9 +16,11 @@ colored.workspace = true common.workspace = true dpd-client.workspace = true futures.workspace = true -reqwest.workspace = true +humantime.workspace = true oxide-tokio-rt.workspace = true oxnet.workspace = true +regex.workspace = true +reqwest.workspace = true slog.workspace = true tabwriter.workspace = true tokio.workspace = true diff --git a/swadm/src/counters.rs b/swadm/src/counters.rs index 3c8e6e02..8d3157b1 100644 --- a/swadm/src/counters.rs +++ b/swadm/src/counters.rs @@ -24,6 +24,7 @@ use dpd_client::types; /// non-development debugging, you probably want "swadm link counters". pub enum P4Counters { /// list all available counters + #[clap(visible_alias = "ls")] List, /// get data from the given counter Get { diff --git a/swadm/src/link.rs b/swadm/src/link.rs index a8d31cac..b801c577 100644 --- a/swadm/src/link.rs +++ b/swadm/src/link.rs @@ -4,14 +4,16 @@ // // Copyright 2026 Oxide Computer Company -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use std::convert::From; use std::io::{Write, stdout}; use std::net::IpAddr; use std::str::FromStr; +use std::time::Duration; use anyhow::Context; use anyhow::bail; +use chrono::{DateTime, Utc}; use clap::{Parser, Subcommand}; use colored::*; use futures::stream::TryStreamExt; @@ -331,7 +333,7 @@ pub enum Link { /// This does a simple substring search in the link name, e.g., /// "rear0/0". Those whose link name contains the substring are printed, /// and others are filtered out. - filter: Option, + filter: Vec, }, /// Enable a link. @@ -365,15 +367,13 @@ pub enum Link { /// be relative to the current time when displaying events in the default /// order, or relative to the oldest time when displaying events from oldest /// to newest. - /// - /// The --raw option will cause the raw timestamps to be displayed. This - /// timestamp can't be used to determine the wallclock time of an event, but - /// it can be used to correlate events across multiple links. History { - /// The link to get the history of. - link: LinkPath, - /// Display raw timestamps rather than relative - #[clap(long, visible_alias = "R")] + /// Display the time of day for each event, to enable correlation with + /// logfile messages. + #[clap(long, group = "time")] + tod: bool, + /// Display the time of each event in raw milliseconds. + #[clap(long, group = "time")] raw: bool, /// Display history from oldest event to newest event #[clap(long, short)] @@ -381,6 +381,8 @@ pub enum Link { /// Maximum number of events to display #[clap(short)] n: Option, + /// The link to get the history of. + link: LinkPath, }, /// Manage a link in the Faulted state @@ -862,7 +864,7 @@ async fn link_rmon_counters( let delay = match interval { None => None, - Some(d) if d >= 1 => Some(std::time::Duration::from_secs(d as u64)), + Some(d) if d >= 1 => Some(Duration::from_secs(d as u64)), _ => bail!("interval must be > 0"), }; @@ -1474,14 +1476,17 @@ fn print_link_verbose( fn display_link_history( history: types::LinkHistory, + tod: bool, raw: bool, reverse: bool, n: Option, ) { - let (newest, mut events) = (history.timestamp, history.events); + let (newest, mut events) = (history.relative, history.events); if events.is_empty() { return; } + let collect_time = + DateTime::::from_timestamp_millis(history.timestamp).unwrap(); let oldest = if reverse { events.sort_by_key(|a| a.timestamp); events[0].timestamp @@ -1506,12 +1511,23 @@ fn display_link_history( writeln!( &mut tw, "{}\t{}\t{}\t{}\t{}", - if raw { - event.timestamp - } else if reverse { - event.timestamp - oldest + if tod { + let delta = + Duration::from_millis((newest - event.timestamp) as u64); + let dt = collect_time - delta; + dt.format("%+").to_string() } else { - newest - event.timestamp + let timing = if reverse { + event.timestamp - oldest + } else { + newest - event.timestamp + }; + if raw { + timing.to_string() + } else { + let d = Duration::from_millis(timing.try_into().unwrap()); + humantime::format_duration(d).to_string() + } }, event.class, event.subclass, @@ -1529,6 +1545,62 @@ fn display_link_history( tw.flush().unwrap(); } +const FILTER_RE: &str = r"(int|rear|qsfp)([0-9]+)?/?([0-9]+)?$"; + +fn apply_filter( + filters: &Vec, + ids: Vec, +) -> anyhow::Result<(HashSet, Vec)> { + let filter_re = regex::Regex::new(FILTER_RE).unwrap(); + + let mut matches = HashSet::new(); + let mut unused = Vec::new(); + for f in filters { + let lc = f.to_lowercase(); + let Some(caps) = filter_re.captures(&lc) else { + bail!(format!("invalid filter: {}", f)); + }; + let filter_type = caps.get(1).map(|m| m.as_str()).unwrap(); + let filter_port = caps.get(2).map(|m| m.as_str()); + let filter_link = + caps.get(3).map(|m| m.as_str().parse::().unwrap()); + let mut used = false; + + for id in &ids { + // Annoyingly, we don't have access to the raw components of the + // port ID here, so we have to convert it to a string first. + let port_id = id.port_id.to_string(); + let (id_type, id_port) = if port_id.starts_with("int") { + ("int", port_id.strip_prefix("int")) + } else if port_id.starts_with("rear") { + ("rear", port_id.strip_prefix("rear")) + } else if port_id.starts_with("qsfp") { + ("qsfp", port_id.strip_prefix("qsfp")) + } else { + bail!(format!("invalid port_id found: {port_id}")) + }; + if filter_type != id_type { + continue; + } + + if filter_port.is_some() && filter_port != id_port { + continue; + } + if let Some(x) = filter_link + && x != *id.link_id + { + continue; + } + matches.insert(id.clone()); + used = true; + } + if !used { + unused.push(f.clone()); + } + } + Ok((matches, unused)) +} + pub async fn link_cmd(client: &Client, link: Link) -> anyhow::Result<()> { match link { Link::Create(LinkCreate { port_id, speed, lane, fec, autoneg, kr }) => { @@ -1619,11 +1691,26 @@ pub async fn link_cmd(client: &Client, link: Link) -> anyhow::Result<()> { print_link_fields(); return Ok(()); } - let links = client - .link_list_all(filter.as_deref()) + let mut links = client + .link_list_all(None) .await .context("failed to list all links")? .into_inner(); + let mut unused_filters = Vec::new(); + if !filter.is_empty() { + let (ids, unused) = apply_filter( + &filter, + links.iter().map(LinkPath::from).collect(), + )?; + links = links + .into_iter() + .filter(|l| ids.contains(&LinkPath::from(l))) + .collect::>(); + unused_filters = unused; + } + if links.is_empty() { + bail!("no links found"); + } if parseable { let fields = fields.as_deref().unwrap_or(DEFAULT_PARSEABLE_FIELDS); @@ -1640,6 +1727,9 @@ pub async fn link_cmd(client: &Client, link: Link) -> anyhow::Result<()> { } tw.flush()?; } + if !unused_filters.is_empty() { + bail!("unused filters: {unused_filters:?}"); + } } Link::GetProp { link, property } => { match property { @@ -1861,12 +1951,12 @@ pub async fn link_cmd(client: &Client, link: Link) -> anyhow::Result<()> { .await .with_context(|| "failed to disable link")?; } - Link::History { link, raw, reverse, n } => { + Link::History { link, tod, raw, reverse, n } => { let history = client .link_history_get(&link.port_id, &link.link_id) .await .context("failed to get Link history")?; - display_link_history(history.into_inner(), raw, reverse, n); + display_link_history(history.into_inner(), tod, raw, reverse, n); } Link::Fault { cmd: fault } => match fault { Fault::Show { link_path } => { @@ -2038,3 +2128,51 @@ pub async fn link_cmd(client: &Client, link: Link) -> anyhow::Result<()> { } Ok(()) } + +#[test] +fn test_filter() { + struct TestCase { + test: &'static str, + type_: Option<&'static str>, + port: Option<&'static str>, + link: Option<&'static str>, + } + + impl TestCase { + pub fn new( + test: &'static str, + type_: Option<&'static str>, + port: Option<&'static str>, + link: Option<&'static str>, + ) -> Self { + TestCase { test, type_, port, link } + } + } + let tests = vec![ + TestCase::new("rear", Some("rear"), None, None), + TestCase::new("qsfp10", Some("qsfp"), Some("10"), None), + TestCase::new("int0/0", Some("int"), Some("0"), Some("0")), + TestCase::new("int0/", Some("int"), Some("0"), None), + TestCase::new("int0/0/", None, None, None), + TestCase::new("into0", None, None, None), + TestCase::new("xxx", None, None, None), + TestCase::new("xxx0", None, None, None), + TestCase::new("xxx0/0", None, None, None), + TestCase::new("qsfp/0/0", None, None, None), + TestCase::new("qsfp0/0/0", None, None, None), + ]; + + let re = regex::Regex::new(FILTER_RE).unwrap(); + for t in tests { + println!("testing {}", t.test); + if let Some(caps) = re.captures(&t.test.to_lowercase()) { + assert_eq!(caps.get(1).map(|m| m.as_str()), t.type_); + assert_eq!(caps.get(2).map(|m| m.as_str()), t.port); + assert_eq!(caps.get(3).map(|m| m.as_str()), t.link); + } else { + assert_eq!(None, t.type_); + assert_eq!(None, t.port); + assert_eq!(None, t.link); + } + } +} diff --git a/swadm/src/main.rs b/swadm/src/main.rs index a1c2294e..b5fd1221 100644 --- a/swadm/src/main.rs +++ b/swadm/src/main.rs @@ -124,12 +124,18 @@ impl FromStr for LinkName { } // A "path" to a link, structured as `port_id/link_id`. -#[derive(Clone, Debug, Parser)] +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Parser)] pub struct LinkPath { port_id: types::PortId, link_id: types::LinkId, } +impl From<&types::Link> for LinkPath { + fn from(value: &types::Link) -> Self { + LinkPath { port_id: value.port_id.clone(), link_id: value.link_id } + } +} + impl FromStr for LinkPath { type Err = anyhow::Error; diff --git a/tools/run_dpd.sh b/tools/run_dpd.sh index c64f5df7..8fc267c8 100755 --- a/tools/run_dpd.sh +++ b/tools/run_dpd.sh @@ -4,7 +4,7 @@ # 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 2025 Oxide Computer Company +# Copyright 2026 Oxide Computer Company WS=${WS:=`git rev-parse --show-toplevel 2> /dev/null`} P4_NAME=${P4_NAME:=sidecar}