diff --git a/Cargo.lock b/Cargo.lock index 1bbb814..3d9dc6e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1269,6 +1269,19 @@ dependencies = [ "thiserror 2.0.16", ] +[[package]] +name = "dendrite-dropshot-apis" +version = "0.1.0" +dependencies = [ + "anyhow", + "camino", + "clap", + "dpd-api", + "dropshot-api-manager", + "dropshot-api-manager-types", + "semver 1.0.27", +] + [[package]] name = "deranged" version = "0.5.3" @@ -1560,6 +1573,20 @@ dependencies = [ "uuid", ] +[[package]] +name = "drift" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43eb40edecda6106744f5e4f3d4dc78b3adf19d3cfb2d81cc4faa007da91e527" +dependencies = [ + "anyhow", + "indexmap 2.11.4", + "openapiv3", + "regex", + "serde", + "serde_json", +] + [[package]] name = "dropshot" version = "0.16.4" @@ -1611,6 +1638,49 @@ dependencies = [ "waitgroup", ] +[[package]] +name = "dropshot-api-manager" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d0c9a9b3587eb5c7da419466203773307767124fa20e84068d9dd06ee34caa9" +dependencies = [ + "anyhow", + "atomicwrites", + "camino", + "clap", + "debug-ignore", + "drift", + "dropshot", + "dropshot-api-manager-types", + "fs-err", + "hex", + "indent_write", + "newtype_derive", + "openapiv3", + "owo-colors", + "paste", + "semver 1.0.27", + "serde_json", + "sha2", + "similar", + "supports-color", + "textwrap", + "thiserror 2.0.16", +] + +[[package]] +name = "dropshot-api-manager-types" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00be3e4459aae391b88805e9f735c7cf9cf4ed6aad02bc0e92b224b590af39ab" +dependencies = [ + "anyhow", + "camino", + "paste", + "semver 1.0.27", + "serde_json", +] + [[package]] name = "dropshot_endpoint" version = "0.16.4" @@ -1885,6 +1955,15 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dd6caf6059519a65843af8fe2a3ae298b14b80179855aeb4adc2c1934ee619" +[[package]] +name = "fs-err" +version = "3.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44f150ffc8782f35521cec2b23727707cb4045706ba3c854e86bef66b3a8cdbd" +dependencies = [ + "autocfg", +] + [[package]] name = "fs_extra" version = "1.3.0" @@ -2950,6 +3029,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "is_ci" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7655c9839580ee829dfacba1d1278c2b7883e50a277ff7541299489d6bdfdc45" + [[package]] name = "is_terminal_polyfill" version = "1.70.1" @@ -6069,6 +6154,9 @@ name = "similar" version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" +dependencies = [ + "bstr", +] [[package]] name = "siphasher" @@ -6412,6 +6500,15 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "supports-color" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c64fc7232dd8d2e4ac5ce4ef302b1d81e0b80d055b9d77c7c4f51f6aa4c867d6" +dependencies = [ + "is_ci", +] + [[package]] name = "swadm" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 3f2fa96..6a646a0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "dpd-api", "dpd-client", "dpd-types", + "dropshot-apis", "packet", "pcap", "swadm", @@ -62,12 +63,14 @@ bytes = "1.6" camino = { version = "1.1", features = ["serde1"] } cfg-if = "1" chrono = "0.4" -colored = "2" clap = { version = "4.5.8", features = [ "derive" ] } +colored = "2" csv = "1.3" curl = "0.4" display-error-chain = "0.2" dropshot = "0.16.3" +dropshot-api-manager = "0.2.2" +dropshot-api-manager-types = "0.2.2" expectorate = "1" futures = "0.3" http = "1.2.0" diff --git a/dpd-types/Cargo.toml b/dpd-types/Cargo.toml index 8dce945..6349a92 100644 --- a/dpd-types/Cargo.toml +++ b/dpd-types/Cargo.toml @@ -12,5 +12,5 @@ oxnet.workspace = true schemars.workspace = true serde.workspace = true thiserror.workspace = true -transceiver-controller.workspace = true +transceiver-controller = { workspace = true, features = ["api-traits"] } uuid.workspace = true diff --git a/dpd/src/main.rs b/dpd/src/main.rs index e2f90cb..8de414a 100644 --- a/dpd/src/main.rs +++ b/dpd/src/main.rs @@ -88,8 +88,6 @@ pub struct Cli { pub(crate) enum Args { /// Run the Dendrite API server. Run(Box), - /// Generate an OpenAPI specification for the Dendrite server. - Openapi, } /// dataplane controller for oxide switch @@ -791,29 +789,10 @@ fn main() -> anyhow::Result<()> { let cli = Cli::parse(); match cli.args { - Args::Openapi => print_openapi(), Args::Run(opt) => oxide_tokio_rt::run(run_dpd(*opt)), } } -fn print_openapi() -> anyhow::Result<()> { - // TODO: Once migrated to the OpenAPI manager, this should use the stub API - // description. But there are currently additional backend-specific methods - // added by the tofino-asic and softnpu features -- those would need to be - // migrated to the API trait (possibly via a uniform API across all - // backends). - crate::api_server::http_api() - .openapi( - "Oxide Switch Dataplane Controller", - semver::Version::new(0, 1, 0), - ) - .description("API for managing the Oxide rack switch") - .contact_url("https://oxide.computer") - .contact_email("api@oxide.computer") - .write(&mut std::io::stdout()) - .context("writing OpenAPI specification") -} - async fn run_dpd(opt: Opt) -> anyhow::Result<()> { let config = config::build_config(&opt)?; diff --git a/dpd/tests/test_openapi.rs b/dpd/tests/test_openapi.rs index 0977d68..6c16889 100644 --- a/dpd/tests/test_openapi.rs +++ b/dpd/tests/test_openapi.rs @@ -6,31 +6,6 @@ #![allow(clippy::missing_safety_doc)] -// Test that the OpenAPI document matches the one in the repository. -// -// Note that we only test the ASIC version, which should be a strict superset -// of the others, including the counter-related endpoints. -#[cfg(all(feature = "tofino_asic", test))] -#[test] -fn test_dpd_openapi() { - let dpd_path = env!("CARGO_BIN_EXE_dpd"); - let openapi_path = - concat!(env!("CARGO_MANIFEST_DIR"), "/../openapi/dpd.json"); - let output = std::process::Command::new(dpd_path) - .arg("openapi") - .output() - .expect("failed to run `dpd`"); - if !output.status.success() { - let mut msg = String::from("\n`dpd openapi` failed!\n\n"); - for line in String::from_utf8_lossy(&output.stderr).lines() { - msg.push_str(&format!("stderr: {line}\n")); - } - panic!("{}", msg); - } - let output = std::str::from_utf8(&output.stdout).expect("Non-UTF8 output"); - expectorate::assert_contents(openapi_path, output); -} - // NOTE: This is a horrible hack that appears to be necessary. // // We use a mapfile in the tofino_sde repo to declare the QSFP management diff --git a/dropshot-apis/Cargo.toml b/dropshot-apis/Cargo.toml new file mode 100644 index 0000000..da693bc --- /dev/null +++ b/dropshot-apis/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "dendrite-dropshot-apis" +version = "0.1.0" +edition = "2024" +license = "MPL-2.0" + +[dependencies] +anyhow.workspace = true +camino.workspace = true +clap.workspace = true +dpd-api.workspace = true +dropshot-api-manager-types.workspace = true +dropshot-api-manager.workspace = true +semver.workspace = true diff --git a/dropshot-apis/src/main.rs b/dropshot-apis/src/main.rs new file mode 100644 index 0000000..d64ffaa --- /dev/null +++ b/dropshot-apis/src/main.rs @@ -0,0 +1,78 @@ +// 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 std::process::ExitCode; + +use anyhow::Context; +use camino::Utf8PathBuf; +use clap::Parser; +use dpd_api::*; +use dropshot_api_manager::{Environment, ManagedApiConfig, ManagedApis}; +use dropshot_api_manager_types::{ManagedApiMetadata, Versions}; + +pub fn environment() -> anyhow::Result { + // The workspace root is one level up from this crate's directory. + let workspace_root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .to_path_buf(); + let env = Environment::new( + // This is the command used to run the OpenAPI manager. + "cargo xtask openapi".to_owned(), + workspace_root, + // This is the location within the workspace root where the OpenAPI + // documents are stored. + "openapi", + )?; + Ok(env) +} + +/// The list of APIs managed by the OpenAPI manager. +pub fn all_apis() -> anyhow::Result { + let apis = vec![ManagedApiConfig { + ident: "dpd", + versions: Versions::Lockstep { + version: semver::Version::new(0, 1, 0), + }, + title: "Oxide Switch Dataplane Controller", + metadata: ManagedApiMetadata { + description: Some("API for managing the Oxide rack switch"), + contact_url: Some("https://oxide.computer"), + contact_email: Some("api@oxide.computer"), + ..Default::default() + }, + api_description: dpd_api_mod::stub_api_description, + extra_validation: None, + }]; + + let apis = ManagedApis::new(apis).context("error creating ManagedApis")?; + Ok(apis) +} + +fn main() -> anyhow::Result { + let app = dropshot_api_manager::App::parse(); + let env = environment()?; + let apis = all_apis()?; + + Ok(app.exec(&env, &apis)) +} + +#[cfg(test)] +mod test { + use dropshot_api_manager::test_util::check_apis_up_to_date; + + use super::*; + + // Also recommended: a test which ensures documents are up-to-date. The + // OpenAPI manager comes with a helper function for this, called + // `check_apis_up_to_date`. + #[test] + fn test_apis_up_to_date() -> anyhow::Result { + let env = environment()?; + let apis = all_apis()?; + + let result = check_apis_up_to_date(&env, &apis)?; + Ok(result.to_exit_code()) + } +} diff --git a/xtask/src/external.rs b/xtask/src/external.rs new file mode 100644 index 0000000..f8a5c9d --- /dev/null +++ b/xtask/src/external.rs @@ -0,0 +1,131 @@ +// 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/. + +//! External xtasks. (extasks?) + +use std::ffi::OsString; +use std::os::unix::process::CommandExt; +use std::process::Command; + +use anyhow::{Context, Result}; +use clap::Parser; + +/// Argument parser for external xtasks. +/// +/// In general we want all developer tasks to be discoverable simply by running +/// `cargo xtask`, but some development tools end up with a particularly +/// large dependency tree. It's not ideal to have to pay the cost of building +/// our release engineering tooling if all the user wants to do is check for +/// workspace dependency issues. +/// +/// `External` provides a pattern for creating xtasks that live in other crates. +/// An external xtask is defined on `crate::Cmds` as a tuple variant containing +/// `External`, which captures all arguments and options (even `--help`) as +/// a `Vec`. The main function then calls `External::exec` with the +/// appropriate bin target name and any additional Cargo arguments. +#[derive(Debug, Parser)] +#[clap( + disable_help_flag(true), + disable_help_subcommand(true), + disable_version_flag(true) +)] +pub struct External { + #[clap(trailing_var_arg(true), allow_hyphen_values(true))] + args: Vec, + + // This stores an in-progress Command builder. `cargo_args` appends args + // to it, and `exec` consumes it. Clap does not treat this as a command + // (`skip`), but fills in this field by calling `new_command`. + #[clap(skip = new_command())] + command: Command, +} + +impl External { + pub fn exec_bin( + self, + package: impl AsRef, + bin_target: impl AsRef, + ) -> Result<()> { + self.exec_common(&[ + "--package", + package.as_ref(), + "--bin", + bin_target.as_ref(), + ]) + } + + fn exec_common(mut self, args: &[&str]) -> Result<()> { + let error = self.command.args(args).arg("--").args(self.args).exec(); + Err(error).context("failed to exec `cargo run`") + } +} + +fn new_command() -> Command { + let mut command = cargo_command(CargoLocation::FromEnv); + command.arg("run"); + command +} + +/// Creates and prepares a `std::process::Command` for the `cargo` executable. +pub fn cargo_command(location: CargoLocation) -> Command { + let mut command = location.resolve(); + + for (key, _) in std::env::vars_os() { + let Some(key) = key.to_str() else { continue }; + if SANITIZED_ENV_VARS.matches(key) { + command.env_remove(key); + } + } + + command +} + +/// How to determine the location of the `cargo` executable. +#[derive(Clone, Copy, Debug)] +pub enum CargoLocation { + /// Use the `CARGO` environment variable, and fall back to `"cargo"` if it + /// is not set. + FromEnv, +} + +impl CargoLocation { + fn resolve(self) -> Command { + match self { + CargoLocation::FromEnv => { + let cargo = std::env::var_os("CARGO") + .unwrap_or_else(|| OsString::from("cargo")); + Command::new(&cargo) + } + } + } +} + +#[derive(Debug)] +struct SanitizedEnvVars { + // At the moment we only ban some prefixes, but we may also want to ban env + // vars by exact name in the future. + prefixes: &'static [&'static str], +} + +impl SanitizedEnvVars { + const fn new() -> Self { + // Remove many of the environment variables set in + // https://doc.rust-lang.org/cargo/reference/environment-variables.html#environment-variables-cargo-sets-for-build-scripts. + // This is done to avoid recompilation with crates like ring between + // `cargo clippy` and `cargo xtask clippy`. (This is really a bug in + // both ring's build script and in Cargo.) + // + // The current list is informed by looking at ring's build script, so + // it's not guaranteed to be exhaustive and it may need to grow over + // time. + let prefixes = &["CARGO_PKG_", "CARGO_MANIFEST_", "CARGO_CFG_"]; + Self { prefixes } + } + + fn matches(&self, key: &str) -> bool { + self.prefixes.iter().any(|prefix| key.starts_with(prefix)) + } +} + +static SANITIZED_ENV_VARS: SanitizedEnvVars = SanitizedEnvVars::new(); diff --git a/xtask/src/main.rs b/xtask/src/main.rs index 529581f..95d1f9f 100644 --- a/xtask/src/main.rs +++ b/xtask/src/main.rs @@ -15,6 +15,7 @@ use anyhow::{Context, Result, anyhow}; use clap::{Parser, Subcommand, ValueEnum}; mod codegen; +mod external; #[cfg(target_os = "illumos")] mod illumos; @@ -63,6 +64,8 @@ struct Xtasks { #[derive(Debug, Subcommand)] #[clap(name = "xtask")] enum XtaskCommands { + /// manage OpenAPI documents + Openapi(external::External), /// compile a p4 program Codegen { /// name of p4 program to build @@ -245,6 +248,8 @@ fn collect_binaries( async fn main() { let task = Xtasks::parse(); if let Err(e) = match task.subcommand { + XtaskCommands::Openapi(external) => external + .exec_bin("dendrite-dropshot-apis", "dendrite-dropshot-apis"), XtaskCommands::Codegen { name, sde, stages } => { codegen::build(name, sde, stages) }