From ff2cd876c03baea0603d8fca870fc62565689d7d Mon Sep 17 00:00:00 2001 From: Rain Date: Fri, 31 May 2024 00:26:28 -0700 Subject: [PATCH 1/8] =?UTF-8?q?[=F0=9D=98=80=F0=9D=97=BD=F0=9D=97=BF]=20in?= =?UTF-8?q?itial=20version?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Created using spr 1.3.6-beta.1 --- Cargo.lock | 53 +- Cargo.toml | 9 +- apis/apigen/Cargo.toml | 25 + apis/apigen/src/check.rs | 156 ++ apis/apigen/src/dispatch.rs | 118 ++ apis/apigen/src/lib.rs | 10 + apis/apigen/src/main.rs | 13 + apis/apigen/src/output.rs | 122 ++ apis/apigen/src/spec.rs | 177 ++ apis/nexus-internal-api/Cargo.toml | 18 + apis/nexus-internal-api/src/lib.rs | 585 ++++++ dev-tools/xtask/src/main.rs | 4 + .../src/bin/{apigen.rs => dns-apigen.rs} | 0 dns-server/tests/openapi_test.rs | 2 +- nexus/Cargo.toml | 1 + nexus/db-model/src/external_ip.rs | 23 + nexus/db-model/src/ipv4_nat_entry.rs | 18 +- .../src/db/datastore/ipv4_nat_entry.rs | 2 +- nexus/db-queries/src/db/datastore/mod.rs | 1 - nexus/db-queries/src/db/datastore/probe.rs | 31 +- nexus/src/app/probe.rs | 2 +- nexus/src/external_api/http_entrypoints.rs | 4 +- nexus/src/internal_api/http_entrypoints.rs | 1710 +++++++---------- nexus/tests/integration_tests/commands.rs | 16 - nexus/tests/integration_tests/probe.rs | 3 +- nexus/types/src/external_api/shared.rs | 26 + nexus/types/src/internal_api/params.rs | 2 +- nexus/types/src/internal_api/views.rs | 17 + openapi/nexus-internal.json | 27 +- workspace-hack/Cargo.toml | 14 +- 30 files changed, 2109 insertions(+), 1080 deletions(-) create mode 100644 apis/apigen/Cargo.toml create mode 100644 apis/apigen/src/check.rs create mode 100644 apis/apigen/src/dispatch.rs create mode 100644 apis/apigen/src/lib.rs create mode 100644 apis/apigen/src/main.rs create mode 100644 apis/apigen/src/output.rs create mode 100644 apis/apigen/src/spec.rs create mode 100644 apis/nexus-internal-api/Cargo.toml create mode 100644 apis/nexus-internal-api/src/lib.rs rename dns-server/src/bin/{apigen.rs => dns-apigen.rs} (100%) diff --git a/Cargo.lock b/Cargo.lock index a0878a0c32..7b8470b39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -169,6 +169,27 @@ dependencies = [ "syn 2.0.64", ] +[[package]] +name = "apigen" +version = "0.1.0" +dependencies = [ + "anyhow", + "atomicwrites", + "camino", + "clap", + "dropshot", + "fs-err", + "indent_write", + "nexus-internal-api", + "omicron-workspace-hack", + "openapi-lint", + "openapiv3", + "owo-colors", + "serde_json", + "similar", + "supports-color", +] + [[package]] name = "approx" version = "0.5.1" @@ -2026,7 +2047,6 @@ dependencies = [ [[package]] name = "dropshot" version = "0.10.2-dev" -source = "git+https://github.com/oxidecomputer/dropshot?branch=main#0cd0e828d096578392b6a5524334d44fd10ef6da" dependencies = [ "async-stream", "async-trait", @@ -2072,7 +2092,6 @@ dependencies = [ [[package]] name = "dropshot_endpoint" version = "0.10.2-dev" -source = "git+https://github.com/oxidecomputer/dropshot?branch=main#0cd0e828d096578392b6a5524334d44fd10ef6da" dependencies = [ "proc-macro2", "quote", @@ -3532,6 +3551,12 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "indent_write" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cfe9645a18782869361d9c8732246be7b410ad4e919d3609ebabdac00ba12c3" + [[package]] name = "indexmap" version = "1.9.3" @@ -4669,6 +4694,20 @@ dependencies = [ "serde_json", ] +[[package]] +name = "nexus-internal-api" +version = "0.1.0" +dependencies = [ + "dropshot", + "nexus-types", + "omicron-common", + "omicron-uuid-kinds", + "omicron-workspace-hack", + "schemars", + "serde", + "uuid", +] + [[package]] name = "nexus-inventory" version = "0.1.0" @@ -5453,6 +5492,7 @@ dependencies = [ "nexus-db-model", "nexus-db-queries", "nexus-defaults", + "nexus-internal-api", "nexus-inventory", "nexus-metrics-producer-gc", "nexus-networking", @@ -5835,6 +5875,7 @@ dependencies = [ "bit-vec", "bitflags 1.3.2", "bitflags 2.5.0", + "bstr 0.2.17", "bstr 1.9.1", "byteorder", "bytes", @@ -8248,9 +8289,9 @@ dependencies = [ [[package]] name = "schemars" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0218ceea14babe24a4a5836f86ade86c1effbc198164e619194cb5069187e29" +checksum = "09c024468a378b7e36765cd36702b7a90cc3cba11654f6685c8f233408e89e92" dependencies = [ "bytes", "chrono", @@ -8263,9 +8304,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "0.8.20" +version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed5a1ccce8ff962e31a165d41f6e2a2dd1245099dc4d594f5574a86cd90f4d3" +checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index fc8811e9b5..0f8fb0d10d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,7 @@ [workspace] members = [ "api_identity", + "apis/*", "bootstore", "certificates", "clients/bootstrap-agent-client", @@ -84,6 +85,7 @@ members = [ ] default-members = [ + "apis/*", "bootstore", "certificates", "clients/bootstrap-agent-client", @@ -291,6 +293,7 @@ hyper = "0.14" hyper-rustls = "0.26.0" hyper-staticfile = "0.9.5" illumos-utils = { path = "illumos-utils" } +indent_write = "2.2.0" indexmap = "2.2.6" indicatif = { version = "0.17.8", features = ["rayon"] } installinator = { path = "installinator" } @@ -323,6 +326,7 @@ nexus-db-model = { path = "nexus/db-model" } nexus-db-queries = { path = "nexus/db-queries" } nexus-defaults = { path = "nexus/defaults" } nexus-inventory = { path = "nexus/inventory" } +nexus-internal-api = { path = "apis/nexus-internal-api" } nexus-macros-common = { path = "nexus/macros-common" } nexus-metrics-producer-gc = { path = "nexus/metrics-producer-gc" } nexus-networking = { path = "nexus/networking" } @@ -425,6 +429,7 @@ shell-words = "1.1.0" signal-hook = "0.3" signal-hook-tokio = { version = "0.3", features = [ "futures-v0_3" ] } sigpipe = "0.1.3" +similar = { version = "2.5.0", features = ["bytes"] } similar-asserts = "1.5.0" sled = "0.34" sled-agent-client = { path = "clients/sled-agent-client" } @@ -667,8 +672,8 @@ opt-level = 3 # It's common during development to use a local copy of various complex # dependencies. If you want to use those, uncomment one of these blocks. # -#[patch."https://github.com/oxidecomputer/dropshot"] -#dropshot = { path = "../dropshot/dropshot" } +[patch."https://github.com/oxidecomputer/dropshot"] +dropshot = { path = "../dropshot/dropshot" } #[patch.crates-io] #steno = { path = "../steno" } #[patch."https://github.com/oxidecomputer/propolis"] diff --git a/apis/apigen/Cargo.toml b/apis/apigen/Cargo.toml new file mode 100644 index 0000000000..df244b59fc --- /dev/null +++ b/apis/apigen/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "apigen" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +anyhow.workspace = true +atomicwrites.workspace = true +camino.workspace = true +clap.workspace = true +dropshot.workspace = true +fs-err.workspace = true +indent_write.workspace = true +nexus-internal-api.workspace = true +omicron-workspace-hack.workspace = true +openapiv3.workspace = true +openapi-lint.workspace = true +owo-colors.workspace = true +serde_json.workspace = true +similar.workspace = true +supports-color.workspace = true diff --git a/apis/apigen/src/check.rs b/apis/apigen/src/check.rs new file mode 100644 index 0000000000..9854777fe1 --- /dev/null +++ b/apis/apigen/src/check.rs @@ -0,0 +1,156 @@ +// 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::Result; +use camino::Utf8Path; +use indent_write::io::IndentWriter; +use owo_colors::OwoColorize; +use similar::TextDiff; + +use crate::{ + output::{write_diff, OutputOpts, Styles, CHECK, CROSS, STAR, WARNING}, + spec::{all_apis, CheckStatus}, +}; + +pub(crate) fn check_impl( + dir: &Utf8Path, + output: &OutputOpts, +) -> Result { + let mut styles = Styles::default(); + if output.use_color(supports_color::Stream::Stderr) { + styles.colorize(); + } + + let all_apis = all_apis(); + + eprintln!("{STAR} checking {} APIs...", all_apis.len().style(styles.bold)); + let mut num_errors = 0; + let mut num_outdated = 0; + + for api in all_apis { + let status = match api.check(&dir) { + Ok(status) => status, + Err(err) => { + eprintln!( + " {} {}: {}", + CROSS.style(styles.failure), + api.filename, + err.style(styles.failure), + ); + num_errors += 1; + continue; + } + }; + + match status { + CheckStatus::Ok => { + eprintln!( + " {} {}: {}", + CHECK.style(styles.success), + api.filename, + "up-to-date".style(styles.success) + ); + } + CheckStatus::Mismatch { full_path, actual, expected } => { + eprintln!( + " {} {}: {}", + WARNING.style(styles.warning), + api.filename, + "mismatch".style(styles.warning), + ); + + let diff = TextDiff::from_lines(&actual, &expected); + write_diff( + &diff, + &full_path, + &styles, + // Add an indent to align diff with the status message. + &mut IndentWriter::new(" ", std::io::stderr()), + )?; + + num_outdated += 1; + } + CheckStatus::Missing { .. } => { + println!( + " {} {}: {}", + WARNING.style(styles.warning), + api.filename, + "missing".style(styles.warning), + ); + num_outdated += 1; + } + } + } + + let mut errors = Vec::new(); + if num_outdated > 0 { + errors.push(format!( + "{} APIs are {}", + num_outdated.style(styles.bold), + "out of date".style(styles.failure), + )); + } + + if num_errors > 0 { + errors.push(format!( + "{} {} encountered", + num_errors.style(styles.bold), + "errors".style(styles.failure), + )); + } + + if !errors.is_empty() { + eprintln!( + "{} {}: run {} to update", + CROSS.style(styles.failure), + errors.join(", "), + "cargo xtask apigen generate".style(styles.bold), + ); + + Ok(CheckResult::OutOfDate) + } else { + eprintln!( + "{} all APIs are {}", + CHECK.style(styles.success), + "up-to-date".style(styles.success) + ); + Ok(CheckResult::Ok) + } +} + +#[derive(Clone, Copy, Debug)] +pub(crate) enum CheckResult { + Ok, + OutOfDate, +} + +impl CheckResult { + pub(crate) fn to_exit_code(self) -> ExitCode { + match self { + CheckResult::Ok => ExitCode::SUCCESS, + // Use a code that's not 0 or 1 (errors) to indicate out-of-date. + CheckResult::OutOfDate => 2.into(), + } + } +} + +#[cfg(test)] +mod tests { + use std::process::ExitCode; + + use crate::spec::find_openapi_dir; + + use super::*; + + #[test] + fn check_apis_up_to_date() -> Result { + let output = OutputOpts { color: clap::ColorChoice::Auto }; + let dir = find_openapi_dir()?; + + let result = check_impl(&dir, &output)?; + Ok(result.to_exit_code()) + } +} diff --git a/apis/apigen/src/dispatch.rs b/apis/apigen/src/dispatch.rs new file mode 100644 index 0000000000..859c7bac3b --- /dev/null +++ b/apis/apigen/src/dispatch.rs @@ -0,0 +1,118 @@ +// 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::Result; +use camino::Utf8PathBuf; +use clap::{Parser, Subcommand}; +use owo_colors::OwoColorize; + +use crate::{ + check::check_impl, + output::{OutputOpts, Styles, CHECK, STAR}, + spec::{all_apis, openapi_dir, OverwriteStatus}, +}; + +/// Manage OpenAPI specifications. +#[derive(Debug, Parser)] +pub struct App { + #[clap(flatten)] + output_opts: OutputOpts, + + #[clap(subcommand)] + command: Command, +} + +impl App { + pub fn exec(self) -> Result { + match self.command { + Command::Generate(args) => args.exec(&self.output_opts), + Command::Check(args) => args.exec(&self.output_opts), + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// Generate APIs. + Generate(GenerateArgs), + + /// Check that APIs are up-to-date. + Check(CheckArgs), +} + +#[derive(Debug, Parser)] +pub struct GenerateArgs { + /// The directory to write generated APIs to (default: /openapi) + #[clap(long)] + dir: Option, +} + +impl GenerateArgs { + fn exec(self, output: &OutputOpts) -> anyhow::Result { + let mut styles = Styles::default(); + if output.use_color(supports_color::Stream::Stderr) { + styles.colorize(); + } + + let dir = openapi_dir(self.dir)?; + let all_apis = all_apis(); + + eprintln!( + "{STAR} generating {} APIs...", + all_apis.len().style(styles.bold) + ); + let mut num_unchanged = 0; + + for api in &all_apis { + let status = api.overwrite(&dir)?; + + match status { + OverwriteStatus::Unchanged => { + eprintln!( + " {} {}: {}", + CHECK.style(styles.success), + api.filename, + "unchanged".style(styles.unchanged), + ); + num_unchanged += 1; + } + OverwriteStatus::Updated => { + eprintln!( + " {} {}: {}", + CHECK.style(styles.success), + api.filename, + "updated".style(styles.warning), + ); + } + } + } + + eprintln!( + "{} all {} APIs are {} ({} {})", + CHECK.style(styles.success), + all_apis.len().style(styles.bold), + "up-to-date".style(styles.success), + num_unchanged.style(styles.bold), + "unchanged".style(styles.unchanged), + ); + + Ok(ExitCode::SUCCESS) + } +} + +#[derive(Debug, Parser)] +pub struct CheckArgs { + /// The directory to read generated APIs from. + #[clap(long)] + dir: Option, +} + +impl CheckArgs { + fn exec(self, output: &OutputOpts) -> anyhow::Result { + let dir = openapi_dir(self.dir)?; + Ok(check_impl(&dir, output)?.to_exit_code()) + } +} diff --git a/apis/apigen/src/lib.rs b/apis/apigen/src/lib.rs new file mode 100644 index 0000000000..da51887d33 --- /dev/null +++ b/apis/apigen/src/lib.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/. + +mod check; +mod dispatch; +mod output; +mod spec; + +pub use dispatch::*; diff --git a/apis/apigen/src/main.rs b/apis/apigen/src/main.rs new file mode 100644 index 0000000000..2eb21bdbaa --- /dev/null +++ b/apis/apigen/src/main.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/. + +use std::process::ExitCode; + +use apigen::App; +use clap::Parser; + +fn main() -> ExitCode { + let app = App::parse(); + app.exec().unwrap() +} diff --git a/apis/apigen/src/output.rs b/apis/apigen/src/output.rs new file mode 100644 index 0000000000..196a4c8412 --- /dev/null +++ b/apis/apigen/src/output.rs @@ -0,0 +1,122 @@ +// 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::{fmt, io}; + +use camino::Utf8Path; +use clap::{Args, ColorChoice}; +use owo_colors::{OwoColorize, Style}; +use similar::{ChangeTag, DiffableStr, TextDiff}; + +#[derive(Debug, Args)] +#[clap(next_help_heading = "Global options")] +pub struct OutputOpts { + /// Color output + #[clap(long, value_enum, global = true, default_value_t)] + pub(crate) color: ColorChoice, +} + +impl OutputOpts { + /// Returns true if color should be used for the stream. + pub(crate) fn use_color(&self, stream: supports_color::Stream) -> bool { + match self.color { + ColorChoice::Auto => supports_color::on_cached(stream).is_some(), + ColorChoice::Always => true, + ColorChoice::Never => false, + } + } +} + +#[derive(Clone, Debug, Default)] +pub(crate) struct Styles { + pub(crate) bold: Style, + pub(crate) unchanged: Style, + pub(crate) success: Style, + pub(crate) failure: Style, + pub(crate) warning: Style, + pub(crate) diff_before: Style, + pub(crate) diff_after: Style, +} + +impl Styles { + pub(crate) fn colorize(&mut self) { + self.bold = Style::new().bold(); + self.unchanged = Style::new().blue(); + self.success = Style::new().green(); + self.failure = Style::new().red(); + self.warning = Style::new().yellow(); + self.diff_before = Style::new().red(); + self.diff_after = Style::new().green(); + } +} + +// This is copied from similar's UnifiedDiff::to_writer, except with colorized +// output. +pub(crate) fn write_diff<'diff, 'old, 'new, 'bufs>( + diff: &'diff TextDiff<'old, 'new, 'bufs, [u8]>, + full_path: &Utf8Path, + styles: &Styles, + out: &mut dyn io::Write, +) -> io::Result<()> +where + 'diff: 'old + 'new + 'bufs, +{ + // The "a/" (/ courtesy full_path) and "b/" make it feel more like git diff. + writeln!( + out, + "{}", + format!("--- a{}", full_path).style(styles.diff_before) + )?; + writeln!( + out, + "{}", + format!("+++ b/generated/{}", full_path.file_name().unwrap()) + .style(styles.diff_after) + )?; + + let udiff = diff.unified_diff(); + for hunk in udiff.iter_hunks() { + for (idx, change) in hunk.iter_changes().enumerate() { + if idx == 0 { + writeln!(out, "{}", hunk.header())?; + } + let style = match change.tag() { + ChangeTag::Delete => styles.diff_before, + ChangeTag::Insert => styles.diff_after, + ChangeTag::Equal => Style::new(), + }; + + write!(out, "{}", change.tag().style(style))?; + write!(out, "{}", change.value().to_string_lossy().style(style))?; + if !diff.newline_terminated() { + writeln!(out)?; + } + if diff.newline_terminated() && change.missing_newline() { + writeln!( + out, + "{}", + MissingNewlineHint(hunk.missing_newline_hint()) + )?; + } + } + } + + Ok(()) +} + +struct MissingNewlineHint(bool); + +impl fmt::Display for MissingNewlineHint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.0 { + write!(f, "\n\\ No newline at end of file")?; + } + Ok(()) + } +} + +pub(crate) const STAR: char = '★'; +pub(crate) const CHECK: char = '✓'; +pub(crate) const CROSS: char = '✗'; +pub(crate) const WARNING: char = '⚠'; diff --git a/apis/apigen/src/spec.rs b/apis/apigen/src/spec.rs new file mode 100644 index 0000000000..14a36e3819 --- /dev/null +++ b/apis/apigen/src/spec.rs @@ -0,0 +1,177 @@ +// 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::io::Write; + +use anyhow::{Context, Result}; +use atomicwrites::AtomicFile; +use camino::{Utf8Path, Utf8PathBuf}; +use dropshot::{ApiDescription, StubContext}; +use fs_err as fs; +use nexus_internal_api::NexusInternalApiFactory; +use openapiv3::OpenAPI; + +/// All APIs managed by apigen. +pub fn all_apis() -> Vec { + vec![ApiSpec { + title: "Nexus internal API".to_string(), + version: "0.0.1".to_string(), + description: "Nexus internal API".to_string(), + api_description: NexusInternalApiFactory::stub_api_description() + .expect("Nexus internal API generated successfully"), + filename: "nexus-internal.json".to_string(), + }] +} + +pub struct ApiSpec { + /// The title. + pub title: String, + + /// The version. + pub version: String, + + /// The description string. + pub description: String, + + /// The API description. + pub api_description: ApiDescription, + + /// The JSON filename to write the API description to. + pub filename: String, +} + +impl ApiSpec { + pub(crate) fn overwrite(&self, dir: &Utf8Path) -> Result { + let contents = self.to_json_bytes()?; + validate_json(&contents).with_context(|| { + format!("failed to validate JSON for `{}`", self.filename) + })?; + + let full_path = dir.join(&self.filename); + overwrite_file(&full_path, &contents) + } + + pub(crate) fn check(&self, dir: &Utf8Path) -> Result { + let contents = self.to_json_bytes()?; + validate_json(&contents).with_context(|| { + format!("failed to validate JSON for `{}`", self.filename) + })?; + + let full_path = dir.join(&self.filename); + let existing_contents = + read_opt(&full_path).context("failed to read contents on disk")?; + + match existing_contents { + Some(existing_contents) if existing_contents == contents => { + Ok(CheckStatus::Ok) + } + Some(existing_contents) => Ok(CheckStatus::Mismatch { + full_path, + actual: existing_contents, + expected: contents, + }), + None => Ok(CheckStatus::Missing { full_path }), + } + } + + pub(crate) fn to_json_bytes(&self) -> Result> { + let mut description = + self.api_description.openapi(&self.title, &self.version); + // Use write because it's the most reliable way to get the canonical + // JSON order. The `json` method returns a serde_json::Value which may + // or may not have preserve_order enabled. + let mut contents = Vec::new(); + description + .description(&self.description) + .contact_url("https://oxide.computer") + .contact_email("api@oxide.computer") + .write(&mut contents)?; + Ok(contents) + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[must_use] +pub(crate) enum OverwriteStatus { + Unchanged, + Updated, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +#[must_use] +pub(crate) enum CheckStatus { + Ok, + Mismatch { full_path: Utf8PathBuf, actual: Vec, expected: Vec }, + Missing { full_path: Utf8PathBuf }, +} + +pub(crate) fn openapi_dir(dir: Option) -> Result { + match dir { + Some(dir) => Ok(dir.canonicalize_utf8().with_context(|| { + format!("failed to canonicalize directory: {}", dir) + })?), + None => find_openapi_dir().context("failed to find openapi directory"), + } +} + +pub(crate) fn find_openapi_dir() -> Result { + let mut root = Utf8PathBuf::from(env!("CARGO_MANIFEST_DIR")); + // This is two levels down, so go up twice. + root.pop(); + root.pop(); + + root.push("openapi"); + let root = root.canonicalize_utf8().with_context(|| { + format!("failed to canonicalize openapi directory: {}", root) + })?; + + if !root.is_dir() { + anyhow::bail!("openapi root is not a directory: {}", root); + } + + Ok(root) +} + +fn validate_json(json: &[u8]) -> Result<()> { + let spec = serde_json::from_slice::(json) + .context("JSON returned by ApiDescription is not valid OpenAPI")?; + + // Check for lint errors. + let errors = openapi_lint::validate(&spec); + if !errors.is_empty() { + return Err(anyhow::anyhow!("{}", errors.join("\n\n"))); + } + + Ok(()) +} + +/// Overwrite a file with new contents, if the contents are different. +/// +/// The file is left unchanged if the contents are the same. That's to avoid +/// mtime-based recompilations. +fn overwrite_file(path: &Utf8Path, contents: &[u8]) -> Result { + let existing_contents = + read_opt(path).context("failed to read contents on disk")?; + + // None means the file doesn't exist, in which case we always want to write + // the new contents. + if existing_contents.as_deref() == Some(contents) { + return Ok(OverwriteStatus::Unchanged); + } + + AtomicFile::new(path, atomicwrites::OverwriteBehavior::AllowOverwrite) + .write(|f| f.write_all(contents)) + .with_context(|| format!("failed to write to `{}`", path))?; + + Ok(OverwriteStatus::Updated) +} + +fn read_opt(path: &Utf8Path) -> std::io::Result>> { + // Only overwrite the file if the contents are actually different. + match fs::read(path) { + Ok(contents) => Ok(Some(contents)), + Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(err) => return Err(err), + } +} diff --git a/apis/nexus-internal-api/Cargo.toml b/apis/nexus-internal-api/Cargo.toml new file mode 100644 index 0000000000..76fa6bd59a --- /dev/null +++ b/apis/nexus-internal-api/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "nexus-internal-api" +version = "0.1.0" +edition = "2021" +license = "MPL-2.0" + +[lints] +workspace = true + +[dependencies] +dropshot.workspace = true +nexus-types.workspace = true +omicron-common.workspace = true +omicron-uuid-kinds.workspace = true +omicron-workspace-hack.workspace = true +serde.workspace = true +schemars.workspace = true +uuid.workspace = true diff --git a/apis/nexus-internal-api/src/lib.rs b/apis/nexus-internal-api/src/lib.rs new file mode 100644 index 0000000000..40da347080 --- /dev/null +++ b/apis/nexus-internal-api/src/lib.rs @@ -0,0 +1,585 @@ +// 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::collections::{BTreeMap, BTreeSet}; + +use dropshot::{ + FreeformBody, HttpError, HttpResponseCreated, HttpResponseDeleted, + HttpResponseOk, HttpResponseUpdatedNoContent, Path, Query, RequestContext, + ResultsPage, TypedBody, +}; +use nexus_types::{ + deployment::{ + Blueprint, BlueprintMetadata, BlueprintTarget, BlueprintTargetSet, + }, + external_api::{ + params::{SledSelector, UninitializedSledId}, + shared::{ProbeInfo, UninitializedSled}, + views::SledPolicy, + }, + internal_api::{ + params::{ + OximeterInfo, RackInitializationRequest, SledAgentInfo, + SwitchPutRequest, SwitchPutResponse, + }, + views::{BackgroundTask, Ipv4NatEntryView, Saga}, + }, +}; +use omicron_common::{ + api::{ + external::http_pagination::PaginatedById, + internal::nexus::{ + DiskRuntimeState, DownstairsClientStopRequest, + DownstairsClientStopped, ProducerEndpoint, + ProducerRegistrationResponse, RepairFinishInfo, RepairProgress, + RepairStartInfo, SledInstanceState, + }, + }, + update::ArtifactId, +}; +use omicron_uuid_kinds::{ + DownstairsKind, SledUuid, TypedUuid, UpstairsKind, UpstairsRepairKind, +}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[dropshot::server] +pub trait NexusInternalApi { + type Context; + + /// Return information about the given sled agent + #[endpoint { + method = GET, + path = "/sled-agents/{sled_id}", + }] + async fn sled_agent_get( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + + /// Report that the sled agent for the specified sled has come online. + #[endpoint { + method = POST, + path = "/sled-agents/{sled_id}", + }] + async fn sled_agent_put( + rqctx: RequestContext, + path_params: Path, + sled_info: TypedBody, + ) -> Result; + + /// Request a new set of firewall rules for a sled. + /// + /// This causes Nexus to read the latest set of rules for the sled, + /// and call a Sled endpoint which applies the rules to all OPTE ports + /// that happen to exist. + #[endpoint { + method = POST, + path = "/sled-agents/{sled_id}/firewall-rules-update", + }] + async fn sled_firewall_rules_request( + rqctx: RequestContext, + path_params: Path, + ) -> Result; + + /// Report that the Rack Setup Service initialization is complete + /// + /// See RFD 278 for more details. + #[endpoint { + method = PUT, + path = "/racks/{rack_id}/initialization-complete", + }] + async fn rack_initialization_complete( + rqctx: RequestContext, + path_params: Path, + info: TypedBody, + ) -> Result; + + #[endpoint { + method = PUT, + path = "/switch/{switch_id}", + }] + async fn switch_put( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, + ) -> Result, HttpError>; + + /// Report updated state for an instance. + #[endpoint { + method = PUT, + path = "/instances/{instance_id}", + }] + async fn cpapi_instances_put( + rqctx: RequestContext, + path_params: Path, + new_runtime_state: TypedBody, + ) -> Result; + + /// Report updated state for a disk. + #[endpoint { + method = PUT, + path = "/disks/{disk_id}", + }] + async fn cpapi_disks_put( + rqctx: RequestContext, + path_params: Path, + new_runtime_state: TypedBody, + ) -> Result; + + /// Request removal of a read_only_parent from a volume + /// A volume can be created with the source data for that volume being another + /// volume that attached as a "read_only_parent". In the background there + /// exists a scrubber that will copy the data from the read_only_parent + /// into the volume. When that scrubber has completed copying the data, this + /// endpoint can be called to update the database that the read_only_parent + /// is no longer needed for a volume and future attachments of this volume + /// should not include that read_only_parent. + #[endpoint { + method = POST, + path = "/volume/{volume_id}/remove-read-only-parent", + }] + async fn cpapi_volume_remove_read_only_parent( + rqctx: RequestContext, + path_params: Path, + ) -> Result; + + /// Request removal of a read_only_parent from a disk + /// This is a thin wrapper around the volume_remove_read_only_parent saga. + /// All we are doing here is, given a disk UUID, figure out what the + /// volume_id is for that disk, then use that to call the + /// volume_remove_read_only_parent saga on it. + #[endpoint { + method = POST, + path = "/disk/{disk_id}/remove-read-only-parent", + }] + async fn cpapi_disk_remove_read_only_parent( + rqctx: RequestContext, + path_params: Path, + ) -> Result; + + /// Accept a registration from a new metric producer + #[endpoint { + method = POST, + path = "/metrics/producers", + }] + async fn cpapi_producers_post( + request_context: RequestContext, + producer_info: TypedBody, + ) -> Result, HttpError>; + + /// List all metric producers assigned to an oximeter collector. + #[endpoint { + method = GET, + path = "/metrics/collectors/{collector_id}/producers", + }] + async fn cpapi_assigned_producers_list( + request_context: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; + + /// Accept a notification of a new oximeter collection server. + #[endpoint { + method = POST, + path = "/metrics/collectors", + }] + async fn cpapi_collectors_post( + request_context: RequestContext, + oximeter_info: TypedBody, + ) -> Result; + + /// Endpoint used by Sled Agents to download cached artifacts. + #[endpoint { + method = GET, + path = "/artifacts/{kind}/{name}/{version}", + }] + async fn cpapi_artifact_download( + request_context: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + + /// An Upstairs will notify this endpoint when a repair starts + #[endpoint { + method = POST, + path = "/crucible/0/upstairs/{upstairs_id}/repair-start", + }] + async fn cpapi_upstairs_repair_start( + rqctx: RequestContext, + path_params: Path, + repair_start_info: TypedBody, + ) -> Result; + + /// An Upstairs will notify this endpoint when a repair finishes. + #[endpoint { + method = POST, + path = "/crucible/0/upstairs/{upstairs_id}/repair-finish", + }] + async fn cpapi_upstairs_repair_finish( + rqctx: RequestContext, + path_params: Path, + repair_finish_info: TypedBody, + ) -> Result; + + /// An Upstairs will update this endpoint with the progress of a repair + #[endpoint { + method = POST, + path = "/crucible/0/upstairs/{upstairs_id}/repair/{repair_id}/progress", + }] + async fn cpapi_upstairs_repair_progress( + rqctx: RequestContext, + path_params: Path, + repair_progress: TypedBody, + ) -> Result; + + /// An Upstairs will update this endpoint if a Downstairs client task is + /// requested to stop + #[endpoint { + method = POST, + path = "/crucible/0/upstairs/{upstairs_id}/downstairs/{downstairs_id}/stop-request", + }] + async fn cpapi_downstairs_client_stop_request( + rqctx: RequestContext, + path_params: Path, + downstairs_client_stop_request: TypedBody, + ) -> Result; + + /// An Upstairs will update this endpoint if a Downstairs client task stops for + /// any reason (not just after being requested to) + #[endpoint { + method = POST, + path = "/crucible/0/upstairs/{upstairs_id}/downstairs/{downstairs_id}/stopped", + }] + async fn cpapi_downstairs_client_stopped( + rqctx: RequestContext, + path_params: Path, + downstairs_client_stopped: TypedBody, + ) -> Result; + + // Sagas + + /// List sagas + #[endpoint { + method = GET, + path = "/sagas", + }] + async fn saga_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError>; + + /// Fetch a saga + #[endpoint { + method = GET, + path = "/sagas/{saga_id}", + }] + async fn saga_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + + // Background Tasks + + /// List background tasks + /// + /// This is a list of discrete background activities that Nexus carries out. + /// This is exposed for support and debugging. + #[endpoint { + method = GET, + path = "/bgtasks", + }] + async fn bgtask_list( + rqctx: RequestContext, + ) -> Result>, HttpError>; + + /// Fetch status of one background task + /// + /// This is exposed for support and debugging. + #[endpoint { + method = GET, + path = "/bgtasks/view/{bgtask_name}", + }] + async fn bgtask_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + + /// Activates one or more background tasks, causing them to be run immediately + /// if idle, or scheduled to run again as soon as possible if already running. + #[endpoint { + method = POST, + path = "/bgtasks/activate", + }] + async fn bgtask_activate( + rqctx: RequestContext, + body: TypedBody, + ) -> Result; + + // NAT RPW internal APIs + + /// Fetch NAT ChangeSet + /// + /// Caller provides their generation as `from_gen`, along with a query + /// parameter for the page size (`limit`). Endpoint will return changes + /// that have occured since the caller's generation number up to the latest + /// change or until the `limit` is reached. If there are no changes, an + /// empty vec is returned. +#[endpoint { + method = GET, + path = "/nat/ipv4/changeset/{from_gen}" + }] + async fn ipv4_nat_changeset( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; + + // APIs for managing blueprints + // + // These are not (yet) intended for use by any other programs. Eventually, we + // will want this functionality part of the public API. But we don't want to + // commit to any of this yet. These properly belong in an RFD 399-style + // "Service and Support API". Absent that, we stick them here. + + /// Lists blueprints + #[endpoint { + method = GET, + path = "/deployment/blueprints/all", + }] + async fn blueprint_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError>; + + /// Fetches one blueprint + #[endpoint { + method = GET, + path = "/deployment/blueprints/all/{blueprint_id}", + }] + async fn blueprint_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + + /// Deletes one blueprint + #[endpoint { + method = DELETE, + path = "/deployment/blueprints/all/{blueprint_id}", + }] + async fn blueprint_delete( + rqctx: RequestContext, + path_params: Path, + ) -> Result; + + // Managing the current target blueprint + + /// Fetches the current target blueprint, if any + #[endpoint { + method = GET, + path = "/deployment/blueprints/target", + }] + async fn blueprint_target_view( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Make the specified blueprint the new target + #[endpoint { + method = POST, + path = "/deployment/blueprints/target", + }] + async fn blueprint_target_set( + rqctx: RequestContext, + target: TypedBody, + ) -> Result, HttpError>; + + /// Set the `enabled` field of the current target blueprint + #[endpoint { + method = PUT, + path = "/deployment/blueprints/target/enabled", + }] + async fn blueprint_target_set_enabled( + rqctx: RequestContext, + target: TypedBody, + ) -> Result, HttpError>; + + // Generating blueprints + + /// Generates a new blueprint for the current system, re-evaluating anything + /// that's changed since the last one was generated + #[endpoint { + method = POST, + path = "/deployment/blueprints/regenerate", + }] + async fn blueprint_regenerate( + rqctx: RequestContext, + ) -> Result, HttpError>; + + /// Imports a client-provided blueprint + /// + /// This is intended for development and support, not end users or operators. + #[endpoint { + method = POST, + path = "/deployment/blueprints/import", + }] + async fn blueprint_import( + rqctx: RequestContext, + blueprint: TypedBody, + ) -> Result; + + /// List uninitialized sleds + #[endpoint { + method = GET, + path = "/sleds/uninitialized", + }] + async fn sled_list_uninitialized( + rqctx: RequestContext, + ) -> Result>, HttpError>; + + /// Add sled to initialized rack + // + // TODO: In the future this should really be a PUT request, once we resolve + // https://github.com/oxidecomputer/omicron/issues/4494. It should also + // explicitly be tied to a rack via a `rack_id` path param. For now we assume + // we are only operating on single rack systems. + #[endpoint { + method = POST, + path = "/sleds/add", + }] + async fn sled_add( + rqctx: RequestContext, + sled: TypedBody, + ) -> Result, HttpError>; + + /// Mark a sled as expunged + /// + /// This is an irreversible process! It should only be called after + /// sufficient warning to the operator. + /// + /// This is idempotent, and it returns the old policy of the sled. + #[endpoint { + method = POST, + path = "/sleds/expunge", + }] + async fn sled_expunge( + rqctx: RequestContext, + sled: TypedBody, + ) -> Result, HttpError>; + + /// Get all the probes associated with a given sled. + #[endpoint { + method = GET, + path = "/probes/{sled}" + }] + async fn probes_get( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError>; +} + +/// Path parameters for Sled Agent requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct SledAgentPathParam { + pub sled_id: Uuid, +} + +/// Path parameters for Disk requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct DiskPathParam { + pub disk_id: Uuid, +} + +/// Path parameters for Volume requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct VolumePathParam { + pub volume_id: Uuid, +} + +/// Path parameters for Rack requests. +#[derive(Deserialize, JsonSchema)] +pub struct RackPathParam { + pub rack_id: Uuid, +} + +/// Path parameters for Switch requests. +#[derive(Deserialize, JsonSchema)] +pub struct SwitchPathParam { + pub switch_id: Uuid, +} + +/// Path parameters for Instance requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct InstancePathParam { + pub instance_id: Uuid, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub struct CollectorIdPathParams { + /// The ID of the oximeter collector. + pub collector_id: Uuid, +} + +/// Path parameters for Upstairs requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct UpstairsPathParam { + pub upstairs_id: TypedUuid, +} + +/// Path parameters for Upstairs requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct UpstairsRepairPathParam { + pub upstairs_id: TypedUuid, + pub repair_id: TypedUuid, +} + +/// Path parameters for Downstairs requests (internal API) +#[derive(Deserialize, JsonSchema)] +pub struct UpstairsDownstairsPathParam { + pub upstairs_id: TypedUuid, + pub downstairs_id: TypedUuid, +} + +/// Path parameters for Saga requests +#[derive(Deserialize, JsonSchema)] +pub struct SagaPathParam { + pub saga_id: Uuid, +} + +/// Path parameters for Background Task requests +#[derive(Deserialize, JsonSchema)] +pub struct BackgroundTaskPathParam { + pub bgtask_name: String, +} + +/// Query parameters for Background Task activation requests. +#[derive(Deserialize, JsonSchema)] +pub struct BackgroundTasksActivateRequest { + pub bgtask_names: BTreeSet, +} + +/// Path parameters for NAT ChangeSet +#[derive(Deserialize, JsonSchema)] +pub struct RpwNatPathParam { + /// which change number to start generating + /// the change set from + pub from_gen: i64, +} + +/// Query parameters for NAT ChangeSet +#[derive(Deserialize, JsonSchema)] +pub struct RpwNatQueryParam { + pub limit: u32, +} + +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub struct SledId { + pub id: SledUuid, +} + +/// Path parameters for probes +#[derive(Deserialize, JsonSchema)] +pub struct ProbePathParam { + pub sled: Uuid, +} diff --git a/dev-tools/xtask/src/main.rs b/dev-tools/xtask/src/main.rs index 9f1131e758..b1b554ace2 100644 --- a/dev-tools/xtask/src/main.rs +++ b/dev-tools/xtask/src/main.rs @@ -39,6 +39,9 @@ enum Cmds { /// Run configured clippy checks Clippy(clippy::ClippyArgs), + /// Manage OpenAPI specifications. + Apigen(external::External), + #[cfg(target_os = "illumos")] /// Build a TUF repo Releng(external::External), @@ -66,6 +69,7 @@ fn main() -> Result<()> { match args.cmd { Cmds::Clippy(args) => clippy::run_cmd(args), Cmds::CheckWorkspaceDeps => check_workspace_deps::run_cmd(), + Cmds::Apigen(external) => external.exec("apigen"), #[cfg(target_os = "illumos")] Cmds::Releng(external) => { diff --git a/dns-server/src/bin/apigen.rs b/dns-server/src/bin/dns-apigen.rs similarity index 100% rename from dns-server/src/bin/apigen.rs rename to dns-server/src/bin/dns-apigen.rs diff --git a/dns-server/tests/openapi_test.rs b/dns-server/tests/openapi_test.rs index 490680eda4..19fd4e8dca 100644 --- a/dns-server/tests/openapi_test.rs +++ b/dns-server/tests/openapi_test.rs @@ -10,7 +10,7 @@ use omicron_test_utils::dev::test_cmds::EXIT_SUCCESS; use openapiv3::OpenAPI; use subprocess::Exec; -const CMD_API_GEN: &str = env!("CARGO_BIN_EXE_apigen"); +const CMD_API_GEN: &str = env!("CARGO_BIN_EXE_dns-apigen"); #[test] fn test_dns_server_openapi() { diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index 0b0bd097bc..8eb1a4d8e8 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -44,6 +44,7 @@ macaddr.workspace = true # integration tests. nexus-client.workspace = true nexus-config.workspace = true +nexus-internal-api.workspace = true nexus-networking.workspace = true nexus-test-interface.workspace = true num-integer.workspace = true diff --git a/nexus/db-model/src/external_ip.rs b/nexus/db-model/src/external_ip.rs index 2a68b4d7d0..8226f8293e 100644 --- a/nexus/db-model/src/external_ip.rs +++ b/nexus/db-model/src/external_ip.rs @@ -22,6 +22,8 @@ use nexus_types::deployment::OmicronZoneExternalIp; use nexus_types::deployment::OmicronZoneExternalSnatIp; use nexus_types::external_api::params; use nexus_types::external_api::shared; +use nexus_types::external_api::shared::ProbeExternalIp; +use nexus_types::external_api::shared::ProbeExternalIpKind; use nexus_types::external_api::views; use nexus_types::inventory::SourceNatConfig; use omicron_common::api::external::Error; @@ -191,6 +193,27 @@ impl TryFrom<&'_ ExternalIp> for OmicronZoneExternalIp { } } +impl From for ProbeExternalIp { + fn from(value: ExternalIp) -> Self { + Self { + ip: value.ip.ip(), + first_port: value.first_port.0, + last_port: value.last_port.0, + kind: value.kind.into(), + } + } +} + +impl From for ProbeExternalIpKind { + fn from(value: IpKind) -> Self { + match value { + IpKind::SNat => ProbeExternalIpKind::Snat, + IpKind::Ephemeral => ProbeExternalIpKind::Ephemeral, + IpKind::Floating => ProbeExternalIpKind::Floating, + } + } +} + /// A view type constructed from `ExternalIp` used to represent Floating IP /// objects in user-facing APIs. /// diff --git a/nexus/db-model/src/ipv4_nat_entry.rs b/nexus/db-model/src/ipv4_nat_entry.rs index 4ff1ee9171..c60c37a0bf 100644 --- a/nexus/db-model/src/ipv4_nat_entry.rs +++ b/nexus/db-model/src/ipv4_nat_entry.rs @@ -1,13 +1,10 @@ -use std::net::{Ipv4Addr, Ipv6Addr}; - use super::MacAddr; use crate::{ schema::ipv4_nat_changes, schema::ipv4_nat_entry, Ipv4Net, Ipv6Net, SqlU16, Vni, }; use chrono::{DateTime, Utc}; -use omicron_common::api::external; -use schemars::JsonSchema; +use nexus_types::internal_api::views::Ipv4NatEntryView; use serde::Deserialize; use serde::Serialize; use uuid::Uuid; @@ -65,19 +62,6 @@ pub struct Ipv4NatChange { pub deleted: bool, } -/// NAT Record -#[derive(Clone, Debug, Serialize, JsonSchema)] -pub struct Ipv4NatEntryView { - pub external_address: Ipv4Addr, - pub first_port: u16, - pub last_port: u16, - pub sled_address: Ipv6Addr, - pub vni: external::Vni, - pub mac: external::MacAddr, - pub gen: i64, - pub deleted: bool, -} - impl From for Ipv4NatEntryView { fn from(value: Ipv4NatChange) -> Self { Self { diff --git a/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs b/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs index 5b370f27a9..0a514f55dc 100644 --- a/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs +++ b/nexus/db-queries/src/db/datastore/ipv4_nat_entry.rs @@ -10,7 +10,7 @@ use diesel::prelude::*; use diesel::sql_types::BigInt; use nexus_db_model::ExternalIp; use nexus_db_model::Ipv4NatChange; -use nexus_db_model::Ipv4NatEntryView; +use nexus_types::internal_api::views::Ipv4NatEntryView; use omicron_common::api::external::CreateResult; use omicron_common::api::external::DeleteResult; use omicron_common::api::external::Error; diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index b90f81affb..af6a9cb7a8 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -109,7 +109,6 @@ pub use dns::DnsVersionUpdateBuilder; pub use instance::InstanceAndActiveVmm; pub use inventory::DataStoreInventoryTest; use nexus_db_model::AllSchemaVersions; -pub use probe::ProbeInfo; pub use rack::RackInit; pub use rack::SledUnderlayAllocationResult; pub use silo::Discoverability; diff --git a/nexus/db-queries/src/db/datastore/probe.rs b/nexus/db-queries/src/db/datastore/probe.rs index a96f857163..c45e825525 100644 --- a/nexus/db-queries/src/db/datastore/probe.rs +++ b/nexus/db-queries/src/db/datastore/probe.rs @@ -1,5 +1,3 @@ -use std::net::IpAddr; - use crate::authz; use crate::context::OpContext; use crate::db; @@ -15,6 +13,7 @@ use diesel::{ExpressionMethods, QueryDsl, SelectableHelper}; use nexus_db_model::IncompleteNetworkInterface; use nexus_db_model::Probe; use nexus_db_model::VpcSubnet; +use nexus_types::external_api::shared::ProbeInfo; use nexus_types::identity::Resource; use omicron_common::api::external::http_pagination::PaginatedBy; use omicron_common::api::external::CreateResult; @@ -32,34 +31,6 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] -pub struct ProbeInfo { - pub id: Uuid, - pub name: Name, - sled: Uuid, - pub external_ips: Vec, - pub interface: NetworkInterface, -} - -#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] -pub struct ProbeExternalIp { - ip: IpAddr, - first_port: u16, - last_port: u16, - kind: IpKind, -} - -impl From for ProbeExternalIp { - fn from(value: nexus_db_model::ExternalIp) -> Self { - Self { - ip: value.ip.ip(), - first_port: value.first_port.0, - last_port: value.last_port.0, - kind: value.kind.into(), - } - } -} - #[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum IpKind { diff --git a/nexus/src/app/probe.rs b/nexus/src/app/probe.rs index 41ea4eece2..67673d8e00 100644 --- a/nexus/src/app/probe.rs +++ b/nexus/src/app/probe.rs @@ -1,9 +1,9 @@ use nexus_db_model::Probe; use nexus_db_queries::authz; use nexus_db_queries::context::OpContext; -use nexus_db_queries::db::datastore::ProbeInfo; use nexus_db_queries::db::lookup; use nexus_types::external_api::params; +use nexus_types::external_api::shared::ProbeInfo; use nexus_types::identity::Resource; use omicron_common::api::external::Error; use omicron_common::api::external::{ diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 350836441e..35a75d296d 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -34,13 +34,13 @@ use dropshot::{ use dropshot::{ApiDescription, StreamingBody}; use dropshot::{ApiEndpoint, EmptyScanParams}; use ipnetwork::IpNetwork; +use nexus_db_queries::authz; use nexus_db_queries::db; use nexus_db_queries::db::identity::Resource; use nexus_db_queries::db::lookup::ImageLookup; use nexus_db_queries::db::lookup::ImageParentLookup; use nexus_db_queries::db::model::Name; -use nexus_db_queries::{authz, db::datastore::ProbeInfo}; -use nexus_types::external_api::shared::BfdStatus; +use nexus_types::external_api::shared::{BfdStatus, ProbeInfo}; use omicron_common::api::external::http_pagination::marker_for_name; use omicron_common::api::external::http_pagination::marker_for_name_or_id; use omicron_common::api::external::http_pagination::name_or_id_pagination; diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index ceafe7f103..95284e64ae 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -6,7 +6,6 @@ use super::params::{OximeterInfo, RackInitializationRequest}; use crate::context::ApiContext; -use dropshot::endpoint; use dropshot::ApiDescription; use dropshot::FreeformBody; use dropshot::HttpError; @@ -20,14 +19,14 @@ use dropshot::RequestContext; use dropshot::ResultsPage; use dropshot::TypedBody; use hyper::Body; -use nexus_db_model::Ipv4NatEntryView; -use nexus_db_queries::db::datastore::ProbeInfo; +use nexus_internal_api::*; use nexus_types::deployment::Blueprint; use nexus_types::deployment::BlueprintMetadata; use nexus_types::deployment::BlueprintTarget; use nexus_types::deployment::BlueprintTargetSet; use nexus_types::external_api::params::SledSelector; use nexus_types::external_api::params::UninitializedSledId; +use nexus_types::external_api::shared::ProbeInfo; use nexus_types::external_api::shared::UninitializedSled; use nexus_types::external_api::views::SledPolicy; use nexus_types::internal_api::params::SledAgentInfo; @@ -35,6 +34,7 @@ use nexus_types::internal_api::params::SwitchPutRequest; use nexus_types::internal_api::params::SwitchPutResponse; use nexus_types::internal_api::views::to_list; use nexus_types::internal_api::views::BackgroundTask; +use nexus_types::internal_api::views::Ipv4NatEntryView; use nexus_types::internal_api::views::Saga; use omicron_common::api::external::http_pagination::data_page_params_for; use omicron_common::api::external::http_pagination::PaginatedById; @@ -50,1059 +50,799 @@ use omicron_common::api::internal::nexus::RepairProgress; use omicron_common::api::internal::nexus::RepairStartInfo; use omicron_common::api::internal::nexus::SledInstanceState; use omicron_common::update::ArtifactId; -use omicron_uuid_kinds::DownstairsKind; -use omicron_uuid_kinds::SledUuid; -use omicron_uuid_kinds::TypedUuid; -use omicron_uuid_kinds::UpstairsKind; -use omicron_uuid_kinds::UpstairsRepairKind; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; use std::collections::BTreeMap; -use std::collections::BTreeSet; -use uuid::Uuid; type NexusApiDescription = ApiDescription; /// Returns a description of the internal nexus API pub(crate) fn internal_api() -> NexusApiDescription { - fn register_endpoints(api: &mut NexusApiDescription) -> Result<(), String> { - api.register(sled_agent_get)?; - api.register(sled_agent_put)?; - api.register(sled_firewall_rules_request)?; - api.register(switch_put)?; - api.register(rack_initialization_complete)?; - api.register(cpapi_instances_put)?; - api.register(cpapi_disks_put)?; - api.register(cpapi_volume_remove_read_only_parent)?; - api.register(cpapi_disk_remove_read_only_parent)?; - api.register(cpapi_producers_post)?; - api.register(cpapi_assigned_producers_list)?; - api.register(cpapi_collectors_post)?; - api.register(cpapi_artifact_download)?; - - api.register(cpapi_upstairs_repair_start)?; - api.register(cpapi_upstairs_repair_finish)?; - api.register(cpapi_upstairs_repair_progress)?; - api.register(cpapi_downstairs_client_stop_request)?; - api.register(cpapi_downstairs_client_stopped)?; - - api.register(saga_list)?; - api.register(saga_view)?; - - api.register(ipv4_nat_changeset)?; - - api.register(bgtask_list)?; - api.register(bgtask_view)?; - api.register(bgtask_activate)?; - - api.register(blueprint_list)?; - api.register(blueprint_view)?; - api.register(blueprint_delete)?; - api.register(blueprint_target_view)?; - api.register(blueprint_target_set)?; - api.register(blueprint_target_set_enabled)?; - api.register(blueprint_regenerate)?; - api.register(blueprint_import)?; + NexusInternalApiFactory::api_description::() + .expect("registered API endpoints successfully") +} - api.register(sled_list_uninitialized)?; - api.register(sled_add)?; - api.register(sled_expunge)?; +enum NexusInternalApiImpl {} - api.register(probes_get)?; +impl NexusInternalApi for NexusInternalApiImpl { + type Context = ApiContext; - Ok(()) + async fn sled_agent_get( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + let path = path_params.into_inner(); + let sled_id = &path.sled_id; + let handler = async { + let (.., sled) = + nexus.sled_lookup(&opctx, sled_id)?.fetch().await?; + Ok(HttpResponseOk(sled.into())) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await } - let mut api = NexusApiDescription::new(); - if let Err(err) = register_endpoints(&mut api) { - panic!("failed to register entrypoints: {}", err); + async fn sled_agent_put( + rqctx: RequestContext, + path_params: Path, + sled_info: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + let path = path_params.into_inner(); + let info = sled_info.into_inner(); + let sled_id = &path.sled_id; + let handler = async { + nexus.upsert_sled(&opctx, *sled_id, info).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await } - api -} - -/// Path parameters for Sled Agent requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct SledAgentPathParam { - sled_id: Uuid, -} - -/// Return information about the given sled agent -#[endpoint { - method = GET, - path = "/sled-agents/{sled_id}", - }] -async fn sled_agent_get( - rqctx: RequestContext, - path_params: Path, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let path = path_params.into_inner(); - let sled_id = &path.sled_id; - let handler = async { - let (.., sled) = nexus.sled_lookup(&opctx, sled_id)?.fetch().await?; - Ok(HttpResponseOk(sled.into())) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Report that the sled agent for the specified sled has come online. -#[endpoint { - method = POST, - path = "/sled-agents/{sled_id}", - }] -async fn sled_agent_put( - rqctx: RequestContext, - path_params: Path, - sled_info: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let path = path_params.into_inner(); - let info = sled_info.into_inner(); - let sled_id = &path.sled_id; - let handler = async { - nexus.upsert_sled(&opctx, *sled_id, info).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Request a new set of firewall rules for a sled. -/// -/// This causes Nexus to read the latest set of rules for the sled, -/// and call a Sled endpoint which applies the rules to all OPTE ports -/// that happen to exist. -#[endpoint { - method = POST, - path = "/sled-agents/{sled_id}/firewall-rules-update", - }] -async fn sled_firewall_rules_request( - rqctx: RequestContext, - path_params: Path, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let path = path_params.into_inner(); - let sled_id = &path.sled_id; - let handler = async { - nexus.sled_request_firewall_rules(&opctx, *sled_id).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Path parameters for Rack requests. -#[derive(Deserialize, JsonSchema)] -struct RackPathParam { - rack_id: Uuid, -} - -/// Report that the Rack Setup Service initialization is complete -/// -/// See RFD 278 for more details. -#[endpoint { - method = PUT, - path = "/racks/{rack_id}/initialization-complete", - }] -async fn rack_initialization_complete( - rqctx: RequestContext, - path_params: Path, - info: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let request = info.into_inner(); - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - - nexus.rack_initialize(&opctx, path.rack_id, request).await?; - - Ok(HttpResponseUpdatedNoContent()) -} -/// Path parameters for Switch requests. -#[derive(Deserialize, JsonSchema)] -struct SwitchPathParam { - switch_id: Uuid, -} + async fn sled_firewall_rules_request( + rqctx: RequestContext, + path_params: Path, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + let path = path_params.into_inner(); + let sled_id = &path.sled_id; + let handler = async { + nexus.sled_request_firewall_rules(&opctx, *sled_id).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -#[endpoint { - method = PUT, - path = "/switch/{switch_id}", -}] -async fn switch_put( - rqctx: RequestContext, - path_params: Path, - body: TypedBody, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { + async fn rack_initialization_complete( + rqctx: RequestContext, + path_params: Path, + info: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; let path = path_params.into_inner(); - let switch = body.into_inner(); - nexus.switch_upsert(path.switch_id, switch).await?; - Ok(HttpResponseOk(SwitchPutResponse {})) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + let request = info.into_inner(); + let opctx = crate::context::op_context_for_internal_api(&rqctx).await; -/// Path parameters for Instance requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct InstancePathParam { - instance_id: Uuid, -} + nexus.rack_initialize(&opctx, path.rack_id, request).await?; -/// Report updated state for an instance. -#[endpoint { - method = PUT, - path = "/instances/{instance_id}", - }] -async fn cpapi_instances_put( - rqctx: RequestContext, - path_params: Path, - new_runtime_state: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let new_state = new_runtime_state.into_inner(); - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let handler = async { - nexus - .notify_instance_updated(&opctx, &path.instance_id, &new_state) - .await?; Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + } -/// Path parameters for Disk requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct DiskPathParam { - disk_id: Uuid, -} + async fn switch_put( + rqctx: RequestContext, + path_params: Path, + body: TypedBody, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let switch = body.into_inner(); + nexus.switch_upsert(path.switch_id, switch).await?; + Ok(HttpResponseOk(SwitchPutResponse {})) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Report updated state for a disk. -#[endpoint { - method = PUT, - path = "/disks/{disk_id}", - }] -async fn cpapi_disks_put( - rqctx: RequestContext, - path_params: Path, - new_runtime_state: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let new_state = new_runtime_state.into_inner(); - let handler = async { + async fn cpapi_instances_put( + rqctx: RequestContext, + path_params: Path, + new_runtime_state: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let new_state = new_runtime_state.into_inner(); let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus.notify_disk_updated(&opctx, path.disk_id, &new_state).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Path parameters for Volume requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct VolumePathParam { - volume_id: Uuid, -} + let handler = async { + nexus + .notify_instance_updated(&opctx, &path.instance_id, &new_state) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Request removal of a read_only_parent from a volume -/// A volume can be created with the source data for that volume being another -/// volume that attached as a "read_only_parent". In the background there -/// exists a scrubber that will copy the data from the read_only_parent -/// into the volume. When that scrubber has completed copying the data, this -/// endpoint can be called to update the database that the read_only_parent -/// is no longer needed for a volume and future attachments of this volume -/// should not include that read_only_parent. -#[endpoint { - method = POST, - path = "/volume/{volume_id}/remove-read-only-parent", - }] -async fn cpapi_volume_remove_read_only_parent( - rqctx: RequestContext, - path_params: Path, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); + async fn cpapi_disks_put( + rqctx: RequestContext, + path_params: Path, + new_runtime_state: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let new_state = new_runtime_state.into_inner(); + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus.notify_disk_updated(&opctx, path.disk_id, &new_state).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus.volume_remove_read_only_parent(&opctx, path.volume_id).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn cpapi_volume_remove_read_only_parent( + rqctx: RequestContext, + path_params: Path, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); -/// Request removal of a read_only_parent from a disk -/// This is a thin wrapper around the volume_remove_read_only_parent saga. -/// All we are doing here is, given a disk UUID, figure out what the -/// volume_id is for that disk, then use that to call the -/// volume_remove_read_only_parent saga on it. -#[endpoint { - method = POST, - path = "/disk/{disk_id}/remove-read-only-parent", - }] -async fn cpapi_disk_remove_read_only_parent( - rqctx: RequestContext, - path_params: Path, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .volume_remove_read_only_parent(&opctx, path.volume_id) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus.disk_remove_read_only_parent(&opctx, path.disk_id).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn cpapi_disk_remove_read_only_parent( + rqctx: RequestContext, + path_params: Path, + ) -> Result { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); -/// Accept a registration from a new metric producer -#[endpoint { - method = POST, - path = "/metrics/producers", - }] -async fn cpapi_producers_post( - request_context: RequestContext, - producer_info: TypedBody, -) -> Result, HttpError> { - let context = &request_context.context().context; - let handler = async { - let nexus = &context.nexus; - let producer_info = producer_info.into_inner(); - let opctx = - crate::context::op_context_for_internal_api(&request_context).await; - nexus - .assign_producer(&opctx, producer_info) + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus.disk_remove_read_only_parent(&opctx, path.disk_id).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) .await - .map_err(HttpError::from) - .map(|_| { - HttpResponseCreated(ProducerRegistrationResponse { - lease_duration: - crate::app::oximeter::PRODUCER_LEASE_DURATION, + } + + async fn cpapi_producers_post( + request_context: RequestContext, + producer_info: TypedBody, + ) -> Result, HttpError> + { + let context = &request_context.context().context; + let handler = async { + let nexus = &context.nexus; + let producer_info = producer_info.into_inner(); + let opctx = + crate::context::op_context_for_internal_api(&request_context) + .await; + nexus + .assign_producer(&opctx, producer_info) + .await + .map_err(HttpError::from) + .map(|_| { + HttpResponseCreated(ProducerRegistrationResponse { + lease_duration: + crate::app::oximeter::PRODUCER_LEASE_DURATION, + }) }) - }) - }; - context - .internal_latencies - .instrument_dropshot_handler(&request_context, handler) - .await -} + }; + context + .internal_latencies + .instrument_dropshot_handler(&request_context, handler) + .await + } -#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] -pub struct CollectorIdPathParams { - /// The ID of the oximeter collector. - pub collector_id: Uuid, -} + async fn cpapi_assigned_producers_list( + request_context: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let context = &request_context.context().context; + let handler = async { + let nexus = &context.nexus; + let collector_id = path_params.into_inner().collector_id; + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&request_context, &query)?; + let opctx = + crate::context::op_context_for_internal_api(&request_context) + .await; + let producers = nexus + .list_assigned_producers(&opctx, collector_id, &pagparams) + .await?; + Ok(HttpResponseOk(ScanById::results_page( + &query, + producers, + &|_, producer: &ProducerEndpoint| producer.id, + )?)) + }; + context + .internal_latencies + .instrument_dropshot_handler(&request_context, handler) + .await + } -/// List all metric producers assigned to an oximeter collector. -#[endpoint { - method = GET, - path = "/metrics/collectors/{collector_id}/producers", - }] -async fn cpapi_assigned_producers_list( - request_context: RequestContext, - path_params: Path, - query_params: Query, -) -> Result>, HttpError> { - let context = &request_context.context().context; - let handler = async { - let nexus = &context.nexus; - let collector_id = path_params.into_inner().collector_id; - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&request_context, &query)?; - let opctx = - crate::context::op_context_for_internal_api(&request_context).await; - let producers = nexus - .list_assigned_producers(&opctx, collector_id, &pagparams) - .await?; - Ok(HttpResponseOk(ScanById::results_page( - &query, - producers, - &|_, producer: &ProducerEndpoint| producer.id, - )?)) - }; - context - .internal_latencies - .instrument_dropshot_handler(&request_context, handler) - .await -} + async fn cpapi_collectors_post( + request_context: RequestContext, + oximeter_info: TypedBody, + ) -> Result { + let context = &request_context.context().context; + let handler = async { + let nexus = &context.nexus; + let oximeter_info = oximeter_info.into_inner(); + let opctx = + crate::context::op_context_for_internal_api(&request_context) + .await; + nexus.upsert_oximeter_collector(&opctx, &oximeter_info).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + context + .internal_latencies + .instrument_dropshot_handler(&request_context, handler) + .await + } -/// Accept a notification of a new oximeter collection server. -#[endpoint { - method = POST, - path = "/metrics/collectors", - }] -async fn cpapi_collectors_post( - request_context: RequestContext, - oximeter_info: TypedBody, -) -> Result { - let context = &request_context.context().context; - let handler = async { + async fn cpapi_artifact_download( + request_context: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let context = &request_context.context().context; let nexus = &context.nexus; - let oximeter_info = oximeter_info.into_inner(); let opctx = crate::context::op_context_for_internal_api(&request_context).await; - nexus.upsert_oximeter_collector(&opctx, &oximeter_info).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - context - .internal_latencies - .instrument_dropshot_handler(&request_context, handler) - .await -} - -/// Endpoint used by Sled Agents to download cached artifacts. -#[endpoint { - method = GET, - path = "/artifacts/{kind}/{name}/{version}", -}] -async fn cpapi_artifact_download( - request_context: RequestContext, - path_params: Path, -) -> Result, HttpError> { - let context = &request_context.context().context; - let nexus = &context.nexus; - let opctx = - crate::context::op_context_for_internal_api(&request_context).await; - // TODO: return 404 if the error we get here says that the record isn't found - let body = nexus - .updates_download_artifact(&opctx, path_params.into_inner()) - .await?; - - Ok(HttpResponseOk(Body::from(body).into())) -} - -/// Path parameters for Upstairs requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct UpstairsPathParam { - upstairs_id: TypedUuid, -} - -/// An Upstairs will notify this endpoint when a repair starts -#[endpoint { - method = POST, - path = "/crucible/0/upstairs/{upstairs_id}/repair-start", - }] -async fn cpapi_upstairs_repair_start( - rqctx: RequestContext, - path_params: Path, - repair_start_info: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus - .upstairs_repair_start( - &opctx, - path.upstairs_id, - repair_start_info.into_inner(), - ) - .await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// An Upstairs will notify this endpoint when a repair finishes. -#[endpoint { - method = POST, - path = "/crucible/0/upstairs/{upstairs_id}/repair-finish", - }] -async fn cpapi_upstairs_repair_finish( - rqctx: RequestContext, - path_params: Path, - repair_finish_info: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus - .upstairs_repair_finish( - &opctx, - path.upstairs_id, - repair_finish_info.into_inner(), - ) - .await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Path parameters for Upstairs requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct UpstairsRepairPathParam { - upstairs_id: TypedUuid, - repair_id: TypedUuid, -} - -/// An Upstairs will update this endpoint with the progress of a repair -#[endpoint { - method = POST, - path = "/crucible/0/upstairs/{upstairs_id}/repair/{repair_id}/progress", - }] -async fn cpapi_upstairs_repair_progress( - rqctx: RequestContext, - path_params: Path, - repair_progress: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus - .upstairs_repair_progress( - &opctx, - path.upstairs_id, - path.repair_id, - repair_progress.into_inner(), - ) + // TODO: return 404 if the error we get here says that the record isn't found + let body = nexus + .updates_download_artifact(&opctx, path_params.into_inner()) .await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} -/// Path parameters for Downstairs requests (internal API) -#[derive(Deserialize, JsonSchema)] -struct UpstairsDownstairsPathParam { - upstairs_id: TypedUuid, - downstairs_id: TypedUuid, -} - -/// An Upstairs will update this endpoint if a Downstairs client task is -/// requested to stop -#[endpoint { - method = POST, - path = "/crucible/0/upstairs/{upstairs_id}/downstairs/{downstairs_id}/stop-request", - }] -async fn cpapi_downstairs_client_stop_request( - rqctx: RequestContext, - path_params: Path, - downstairs_client_stop_request: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus - .downstairs_client_stop_request_notification( - &opctx, - path.upstairs_id, - path.downstairs_id, - downstairs_client_stop_request.into_inner(), - ) - .await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// An Upstairs will update this endpoint if a Downstairs client task stops for -/// any reason (not just after being requested to) -#[endpoint { - method = POST, - path = "/crucible/0/upstairs/{upstairs_id}/downstairs/{downstairs_id}/stopped", - }] -async fn cpapi_downstairs_client_stopped( - rqctx: RequestContext, - path_params: Path, - downstairs_client_stopped: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - nexus - .downstairs_client_stopped_notification( - &opctx, - path.upstairs_id, - path.downstairs_id, - downstairs_client_stopped.into_inner(), - ) - .await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -// Sagas + Ok(HttpResponseOk(Body::from(body).into())) + } -/// List sagas -#[endpoint { - method = GET, - path = "/sagas", -}] -async fn saga_list( - rqctx: RequestContext, - query_params: Query, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { + async fn cpapi_upstairs_repair_start( + rqctx: RequestContext, + path_params: Path, + repair_start_info: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let pagparams = data_page_params_for(&rqctx, &query)?; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let saga_stream = nexus.sagas_list(&opctx, &pagparams).await?; - let view_list = to_list(saga_stream).await; - Ok(HttpResponseOk(ScanById::results_page( - &query, - view_list, - &|_, saga: &Saga| saga.id, - )?)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + let path = path_params.into_inner(); -/// Path parameters for Saga requests -#[derive(Deserialize, JsonSchema)] -struct SagaPathParam { - saga_id: Uuid, -} + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .upstairs_repair_start( + &opctx, + path.upstairs_id, + repair_start_info.into_inner(), + ) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Fetch a saga -#[endpoint { - method = GET, - path = "/sagas/{saga_id}", -}] -async fn saga_view( - rqctx: RequestContext, - path_params: Path, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + async fn cpapi_upstairs_repair_finish( + rqctx: RequestContext, + path_params: Path, + repair_finish_info: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; let path = path_params.into_inner(); - let saga = nexus.saga_get(&opctx, path.saga_id).await?; - Ok(HttpResponseOk(saga)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} -// Background Tasks + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .upstairs_repair_finish( + &opctx, + path.upstairs_id, + repair_finish_info.into_inner(), + ) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// List background tasks -/// -/// This is a list of discrete background activities that Nexus carries out. -/// This is exposed for support and debugging. -#[endpoint { - method = GET, - path = "/bgtasks", -}] -async fn bgtask_list( - rqctx: RequestContext, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { + async fn cpapi_upstairs_repair_progress( + rqctx: RequestContext, + path_params: Path, + repair_progress: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let bgtask_list = nexus.bgtasks_list(&opctx).await?; - Ok(HttpResponseOk(bgtask_list)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Path parameters for Background Task requests -#[derive(Deserialize, JsonSchema)] -struct BackgroundTaskPathParam { - bgtask_name: String, -} + let path = path_params.into_inner(); -/// Query parameters for Background Task activation requests. -#[derive(Deserialize, JsonSchema)] -struct BackgroundTasksActivateRequest { - bgtask_names: BTreeSet, -} + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .upstairs_repair_progress( + &opctx, + path.upstairs_id, + path.repair_id, + repair_progress.into_inner(), + ) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Fetch status of one background task -/// -/// This is exposed for support and debugging. -#[endpoint { - method = GET, - path = "/bgtasks/view/{bgtask_name}", -}] -async fn bgtask_view( - rqctx: RequestContext, - path_params: Path, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + async fn cpapi_downstairs_client_stop_request( + rqctx: RequestContext, + path_params: Path, + downstairs_client_stop_request: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; let path = path_params.into_inner(); - let bgtask = nexus.bgtask_status(&opctx, &path.bgtask_name).await?; - Ok(HttpResponseOk(bgtask)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -/// Activates one or more background tasks, causing them to be run immediately -/// if idle, or scheduled to run again as soon as possible if already running. -#[endpoint { - method = POST, - path = "/bgtasks/activate", -}] -async fn bgtask_activate( - rqctx: RequestContext, - body: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let body = body.into_inner(); - nexus.bgtask_activate(&opctx, body.bgtask_names).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} - -// NAT RPW internal APIs -/// Path parameters for NAT ChangeSet -#[derive(Deserialize, JsonSchema)] -struct RpwNatPathParam { - /// which change number to start generating - /// the change set from - from_gen: i64, -} - -/// Query parameters for NAT ChangeSet -#[derive(Deserialize, JsonSchema)] -struct RpwNatQueryParam { - limit: u32, -} + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .downstairs_client_stop_request_notification( + &opctx, + path.upstairs_id, + path.downstairs_id, + downstairs_client_stop_request.into_inner(), + ) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Fetch NAT ChangeSet -/// -/// Caller provides their generation as `from_gen`, along with a query -/// parameter for the page size (`limit`). Endpoint will return changes -/// that have occured since the caller's generation number up to the latest -/// change or until the `limit` is reached. If there are no changes, an -/// empty vec is returned. -#[endpoint { - method = GET, - path = "/nat/ipv4/changeset/{from_gen}" -}] -async fn ipv4_nat_changeset( - rqctx: RequestContext, - path_params: Path, - query_params: Query, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; + async fn cpapi_downstairs_client_stopped( + rqctx: RequestContext, + path_params: Path, + downstairs_client_stopped: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; let path = path_params.into_inner(); - let query = query_params.into_inner(); - let mut changeset = nexus - .datastore() - .ipv4_nat_changeset(&opctx, path.from_gen, query.limit) - .await?; - changeset.sort_by_key(|e| e.gen); - Ok(HttpResponseOk(changeset)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} -// APIs for managing blueprints -// -// These are not (yet) intended for use by any other programs. Eventually, we -// will want this functionality part of the public API. But we don't want to -// commit to any of this yet. These properly belong in an RFD 399-style -// "Service and Support API". Absent that, we stick them here. + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + nexus + .downstairs_client_stopped_notification( + &opctx, + path.upstairs_id, + path.downstairs_id, + downstairs_client_stopped.into_inner(), + ) + .await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Lists blueprints -#[endpoint { - method = GET, - path = "/deployment/blueprints/all", -}] -async fn blueprint_list( - rqctx: RequestContext, - query_params: Query, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let nexus = &apictx.nexus; - let query = query_params.into_inner(); - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let pagparams = data_page_params_for(&rqctx, &query)?; - let blueprints = nexus.blueprint_list(&opctx, &pagparams).await?; - Ok(HttpResponseOk(ScanById::results_page( - &query, - blueprints, - &|_, blueprint: &BlueprintMetadata| blueprint.id, - )?)) - }; + // Sagas + + async fn saga_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let pagparams = data_page_params_for(&rqctx, &query)?; + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let saga_stream = nexus.sagas_list(&opctx, &pagparams).await?; + let view_list = to_list(saga_stream).await; + Ok(HttpResponseOk(ScanById::results_page( + &query, + view_list, + &|_, saga: &Saga| saga.id, + )?)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn saga_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let saga = nexus.saga_get(&opctx, path.saga_id).await?; + Ok(HttpResponseOk(saga)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Fetches one blueprint -#[endpoint { - method = GET, - path = "/deployment/blueprints/all/{blueprint_id}", -}] -async fn blueprint_view( - rqctx: RequestContext, - path_params: Path, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - let blueprint = nexus.blueprint_view(&opctx, path.blueprint_id).await?; - Ok(HttpResponseOk(blueprint)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + // Background Tasks + + async fn bgtask_list( + rqctx: RequestContext, + ) -> Result>, HttpError> + { + let apictx = &rqctx.context().context; + let handler = async { + let nexus = &apictx.nexus; + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let bgtask_list = nexus.bgtasks_list(&opctx).await?; + Ok(HttpResponseOk(bgtask_list)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Deletes one blueprint -#[endpoint { - method = DELETE, - path = "/deployment/blueprints/all/{blueprint_id}", -}] -async fn blueprint_delete( - rqctx: RequestContext, - path_params: Path, -) -> Result { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let path = path_params.into_inner(); - nexus.blueprint_delete(&opctx, path.blueprint_id).await?; - Ok(HttpResponseDeleted()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn bgtask_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let bgtask = nexus.bgtask_status(&opctx, &path.bgtask_name).await?; + Ok(HttpResponseOk(bgtask)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -// Managing the current target blueprint + async fn bgtask_activate( + rqctx: RequestContext, + body: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let body = body.into_inner(); + nexus.bgtask_activate(&opctx, body.bgtask_names).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Fetches the current target blueprint, if any -#[endpoint { - method = GET, - path = "/deployment/blueprints/target", -}] -async fn blueprint_target_view( - rqctx: RequestContext, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let target = nexus.blueprint_target_view(&opctx).await?; - Ok(HttpResponseOk(target)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + // NAT RPW internal APIs + + async fn ipv4_nat_changeset( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let query = query_params.into_inner(); + let mut changeset = nexus + .datastore() + .ipv4_nat_changeset(&opctx, path.from_gen, query.limit) + .await?; + changeset.sort_by_key(|e| e.gen); + Ok(HttpResponseOk(changeset)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Make the specified blueprint the new target -#[endpoint { - method = POST, - path = "/deployment/blueprints/target", -}] -async fn blueprint_target_set( - rqctx: RequestContext, - target: TypedBody, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let target = target.into_inner(); - let target = nexus.blueprint_target_set(&opctx, target).await?; - Ok(HttpResponseOk(target)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + // APIs for managing blueprints + async fn blueprint_list( + rqctx: RequestContext, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let nexus = &apictx.nexus; + let query = query_params.into_inner(); + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let pagparams = data_page_params_for(&rqctx, &query)?; + let blueprints = nexus.blueprint_list(&opctx, &pagparams).await?; + Ok(HttpResponseOk(ScanById::results_page( + &query, + blueprints, + &|_, blueprint: &BlueprintMetadata| blueprint.id, + )?)) + }; + + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Set the `enabled` field of the current target blueprint -#[endpoint { - method = PUT, - path = "/deployment/blueprints/target/enabled", -}] -async fn blueprint_target_set_enabled( - rqctx: RequestContext, - target: TypedBody, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let target = target.into_inner(); - let target = nexus.blueprint_target_set_enabled(&opctx, target).await?; - Ok(HttpResponseOk(target)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + /// Fetches one blueprint + async fn blueprint_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + let blueprint = + nexus.blueprint_view(&opctx, path.blueprint_id).await?; + Ok(HttpResponseOk(blueprint)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -// Generating blueprints + /// Deletes one blueprint + async fn blueprint_delete( + rqctx: RequestContext, + path_params: Path, + ) -> Result { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let path = path_params.into_inner(); + nexus.blueprint_delete(&opctx, path.blueprint_id).await?; + Ok(HttpResponseDeleted()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Generates a new blueprint for the current system, re-evaluating anything -/// that's changed since the last one was generated -#[endpoint { - method = POST, - path = "/deployment/blueprints/regenerate", -}] -async fn blueprint_regenerate( - rqctx: RequestContext, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let result = nexus.blueprint_create_regenerate(&opctx).await?; - Ok(HttpResponseOk(result)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn blueprint_target_view( + rqctx: RequestContext, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let target = nexus.blueprint_target_view(&opctx).await?; + Ok(HttpResponseOk(target)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Imports a client-provided blueprint -/// -/// This is intended for development and support, not end users or operators. -#[endpoint { - method = POST, - path = "/deployment/blueprints/import", -}] -async fn blueprint_import( - rqctx: RequestContext, - blueprint: TypedBody, -) -> Result { - let apictx = &rqctx.context().context; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let nexus = &apictx.nexus; - let blueprint = blueprint.into_inner(); - nexus.blueprint_import(&opctx, blueprint).await?; - Ok(HttpResponseUpdatedNoContent()) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn blueprint_target_set( + rqctx: RequestContext, + target: TypedBody, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let target = target.into_inner(); + let target = nexus.blueprint_target_set(&opctx, target).await?; + Ok(HttpResponseOk(target)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// List uninitialized sleds -#[endpoint { - method = GET, - path = "/sleds/uninitialized", -}] -async fn sled_list_uninitialized( - rqctx: RequestContext, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let sleds = nexus.sled_list_uninitialized(&opctx).await?; - Ok(HttpResponseOk(ResultsPage { items: sleds, next_page: None })) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn blueprint_target_set_enabled( + rqctx: RequestContext, + target: TypedBody, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let target = target.into_inner(); + let target = + nexus.blueprint_target_set_enabled(&opctx, target).await?; + Ok(HttpResponseOk(target)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -#[derive(Clone, Debug, Serialize, JsonSchema)] -pub struct SledId { - pub id: SledUuid, -} + async fn blueprint_regenerate( + rqctx: RequestContext, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let result = nexus.blueprint_create_regenerate(&opctx).await?; + Ok(HttpResponseOk(result)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Add sled to initialized rack -// -// TODO: In the future this should really be a PUT request, once we resolve -// https://github.com/oxidecomputer/omicron/issues/4494. It should also -// explicitly be tied to a rack via a `rack_id` path param. For now we assume -// we are only operating on single rack systems. -#[endpoint { - method = POST, - path = "/sleds/add", -}] -async fn sled_add( - rqctx: RequestContext, - sled: TypedBody, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let id = nexus.sled_add(&opctx, sled.into_inner()).await?; - Ok(HttpResponseCreated(SledId { id })) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn blueprint_import( + rqctx: RequestContext, + blueprint: TypedBody, + ) -> Result { + let apictx = &rqctx.context().context; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let nexus = &apictx.nexus; + let blueprint = blueprint.into_inner(); + nexus.blueprint_import(&opctx, blueprint).await?; + Ok(HttpResponseUpdatedNoContent()) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Mark a sled as expunged -/// -/// This is an irreversible process! It should only be called after -/// sufficient warning to the operator. -/// -/// This is idempotent, and it returns the old policy of the sled. -#[endpoint { - method = POST, - path = "/sleds/expunge", -}] -async fn sled_expunge( - rqctx: RequestContext, - sled: TypedBody, -) -> Result, HttpError> { - let apictx = &rqctx.context().context; - let nexus = &apictx.nexus; - let handler = async { - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let previous_policy = - nexus.sled_expunge(&opctx, sled.into_inner().sled).await?; - Ok(HttpResponseOk(previous_policy)) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await -} + async fn sled_list_uninitialized( + rqctx: RequestContext, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let nexus = &apictx.nexus; + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let sleds = nexus.sled_list_uninitialized(&opctx).await?; + Ok(HttpResponseOk(ResultsPage { items: sleds, next_page: None })) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Path parameters for probes -#[derive(Deserialize, JsonSchema)] -struct ProbePathParam { - sled: Uuid, -} + async fn sled_add( + rqctx: RequestContext, + sled: TypedBody, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; + let nexus = &apictx.nexus; + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let id = nexus.sled_add(&opctx, sled.into_inner()).await?; + Ok(HttpResponseCreated(SledId { id })) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } -/// Get all the probes associated with a given sled. -#[endpoint { - method = GET, - path = "/probes/{sled}" -}] -async fn probes_get( - rqctx: RequestContext, - path_params: Path, - query_params: Query, -) -> Result>, HttpError> { - let apictx = &rqctx.context().context; - let handler = async { - let query = query_params.into_inner(); - let path = path_params.into_inner(); + async fn sled_expunge( + rqctx: RequestContext, + sled: TypedBody, + ) -> Result, HttpError> { + let apictx = &rqctx.context().context; let nexus = &apictx.nexus; - let opctx = crate::context::op_context_for_internal_api(&rqctx).await; - let pagparams = data_page_params_for(&rqctx, &query)?; - Ok(HttpResponseOk( - nexus.probe_list_for_sled(&opctx, &pagparams, path.sled).await?, - )) - }; - apictx.internal_latencies.instrument_dropshot_handler(&rqctx, handler).await + let handler = async { + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let previous_policy = + nexus.sled_expunge(&opctx, sled.into_inner().sled).await?; + Ok(HttpResponseOk(previous_policy)) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn probes_get( + rqctx: RequestContext, + path_params: Path, + query_params: Query, + ) -> Result>, HttpError> { + let apictx = &rqctx.context().context; + let handler = async { + let query = query_params.into_inner(); + let path = path_params.into_inner(); + let nexus = &apictx.nexus; + let opctx = + crate::context::op_context_for_internal_api(&rqctx).await; + let pagparams = data_page_params_for(&rqctx, &query)?; + Ok(HttpResponseOk( + nexus + .probe_list_for_sled(&opctx, &pagparams, path.sled) + .await?, + )) + }; + apictx + .internal_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } } diff --git a/nexus/tests/integration_tests/commands.rs b/nexus/tests/integration_tests/commands.rs index 2eaf24d907..64441a5f25 100644 --- a/nexus/tests/integration_tests/commands.rs +++ b/nexus/tests/integration_tests/commands.rs @@ -180,19 +180,3 @@ fn test_nexus_openapi() { // renaming, or changing the tags are what you intend. assert_contents("tests/output/nexus_tags.txt", &tags); } - -#[test] -fn test_nexus_openapi_internal() { - let (stdout_text, _) = run_command_with_arg("--openapi-internal"); - let spec: OpenAPI = serde_json::from_str(&stdout_text) - .expect("stdout was not valid OpenAPI"); - - // Check for lint errors. - let errors = openapi_lint::validate(&spec); - assert!(errors.is_empty(), "{}", errors.join("\n\n")); - - // Confirm that the output hasn't changed. It's expected that we'll change - // this file as the API evolves, but pay attention to the diffs to ensure - // that the changes match your expectations. - assert_contents("../openapi/nexus-internal.json", &stdout_text); -} diff --git a/nexus/tests/integration_tests/probe.rs b/nexus/tests/integration_tests/probe.rs index 71a695bf8c..53ad6a3ef9 100644 --- a/nexus/tests/integration_tests/probe.rs +++ b/nexus/tests/integration_tests/probe.rs @@ -1,13 +1,12 @@ use dropshot::HttpErrorResponseBody; use http::{Method, StatusCode}; -use nexus_db_queries::db::datastore::ProbeInfo; use nexus_test_utils::{ http_testing::{AuthnMode, NexusRequest}, resource_helpers::{create_default_ip_pool, create_project}, SLED_AGENT_UUID, }; use nexus_test_utils_macros::nexus_test; -use nexus_types::external_api::params::ProbeCreate; +use nexus_types::external_api::{params::ProbeCreate, shared::ProbeInfo}; use omicron_common::api::external::{IdentityMetadataCreateParams, Probe}; type ControlPlaneTestContext = diff --git a/nexus/types/src/external_api/shared.rs b/nexus/types/src/external_api/shared.rs index 96843ba6a4..32d8765a54 100644 --- a/nexus/types/src/external_api/shared.rs +++ b/nexus/types/src/external_api/shared.rs @@ -7,6 +7,7 @@ use std::net::IpAddr; use omicron_common::api::external::Name; +use omicron_common::api::internal::shared::NetworkInterface; use parse_display::FromStr; use schemars::JsonSchema; use serde::de::Error as _; @@ -412,3 +413,28 @@ mod test { ); } } + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +pub struct ProbeInfo { + pub id: Uuid, + pub name: Name, + pub sled: Uuid, + pub external_ips: Vec, + pub interface: NetworkInterface, +} + +#[derive(Debug, Clone, JsonSchema, Serialize, Deserialize)] +pub struct ProbeExternalIp { + pub ip: IpAddr, + pub first_port: u16, + pub last_port: u16, + pub kind: ProbeExternalIpKind, +} + +#[derive(Debug, Clone, Copy, JsonSchema, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum ProbeExternalIpKind { + Snat, + Floating, + Ephemeral, +} diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index 143ca1be8b..d0afb996ac 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -221,7 +221,7 @@ pub struct RackInitializationRequest { /// Blueprint describing services initialized by RSS. pub blueprint: Blueprint, - /// "Managed" physical disks owned by the control plane +/// "Managed" physical disks owned by the control plane pub physical_disks: Vec, /// Zpools created within the physical disks created by the control plane. diff --git a/nexus/types/src/internal_api/views.rs b/nexus/types/src/internal_api/views.rs index fde2d07072..b71fd04779 100644 --- a/nexus/types/src/internal_api/views.rs +++ b/nexus/types/src/internal_api/views.rs @@ -6,9 +6,13 @@ use chrono::DateTime; use chrono::Utc; use futures::future::ready; use futures::stream::StreamExt; +use omicron_common::api::external::MacAddr; use omicron_common::api::external::ObjectStream; +use omicron_common::api::external::Vni; use schemars::JsonSchema; use serde::Serialize; +use std::net::Ipv4Addr; +use std::net::Ipv6Addr; use std::time::Duration; use std::time::Instant; use steno::SagaResultErr; @@ -296,3 +300,16 @@ pub struct LastResultCompleted { /// arbitrary datum emitted by the background task pub details: serde_json::Value, } + +/// NAT Record +#[derive(Clone, Debug, Serialize, JsonSchema)] +pub struct Ipv4NatEntryView { + pub external_address: Ipv4Addr, + pub first_port: u16, + pub last_port: u16, + pub sled_address: Ipv6Addr, + pub vni: Vni, + pub mac: MacAddr, + pub gen: i64, + pub deleted: bool, +} diff --git a/openapi/nexus-internal.json b/openapi/nexus-internal.json index 828378eaba..319617b2fa 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -1567,6 +1567,7 @@ "checker": { "nullable": true, "description": "Checker to apply to incoming messages.", + "default": null, "type": "string" }, "originate": { @@ -1579,6 +1580,7 @@ "shaper": { "nullable": true, "description": "Shaper to apply to outgoing messages.", + "default": null, "type": "string" } }, @@ -1676,6 +1678,7 @@ "local_pref": { "nullable": true, "description": "Apply a local preference to routes received from this peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1683,11 +1686,13 @@ "md5_auth_key": { "nullable": true, "description": "Use the given key for TCP-MD5 authentication with the peer.", + "default": null, "type": "string" }, "min_ttl": { "nullable": true, "description": "Require messages from a peer have a minimum IP time to live field.", + "default": null, "type": "integer", "format": "uint8", "minimum": 0 @@ -1695,6 +1700,7 @@ "multi_exit_discriminator": { "nullable": true, "description": "Apply the provided multi-exit discriminator (MED) updates sent to the peer.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1706,6 +1712,7 @@ "remote_asn": { "nullable": true, "description": "Require that a peer has a specified ASN.", + "default": null, "type": "integer", "format": "uint32", "minimum": 0 @@ -1713,6 +1720,7 @@ "vlan_id": { "nullable": true, "description": "Associate a VLAN ID with a BGP peer session.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 @@ -3254,14 +3262,6 @@ } ] }, - "IpKind": { - "type": "string", - "enum": [ - "snat", - "floating", - "ephemeral" - ] - }, "IpNet": { "x-rust-type": { "crate": "oxnet", @@ -3904,7 +3904,7 @@ "format": "ip" }, "kind": { - "$ref": "#/components/schemas/IpKind" + "$ref": "#/components/schemas/ProbeExternalIpKind" }, "last_port": { "type": "integer", @@ -3919,6 +3919,14 @@ "last_port" ] }, + "ProbeExternalIpKind": { + "type": "string", + "enum": [ + "snat", + "floating", + "ephemeral" + ] + }, "ProbeInfo": { "type": "object", "properties": { @@ -4332,6 +4340,7 @@ "vlan_id": { "nullable": true, "description": "The VLAN id associated with this route.", + "default": null, "type": "integer", "format": "uint16", "minimum": 0 diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index ee4dcccb70..3784c81408 100644 --- a/workspace-hack/Cargo.toml +++ b/workspace-hack/Cargo.toml @@ -25,7 +25,8 @@ bit-set = { version = "0.5.3" } bit-vec = { version = "0.6.3" } bitflags-dff4ba8e3ae991db = { package = "bitflags", version = "1.3.2" } bitflags-f595c2ba2a3f28df = { package = "bitflags", version = "2.5.0", default-features = false, features = ["serde", "std"] } -bstr = { version = "1.9.1" } +bstr-6f8ce4dd05d13bba = { package = "bstr", version = "0.2.17" } +bstr-dff4ba8e3ae991db = { package = "bstr", version = "1.9.1" } byteorder = { version = "1.5.0" } bytes = { version = "1.6.0", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] } @@ -89,13 +90,13 @@ regex-automata = { version = "0.4.6", default-features = false, features = ["dfa regex-syntax = { version = "0.8.3" } reqwest = { version = "0.11.27", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } ring = { version = "0.17.8", features = ["std"] } -schemars = { version = "0.8.20", features = ["bytes", "chrono", "uuid1"] } +schemars = { version = "0.8.21", features = ["bytes", "chrono", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.203", features = ["alloc", "derive", "rc"] } serde_json = { version = "1.0.117", features = ["raw_value", "unbounded_depth"] } sha2 = { version = "0.10.8", features = ["oid"] } -similar = { version = "2.5.0", features = ["inline", "unicode"] } +similar = { version = "2.5.0", features = ["bytes", "inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } smallvec = { version = "1.13.2", default-features = false, features = ["const_new"] } spin = { version = "0.9.8" } @@ -130,7 +131,8 @@ bit-set = { version = "0.5.3" } bit-vec = { version = "0.6.3" } bitflags-dff4ba8e3ae991db = { package = "bitflags", version = "1.3.2" } bitflags-f595c2ba2a3f28df = { package = "bitflags", version = "2.5.0", default-features = false, features = ["serde", "std"] } -bstr = { version = "1.9.1" } +bstr-6f8ce4dd05d13bba = { package = "bstr", version = "0.2.17" } +bstr-dff4ba8e3ae991db = { package = "bstr", version = "1.9.1" } byteorder = { version = "1.5.0" } bytes = { version = "1.6.0", features = ["serde"] } chrono = { version = "0.4.38", features = ["serde"] } @@ -194,13 +196,13 @@ regex-automata = { version = "0.4.6", default-features = false, features = ["dfa regex-syntax = { version = "0.8.3" } reqwest = { version = "0.11.27", features = ["blocking", "cookies", "json", "rustls-tls", "stream"] } ring = { version = "0.17.8", features = ["std"] } -schemars = { version = "0.8.20", features = ["bytes", "chrono", "uuid1"] } +schemars = { version = "0.8.21", features = ["bytes", "chrono", "uuid1"] } scopeguard = { version = "1.2.0" } semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.203", features = ["alloc", "derive", "rc"] } serde_json = { version = "1.0.117", features = ["raw_value", "unbounded_depth"] } sha2 = { version = "0.10.8", features = ["oid"] } -similar = { version = "2.5.0", features = ["inline", "unicode"] } +similar = { version = "2.5.0", features = ["bytes", "inline", "unicode"] } slog = { version = "2.7.0", features = ["dynamic-keys", "max_level_trace", "release_max_level_debug", "release_max_level_trace"] } smallvec = { version = "1.13.2", default-features = false, features = ["const_new"] } spin = { version = "0.9.8" } From ff47a839af0e08d1a2d5700d2864d6079f797c29 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 3 Jul 2024 22:04:28 -0700 Subject: [PATCH 2/8] instructions Created using spr 1.3.6-beta.1 --- dev-tools/openapi-manager/README.adoc | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/dev-tools/openapi-manager/README.adoc b/dev-tools/openapi-manager/README.adoc index fdef0b9a72..0a6e80515c 100644 --- a/dev-tools/openapi-manager/README.adoc +++ b/dev-tools/openapi-manager/README.adoc @@ -47,7 +47,7 @@ In the implementation crate: . Add a dependency on the API crate. . Following the example in https://rfd.shared.oxide.computer/rfd/0479#guide_api_implementation[RFD 479's _API implementation_], provide an implementation of the trait. -Once the API crate is defined, follow the instructions in <> below. +Once the API crate is defined, perform the steps in <> below. === Converting existing documents @@ -56,9 +56,16 @@ Existing, unmanaged documents are generated via *function-based servers*: a set The first step is to convert the function-based server into an API trait. To do so, create an API crate (see <> above). . Add the API crate to the workspace's `Cargo.toml`: `members` and `default-members`, and a reference in `[workspace.dependencies]`. -. Follow the instructions in https://rfd.shared.oxide.computer/rfd/0479#guide_converting_functions_to_traits[RFD 479's _Converting functions to API traits_] for the API crate, as well as for the implementation crate where the endpoint functions are currently defined. +. Follow the instructions in https://rfd.shared.oxide.computer/rfd/0479#guide_converting_functions_to_traits[RFD 479's _Converting functions to API traits_] for the API crate. -Finally, follow the instructions in <> below. +In the implementation crate: + +. Continue following the instructions in https://rfd.shared.oxide.computer/rfd/0479#guide_converting_functions_to_traits[RFD 479's _Converting functions to API traits_] for where the endpoint functions are currently defined. +. Find the test which currently manages the document (try searching the repo for `openapi_lint::validate`). If it performs any checks on the document beyond `openapi_lint::validate` or `openapi_lint::validate_external`, see <>. + +Next, perform the steps in <> below. + +Finally, remove the test which used to manage the document. === Adding the API crate to the manager [[add_to_manager]] @@ -72,6 +79,16 @@ To ensure everything works well, run `cargo xtask openapi generate`. * Your OpenAPI document should be generated on disk and listed in the output. * If you're converting an existing API, the only changes should be the ones you might have introduced as part of the refactor. If there are significant changes, something's gone wrong--maybe you missed an endpoint? +==== Performing extra validation [[extra_validation]] + +By default, the OpenAPI manager does basic validation on the generated document. Some documents require extra validation steps. + +It's best to put extra validation next to the trait, within the API crate. + +. In the API crate, add dependencies on `anyhow` and `openapiv3`. +. Define a function with signature `fn extra_validation(openapi: &openapiv3::OpenAPI) -> anyhow::Result<()>` which performs the extra validation steps. +. In `all_apis`, set the `extra_validation` field to this function. + == Design notes The OpenAPI manager uses the new support for Dropshot API traits described in https://rfd.shared.oxide.computer/rfd/0479[RFD 479]. From 7150c4c04cb3d7c600c50998405f5b566d710a18 Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 3 Jul 2024 22:06:00 -0700 Subject: [PATCH 3/8] fix rustfmt Created using spr 1.3.6-beta.1 --- nexus/types/src/internal_api/params.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nexus/types/src/internal_api/params.rs b/nexus/types/src/internal_api/params.rs index d0afb996ac..143ca1be8b 100644 --- a/nexus/types/src/internal_api/params.rs +++ b/nexus/types/src/internal_api/params.rs @@ -221,7 +221,7 @@ pub struct RackInitializationRequest { /// Blueprint describing services initialized by RSS. pub blueprint: Blueprint, -/// "Managed" physical disks owned by the control plane + /// "Managed" physical disks owned by the control plane pub physical_disks: Vec, /// Zpools created within the physical disks created by the control plane. From feca537551d6ed68326118b7e3b656951e40ce3c Mon Sep 17 00:00:00 2001 From: Rain Date: Wed, 3 Jul 2024 22:10:55 -0700 Subject: [PATCH 4/8] some more fixes Created using spr 1.3.6-beta.1 --- dns-server/src/bin/{dns-apigen.rs => apigen.rs} | 0 dns-server/tests/openapi_test.rs | 2 +- installinator/Cargo.toml | 1 - 3 files changed, 1 insertion(+), 2 deletions(-) rename dns-server/src/bin/{dns-apigen.rs => apigen.rs} (100%) diff --git a/dns-server/src/bin/dns-apigen.rs b/dns-server/src/bin/apigen.rs similarity index 100% rename from dns-server/src/bin/dns-apigen.rs rename to dns-server/src/bin/apigen.rs diff --git a/dns-server/tests/openapi_test.rs b/dns-server/tests/openapi_test.rs index 19fd4e8dca..490680eda4 100644 --- a/dns-server/tests/openapi_test.rs +++ b/dns-server/tests/openapi_test.rs @@ -10,7 +10,7 @@ use omicron_test_utils::dev::test_cmds::EXIT_SUCCESS; use openapiv3::OpenAPI; use subprocess::Exec; -const CMD_API_GEN: &str = env!("CARGO_BIN_EXE_dns-apigen"); +const CMD_API_GEN: &str = env!("CARGO_BIN_EXE_apigen"); #[test] fn test_dns_server_openapi() { diff --git a/installinator/Cargo.toml b/installinator/Cargo.toml index 752f07b4b9..c21c3f2ee2 100644 --- a/installinator/Cargo.toml +++ b/installinator/Cargo.toml @@ -57,4 +57,3 @@ tokio-stream.workspace = true [features] image-standard = [] -image-trampoline = [] From 68fdbb1fb265794f8317a4550952aa167ba084d3 Mon Sep 17 00:00:00 2001 From: Rain Date: Fri, 5 Jul 2024 15:19:51 -0700 Subject: [PATCH 5/8] Cleanup Created using spr 1.3.6-beta.1 --- dev-tools/openapi-manager/src/check.rs | 42 +++++----- dev-tools/openapi-manager/src/generate.rs | 36 +++++---- dev-tools/openapi-manager/src/list.rs | 34 ++++---- dev-tools/openapi-manager/src/output.rs | 97 ++++++++++++++++++----- 4 files changed, 133 insertions(+), 76 deletions(-) diff --git a/dev-tools/openapi-manager/src/check.rs b/dev-tools/openapi-manager/src/check.rs index 7e2ed4e4b3..182ed9fb19 100644 --- a/dev-tools/openapi-manager/src/check.rs +++ b/dev-tools/openapi-manager/src/check.rs @@ -2,7 +2,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/. -use std::process::ExitCode; +use std::{io::Write, process::ExitCode}; use anyhow::Result; use camino::Utf8Path; @@ -12,8 +12,8 @@ use similar::TextDiff; use crate::{ output::{ - display_error, display_summary, headers::*, plural, write_diff, - OutputOpts, Styles, + display_api_spec, display_error, display_summary, headers::*, plural, + write_diff, OutputOpts, Styles, }, spec::{all_apis, CheckStatus}, FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE, @@ -48,6 +48,7 @@ pub(crate) fn check_impl( let all_apis = all_apis(); let total = all_apis.len(); let count_width = total.to_string().len(); + let continued_indent = continued_indent(count_width); eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); @@ -62,16 +63,16 @@ pub(crate) fn check_impl( let mut num_missing = 0; let mut num_failed = 0; - for (ix, api) in all_apis.iter().enumerate() { + for (ix, spec) in all_apis.iter().enumerate() { let count = ix + 1; - match api.check(&dir) { + match spec.check(&dir) { Ok(status) => match status { CheckStatus::Ok(summary) => { eprintln!( "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: {}", UP_TO_DATE.style(styles.success_header), - api.filename, + display_api_spec(spec, &styles), display_summary(&summary, &styles), ); @@ -81,7 +82,7 @@ pub(crate) fn check_impl( eprintln!( "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", STALE.style(styles.warning_header), - api.filename, + display_api_spec(spec, &styles), ); let diff = TextDiff::from_lines(&actual, &expected); @@ -90,7 +91,10 @@ pub(crate) fn check_impl( &full_path, &styles, // Add an indent to align diff with the status message. - &mut IndentWriter::new(" ", std::io::stderr()), + &mut IndentWriter::new( + &continued_indent, + std::io::stderr(), + ), )?; num_stale += 1; @@ -99,25 +103,23 @@ pub(crate) fn check_impl( eprintln!( "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", MISSING.style(styles.warning_header), - api.filename, + display_api_spec(spec, &styles), ); num_missing += 1; } }, - Err(err) => { + Err(error) => { eprint!( - "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: ", + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", FAILURE.style(styles.failure_header), - api.filename + display_api_spec(spec, &styles), ); - display_error( - &err, - styles.failure, - &mut IndentWriter::new_skip_initial( - " ", - std::io::stderr(), - ), + let display = display_error(&error, styles.failure); + write!( + IndentWriter::new(&continued_indent, std::io::stderr()), + "{}", + display, )?; num_failed += 1; @@ -125,6 +127,8 @@ pub(crate) fn check_impl( }; } + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + let status_header = if num_failed > 0 { FAILURE.style(styles.failure_header) } else if num_stale > 0 { diff --git a/dev-tools/openapi-manager/src/generate.rs b/dev-tools/openapi-manager/src/generate.rs index 017100d183..f776ff2709 100644 --- a/dev-tools/openapi-manager/src/generate.rs +++ b/dev-tools/openapi-manager/src/generate.rs @@ -2,7 +2,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/. -use std::process::ExitCode; +use std::{io::Write, process::ExitCode}; use anyhow::Result; use camino::Utf8Path; @@ -11,7 +11,8 @@ use owo_colors::OwoColorize; use crate::{ output::{ - display_error, display_summary, headers::*, plural, OutputOpts, Styles, + display_api_spec, display_error, display_summary, headers::*, plural, + OutputOpts, Styles, }, spec::{all_apis, OverwriteStatus}, FAILURE_EXIT_CODE, @@ -44,6 +45,7 @@ pub(crate) fn generate_impl( let all_apis = all_apis(); let total = all_apis.len(); let count_width = total.to_string().len(); + let continued_indent = continued_indent(count_width); eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); @@ -57,16 +59,16 @@ pub(crate) fn generate_impl( let mut num_unchanged = 0; let mut num_failed = 0; - for (ix, api) in all_apis.iter().enumerate() { + for (ix, spec) in all_apis.iter().enumerate() { let count = ix + 1; - match api.overwrite(&dir) { + match spec.overwrite(&dir) { Ok((status, summary)) => match status { OverwriteStatus::Updated => { eprintln!( "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: {}", UPDATED.style(styles.success_header), - api.filename, + display_api_spec(spec, &styles), display_summary(&summary, &styles), ); num_updated += 1; @@ -75,25 +77,23 @@ pub(crate) fn generate_impl( eprintln!( "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: {}", UNCHANGED.style(styles.unchanged_header), - api.filename, + display_api_spec(spec, &styles), display_summary(&summary, &styles), ); num_unchanged += 1; } }, Err(err) => { - eprint!( - "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: ", + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", FAILURE.style(styles.failure_header), - api.filename + display_api_spec(spec, &styles), ); - display_error( - &err, - styles.failure, - &mut IndentWriter::new_skip_initial( - " ", - std::io::stderr(), - ), + let display = display_error(&err, styles.failure); + write!( + IndentWriter::new(&continued_indent, std::io::stderr()), + "{}", + display, )?; num_failed += 1; @@ -101,6 +101,8 @@ pub(crate) fn generate_impl( }; } + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + let status_header = if num_failed > 0 { FAILURE.style(styles.failure_header) } else { @@ -111,7 +113,7 @@ pub(crate) fn generate_impl( "{:>HEADER_WIDTH$} {} {} generated: \ {} updated, {} unchanged, {} failed", status_header, - all_apis.len().style(styles.bold), + total.style(styles.bold), plural::documents(total), num_updated.style(styles.bold), num_unchanged.style(styles.bold), diff --git a/dev-tools/openapi-manager/src/list.rs b/dev-tools/openapi-manager/src/list.rs index 27582fba78..bf1920c69d 100644 --- a/dev-tools/openapi-manager/src/list.rs +++ b/dev-tools/openapi-manager/src/list.rs @@ -8,7 +8,7 @@ use indent_write::io::IndentWriter; use owo_colors::OwoColorize; use crate::{ - output::{display_error, OutputOpts, Styles}, + output::{display_api_spec, display_error, OutputOpts, Styles}, spec::all_apis, }; @@ -23,17 +23,15 @@ pub(crate) fn list_impl( let mut out = std::io::stdout(); let all_apis = all_apis(); - - let count_width = all_apis.len().to_string().len(); + let total = all_apis.len(); + let count_width = total.to_string().len(); if verbose { // A string for verbose indentation. +1 for the closing ), and +2 for // further indentation. - let initial_indent = - format!("{:width$}", "", width = count_width + 1 + 2); + let initial_indent = " ".repeat(count_width + 1 + 2); // This plus 4 more for continued indentation. - let continued_indent = - format!("{:width$}", "", width = count_width + 1 + 2 + 4); + let continued_indent = " ".repeat(count_width + 1 + 2 + 4); for (ix, api) in all_apis.iter().enumerate() { let count = ix + 1; @@ -91,32 +89,30 @@ pub(crate) fn list_impl( "{initial_indent} {}: ", "error".style(styles.failure), )?; - display_error( - &error, - styles.failure, - &mut IndentWriter::new_skip_initial( + let display = display_error(&error, styles.failure); + write!( + IndentWriter::new_skip_initial( &continued_indent, - &mut out, + std::io::stderr(), ), + "{}", + display, )?; - continue; } }; - if ix + 1 < all_apis.len() { + if ix + 1 < total { writeln!(&mut out)?; } } } else { - for (ix, api) in all_apis.iter().enumerate() { + for (ix, spec) in all_apis.iter().enumerate() { let count = ix + 1; writeln!( &mut out, - "{count:count_width$}) {}: {} v{}", - api.filename.style(styles.bold), - api.title, - api.version, + "{count:count_width$}) {}", + display_api_spec(spec, &styles), )?; } diff --git a/dev-tools/openapi-manager/src/output.rs b/dev-tools/openapi-manager/src/output.rs index b127f3e46a..6cd578e778 100644 --- a/dev-tools/openapi-manager/src/output.rs +++ b/dev-tools/openapi-manager/src/output.rs @@ -2,15 +2,15 @@ // 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::{fmt, io, io::Write}; +use std::{fmt, fmt::Write, io}; use camino::Utf8Path; use clap::{Args, ColorChoice}; -use indent_write::io::IndentWriter; +use indent_write::fmt::IndentWriter; use owo_colors::{OwoColorize, Style}; use similar::{ChangeTag, DiffableStr, TextDiff}; -use crate::spec::DocumentSummary; +use crate::spec::{ApiSpec, DocumentSummary}; #[derive(Debug, Args)] #[clap(next_help_heading = "Global options")] @@ -40,6 +40,7 @@ pub(crate) struct Styles { pub(crate) failure_header: Style, pub(crate) warning_header: Style, pub(crate) unchanged_header: Style, + pub(crate) filename: Style, pub(crate) diff_before: Style, pub(crate) diff_after: Style, } @@ -53,6 +54,7 @@ impl Styles { self.failure_header = Style::new().red().bold(); self.unchanged_header = Style::new().blue().bold(); self.warning_header = Style::new().yellow().bold(); + self.filename = Style::new().cyan(); self.diff_before = Style::new().red(); self.diff_after = Style::new().green(); } @@ -112,17 +114,30 @@ where Ok(()) } +pub(crate) fn display_api_spec(spec: &ApiSpec, styles: &Styles) -> String { + format!( + "{} ({} v{})", + spec.filename.style(styles.filename), + spec.title, + spec.version, + ) +} + pub(crate) fn display_summary( summary: &DocumentSummary, styles: &Styles, ) -> String { - let mut summary_str = - format!("{} paths", summary.path_count.to_string().style(styles.bold)); + let mut summary_str = format!( + "{} {}", + summary.path_count.style(styles.bold), + plural::paths(summary.path_count), + ); if let Some(schema_count) = summary.schema_count { summary_str.push_str(&format!( - ", {} schemas", - schema_count.to_string().style(styles.bold), + ", {} {}", + schema_count.style(styles.bold), + plural::schemas(schema_count), )); } else { summary_str.push_str(&format!( @@ -137,22 +152,32 @@ pub(crate) fn display_summary( pub(crate) fn display_error( error: &anyhow::Error, failure_style: Style, - mut out: &mut dyn io::Write, -) -> io::Result<()> { - writeln!(out, "{}", error.style(failure_style))?; - - let mut source = error.source(); - while let Some(curr) = source { - write!(out, "-> ")?; - writeln!( - IndentWriter::new_skip_initial(" ", &mut out), - "{}", - curr.style(failure_style), - )?; - source = curr.source(); +) -> impl fmt::Display + '_ { + struct DisplayError<'a> { + error: &'a anyhow::Error, + failure_style: Style, } - Ok(()) + impl fmt::Display for DisplayError<'_> { + fn fmt(&self, mut f: &mut fmt::Formatter<'_>) -> fmt::Result { + writeln!(f, "{}", self.error.style(self.failure_style))?; + + let mut source = self.error.source(); + while let Some(curr) = source { + write!(f, "-> ")?; + writeln!( + IndentWriter::new_skip_initial(" ", &mut f), + "{}", + curr.style(self.failure_style), + )?; + source = curr.source(); + } + + Ok(()) + } + } + + DisplayError { error, failure_style } } struct MissingNewlineHint(bool); @@ -185,6 +210,20 @@ pub(crate) mod headers { pub(crate) static SUCCESS: &str = "Success"; pub(crate) static FAILURE: &str = "Failure"; + + pub(crate) fn continued_indent(count_width: usize) -> String { + // Status strings are of the form: + // + // Generated [ 1/12] api.json: 1 path, 1 schema + // + // So the continued indent is: + // + // HEADER_WIDTH for the status string + // + (count_width * 2) for current and total counts + // + 3 for '[/]' + // + 2 for spaces on either side. + " ".repeat(HEADER_WIDTH + count_width * 2 + 3 + 2) + } } pub(crate) mod plural { @@ -195,4 +234,20 @@ pub(crate) mod plural { "documents" } } + + pub(crate) fn paths(count: usize) -> &'static str { + if count == 1 { + "path" + } else { + "paths" + } + } + + pub(crate) fn schemas(count: usize) -> &'static str { + if count == 1 { + "schema" + } else { + "schemas" + } + } } From 111039831e07a731b69a214981cc8a481faf12a1 Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 11 Jul 2024 15:15:44 -0700 Subject: [PATCH 6/8] update error type name Created using spr 1.3.6-beta.1 --- dev-tools/openapi-manager/src/spec.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dev-tools/openapi-manager/src/spec.rs b/dev-tools/openapi-manager/src/spec.rs index a705acf32e..37330d6922 100644 --- a/dev-tools/openapi-manager/src/spec.rs +++ b/dev-tools/openapi-manager/src/spec.rs @@ -7,7 +7,7 @@ use std::{fmt, io::Write}; use anyhow::{Context, Result}; use atomicwrites::AtomicFile; use camino::{Utf8Path, Utf8PathBuf}; -use dropshot::{ApiDescription, ApiDescriptionBuildError, StubContext}; +use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext}; use fs_err as fs; use openapiv3::OpenAPI; @@ -44,7 +44,7 @@ pub struct ApiSpec { /// The API description function, typically a reference to /// `stub_api_description`. pub api_description: - fn() -> Result, ApiDescriptionBuildError>, + fn() -> Result, ApiDescriptionBuildErrors>, /// The JSON filename to write the API description to. pub filename: String, From 1320e689d924d35afdf147e4fb5ee62434a50b2a Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 11 Jul 2024 15:34:10 -0700 Subject: [PATCH 7/8] fix build issues Created using spr 1.3.6-beta.1 --- nexus/src/bin/nexus.rs | 1 - nexus/src/internal_api/http_entrypoints.rs | 1 - 2 files changed, 2 deletions(-) diff --git a/nexus/src/bin/nexus.rs b/nexus/src/bin/nexus.rs index 9d13409cef..3b491b5893 100644 --- a/nexus/src/bin/nexus.rs +++ b/nexus/src/bin/nexus.rs @@ -17,7 +17,6 @@ use nexus_config::NexusConfig; use omicron_common::cmd::fatal; use omicron_common::cmd::CmdError; use omicron_nexus::run_openapi_external; -use omicron_nexus::run_openapi_internal; use omicron_nexus::run_server; #[derive(Debug, Parser)] diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index 583c9b0065..f324ea787d 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -7,7 +7,6 @@ use super::params::{OximeterInfo, RackInitializationRequest}; use crate::context::ApiContext; use dropshot::ApiDescription; -use dropshot::ApiDescriptionRegisterError; use dropshot::FreeformBody; use dropshot::HttpError; use dropshot::HttpResponseCreated; From 4593c8d7b714ffcb8a6be53d0e6c654f6826344b Mon Sep 17 00:00:00 2001 From: Rain Date: Thu, 11 Jul 2024 19:54:33 -0700 Subject: [PATCH 8/8] update readme Created using spr 1.3.6-beta.1 --- README.adoc | 25 ++++++++++++++++------ dev-tools/openapi-manager/README.adoc | 4 +++- nexus/tests/output/cmd-nexus-noargs-stderr | 5 ++--- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/README.adoc b/README.adoc index f0e3a88343..1ef4bd8601 100644 --- a/README.adoc +++ b/README.adoc @@ -181,16 +181,23 @@ By default, Cargo does not operate on the tests. Cargo's check/build/clippy com Each service is a Dropshot server that presents an HTTP API. The description of that API is serialized as an https://github.com/OAI/OpenAPI-Specification[OpenAPI] document which we store -in link:./openapi[`omicron/openapi`] and check in to this repo. In order to -ensure that changes to those APIs are made intentionally, each service contains -a test that validates that the current API matches. This allows us 1. to catch -accidental changes as test failures and 2. to explicitly observe API changes -during code review (and in the git history). +in link:./openapi[`omicron/openapi`] and check in to this repo. Checking in +these generated files allows us: + +. To catch accidental changes as test failures. +. To explicitly observe API changes during code review (and in the git history). We also use these OpenAPI documents as the source for the clients we generate using https://github.com/oxidecomputer/progenitor[Progenitor]. Clients are automatically updated when the coresponding OpenAPI document is modified. +There are currently two kinds of services based on how their corresponding documents are generated: *managed* and *unmanaged*. Eventually, all services within Omicron will transition to being managed. + +* A *managed* service is tracked by the `cargo xtask openapi` command, using Dropshot's relatively new API trait functionality. +* An *unmanaged* service is defined the traditional way, by gluing together a set of implementation functions, and is tracked by an independent test. + +To check whether your document is managed, run `cargo xtask openapi list`: it will list out all managed OpenAPI documents. If your document is not on the list, it is unmanaged. + Note that Omicron contains a nominally circular dependency: * Nexus depends on the Sled Agent client @@ -201,7 +208,13 @@ Note that Omicron contains a nominally circular dependency: We effectively "break" this circular dependency by virtue of the OpenAPI documents being checked in. -In general, changes any service API **require the following set of build steps**: +==== Updating Managed Services + +See the documentation in link:./dev-tools/openapi-manager[`dev-tools/openapi-manager`] for more information. + +==== Updating Unmanaged Services + +In general, changes to unmanaged service APs **require the following set of build steps**: . Make changes to the service API. . Update the OpenAPI document by running the relevant test with overwrite set: diff --git a/dev-tools/openapi-manager/README.adoc b/dev-tools/openapi-manager/README.adoc index 82aa8d1742..1aadaa2c0c 100644 --- a/dev-tools/openapi-manager/README.adoc +++ b/dev-tools/openapi-manager/README.adoc @@ -6,6 +6,8 @@ NOTE: For more information about API traits, see https://rfd.shared.oxide.comput Currently, a subset of OpenAPI documents is managed by this tool. Eventually, all of the OpenAPI documents in Omicron will be managed by this tool; work to make that happen is ongoing. +To check whether your document is managed, run `cargo xtask openapi list`: it will list out all managed OpenAPI documents. If your document is not on the list, it is unmanaged. + == Basic usage The OpenAPI manager is meant to be invoked via `cargo xtask openapi`. Currently, three commands are provided: @@ -14,7 +16,7 @@ The OpenAPI manager is meant to be invoked via `cargo xtask openapi`. Currently, * `cargo xtask openapi check`: Check that all of the managed documents are up-to-date. * `cargo xtask openapi generate`: Update and generate OpenAPI documents. -There is also a test which makes sure that all managed documents are up-to-date, and tells you to run `xtask openapi generate` if they aren't. +There is also a test which makes sure that all managed documents are up-to-date, and tells you to run `cargo xtask openapi generate` if they aren't. === API crates [[api_crates]] diff --git a/nexus/tests/output/cmd-nexus-noargs-stderr b/nexus/tests/output/cmd-nexus-noargs-stderr index 8dff679340..385248bd0e 100644 --- a/nexus/tests/output/cmd-nexus-noargs-stderr +++ b/nexus/tests/output/cmd-nexus-noargs-stderr @@ -6,8 +6,7 @@ Arguments: [CONFIG_FILE_PATH] Options: - -O, --openapi Print the external OpenAPI Spec document and exit - -I, --openapi-internal Print the internal OpenAPI Spec document and exit - -h, --help Print help + -O, --openapi Print the external OpenAPI Spec document and exit + -h, --help Print help nexus: CONFIG_FILE_PATH is required