diff --git a/Cargo.lock b/Cargo.lock index 6665f3da41..98efcce19a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3531,6 +3531,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" @@ -4720,6 +4726,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" @@ -5522,6 +5542,7 @@ dependencies = [ "nexus-db-model", "nexus-db-queries", "nexus-defaults", + "nexus-internal-api", "nexus-inventory", "nexus-metrics-producer-gc", "nexus-networking", @@ -5908,6 +5929,7 @@ dependencies = [ "bit-vec", "bitflags 1.3.2", "bitflags 2.5.0", + "bstr 0.2.17", "bstr 1.9.1", "byteorder", "bytes", @@ -6076,6 +6098,27 @@ dependencies = [ "regex", ] +[[package]] +name = "openapi-manager" +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 = "openapiv3" version = "2.0.0" diff --git a/Cargo.toml b/Cargo.toml index 8a5bd29974..34f8e4ee24 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,6 +20,7 @@ members = [ "dev-tools/crdb-seed", "dev-tools/omdb", "dev-tools/omicron-dev", + "dev-tools/openapi-manager", "dev-tools/oxlog", "dev-tools/reconfigurator-cli", "dev-tools/releng", @@ -46,6 +47,7 @@ members = [ "nexus/db-model", "nexus/db-queries", "nexus/defaults", + "nexus/internal-api", "nexus/inventory", "nexus/macros-common", "nexus/metrics-producer-gc", @@ -109,6 +111,7 @@ default-members = [ "dev-tools/crdb-seed", "dev-tools/omdb", "dev-tools/omicron-dev", + "dev-tools/openapi-manager", "dev-tools/oxlog", "dev-tools/reconfigurator-cli", "dev-tools/releng", @@ -138,6 +141,7 @@ default-members = [ "nexus/db-model", "nexus/db-queries", "nexus/defaults", + "nexus/internal-api", "nexus/inventory", "nexus/macros-common", "nexus/metrics-producer-gc", @@ -310,6 +314,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" } @@ -344,6 +349,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 = "nexus/internal-api" } nexus-macros-common = { path = "nexus/macros-common" } nexus-metrics-producer-gc = { path = "nexus/metrics-producer-gc" } nexus-networking = { path = "nexus/networking" } @@ -449,6 +455,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" # Don't change sled's version on accident; sled's on-disk format is not yet # stable and requires manual migrations. In the limit this won't matter because 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/Cargo.toml b/dev-tools/openapi-manager/Cargo.toml new file mode 100644 index 0000000000..b50aeec69f --- /dev/null +++ b/dev-tools/openapi-manager/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "openapi-manager" +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/dev-tools/openapi-manager/README.adoc b/dev-tools/openapi-manager/README.adoc new file mode 100644 index 0000000000..1aadaa2c0c --- /dev/null +++ b/dev-tools/openapi-manager/README.adoc @@ -0,0 +1,103 @@ += OpenAPI manager + +This tool manages the OpenAPI documents (JSON files) checked into Omicron's `openapi` directory, using Dropshot's support for *API traits*. + +NOTE: For more information about API traits, see https://rfd.shared.oxide.computer/rfd/0479[RFD 479]. + +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: + +* `cargo xtask openapi list`: List information about currently-managed documents. +* `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 `cargo xtask openapi generate` if they aren't. + +=== API crates [[api_crates]] + +The OpenAPI manager has dependencies on a set of *API crates*. An API crate is a Rust library that consists of the API trait, and possibly supporting types. Each OpenAPI document should have a separate API crate. + +To keep compile times down, ensure that the API crate has as few dependencies as possible. In particular, *strongly avoid any dependencies on Diesel or other database logic*. + +The ideal set of dependencies is: + +* Common crates within omicron: `omicron-common`, perhaps `omicron-uuid-kinds` if typed UUIDs are in use, and a `types` crate for your service. +* Core external crates: `dropshot`, `serde`, `schemars`, and `uuid`. + +For an archetypal way to organize code, see the dependency graph in https://rfd.shared.oxide.computer/rfd/0479#functions_vs_traits[RFD 479's _Choosing between functions and traits_]. + +== Managing OpenAPI documents + +For OpenAPI documents to be managed by this tool, the corresponding interfaces must be defined via *API traits* rather than traditional Dropshot function-based servers. + +TIP: For examples within Omicron, search the repo for `dropshot::api_description`. + +=== Adding new documents + +If you're defining a new service fronted by OpenAPI, first 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]`. +. Following the example in https://rfd.shared.oxide.computer/rfd/0479#guide_trait_definition[RFD 479's _Trait definition_], define the API trait. + +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, perform the steps in <> below. + +=== Converting existing documents + +Existing, unmanaged documents are generated via *function-based servers*: a set of functions that some code combines into a Dropshot `ApiDescription`. (There is also likely an expectorate test which ensures that the document is up-to-date.) + +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. + +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. The OpenAPI manager includes a test that will automatically run in CI. +. The binary subcommand (typically called `openapi`) that generated the OpenAPI document. The test was the only practical use of this subcommand. + +=== Adding the API crate to the manager [[add_to_manager]] + +Once the API crate is defined, inform the OpenAPI manager of its existence. Within this directory: + +. In `Cargo.toml`, add a dependency on the API crate. +. In `src/spec.rs`, add the crate to the `all_apis` function. (Please keep the list sorted by filename.) + +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]. + +With traditional function-based Dropshot servers, generating the OpenAPI document requires the implementation to be compiled. With API traits, that is no longer necessary. The OpenAPI manager leverages this to provide a fast and easy way to regenerate API documents. + +This does mean that the OpenAPI manager requires the use of API traits, and that eventually all of Omicron's Dropshot APIs should be switched over to traits. diff --git a/dev-tools/openapi-manager/src/check.rs b/dev-tools/openapi-manager/src/check.rs new file mode 100644 index 0000000000..182ed9fb19 --- /dev/null +++ b/dev-tools/openapi-manager/src/check.rs @@ -0,0 +1,185 @@ +// 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, process::ExitCode}; + +use anyhow::Result; +use camino::Utf8Path; +use indent_write::io::IndentWriter; +use owo_colors::OwoColorize; +use similar::TextDiff; + +use crate::{ + output::{ + display_api_spec, display_error, display_summary, headers::*, plural, + write_diff, OutputOpts, Styles, + }, + spec::{all_apis, CheckStatus}, + FAILURE_EXIT_CODE, NEEDS_UPDATE_EXIT_CODE, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum CheckResult { + Success, + NeedsUpdate, + Failures, +} + +impl CheckResult { + pub(crate) fn to_exit_code(self) -> ExitCode { + match self { + CheckResult::Success => ExitCode::SUCCESS, + CheckResult::NeedsUpdate => NEEDS_UPDATE_EXIT_CODE.into(), + CheckResult::Failures => FAILURE_EXIT_CODE.into(), + } + } +} + +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(); + let total = all_apis.len(); + let count_width = total.to_string().len(); + let continued_indent = continued_indent(count_width); + + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + + eprintln!( + "{:>HEADER_WIDTH$} {} OpenAPI {}...", + CHECKING.style(styles.success_header), + total.style(styles.bold), + plural::documents(total), + ); + let mut num_up_to_date = 0; + let mut num_stale = 0; + let mut num_missing = 0; + let mut num_failed = 0; + + for (ix, spec) in all_apis.iter().enumerate() { + let count = ix + 1; + + 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), + display_api_spec(spec, &styles), + display_summary(&summary, &styles), + ); + + num_up_to_date += 1; + } + CheckStatus::Stale { full_path, actual, expected } => { + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", + STALE.style(styles.warning_header), + display_api_spec(spec, &styles), + ); + + 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( + &continued_indent, + std::io::stderr(), + ), + )?; + + num_stale += 1; + } + CheckStatus::Missing => { + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", + MISSING.style(styles.warning_header), + display_api_spec(spec, &styles), + ); + + num_missing += 1; + } + }, + Err(error) => { + eprint!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", + FAILURE.style(styles.failure_header), + display_api_spec(spec, &styles), + ); + let display = display_error(&error, styles.failure); + write!( + IndentWriter::new(&continued_indent, std::io::stderr()), + "{}", + display, + )?; + + num_failed += 1; + } + }; + } + + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + + let status_header = if num_failed > 0 { + FAILURE.style(styles.failure_header) + } else if num_stale > 0 { + STALE.style(styles.warning_header) + } else { + SUCCESS.style(styles.success_header) + }; + + eprintln!( + "{:>HEADER_WIDTH$} {} {} checked: {} up-to-date, {} stale, {} missing, {} failed", + status_header, + total.style(styles.bold), + plural::documents(total), + num_up_to_date.style(styles.bold), + num_stale.style(styles.bold), + num_missing.style(styles.bold), + num_failed.style(styles.bold), + ); + if num_failed > 0 { + eprintln!( + "{:>HEADER_WIDTH$} (fix failures, then run {} to update)", + "", + "cargo xtask openapi generate".style(styles.bold) + ); + Ok(CheckResult::Failures) + } else if num_stale > 0 { + eprintln!( + "{:>HEADER_WIDTH$} (run {} to update)", + "", + "cargo xtask openapi generate".style(styles.bold) + ); + Ok(CheckResult::NeedsUpdate) + } else { + Ok(CheckResult::Success) + } +} + +#[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/dev-tools/openapi-manager/src/dispatch.rs b/dev-tools/openapi-manager/src/dispatch.rs new file mode 100644 index 0000000000..937a8b485f --- /dev/null +++ b/dev-tools/openapi-manager/src/dispatch.rs @@ -0,0 +1,99 @@ +// 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::{Args, Parser, Subcommand}; + +use crate::{ + check::check_impl, generate::generate_impl, list::list_impl, + output::OutputOpts, spec::openapi_dir, +}; + +/// Manage OpenAPI specifications. +/// +/// For more information, see dev-tools/openapi-manager/README.adoc. +#[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::List(args) => args.exec(&self.output_opts), + Command::Generate(args) => args.exec(&self.output_opts), + Command::Check(args) => args.exec(&self.output_opts), + } + } +} + +#[derive(Debug, Subcommand)] +pub enum Command { + /// List managed APIs. + /// + /// Returns information purely from code without consulting JSON files on + /// disk. To compare against files on disk, use the `check` command. + List(ListArgs), + + /// Generate APIs. + Generate(GenerateArgs), + + /// Check that APIs are up-to-date. + Check(CheckArgs), +} + +#[derive(Debug, Args)] +pub struct ListArgs { + /// Show verbose output including descriptions. + #[clap(long, short)] + verbose: bool, +} + +impl ListArgs { + fn exec(self, output: &OutputOpts) -> anyhow::Result { + list_impl(self.verbose, output)?; + Ok(ExitCode::SUCCESS) + } +} + +#[derive(Debug, Args)] +pub struct GenerateArgs { + /// The directory to write generated APIs to (default: workspace root/openapi) + #[clap(long)] + dir: Option, +} + +impl GenerateArgs { + fn exec(self, output: &OutputOpts) -> anyhow::Result { + let dir = openapi_dir(self.dir)?; + Ok(generate_impl(&dir, output)?.to_exit_code()) + } +} + +#[derive(Debug, Args)] +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()) + } +} + +// This code is not 0 or 1 (general anyhow errors) and indicates out-of-date. +pub(crate) const NEEDS_UPDATE_EXIT_CODE: u8 = 2; + +// This code indicates failures during generation, e.g. validation errors. +pub(crate) const FAILURE_EXIT_CODE: u8 = 100; diff --git a/dev-tools/openapi-manager/src/generate.rs b/dev-tools/openapi-manager/src/generate.rs new file mode 100644 index 0000000000..f776ff2709 --- /dev/null +++ b/dev-tools/openapi-manager/src/generate.rs @@ -0,0 +1,128 @@ +// 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, process::ExitCode}; + +use anyhow::Result; +use camino::Utf8Path; +use indent_write::io::IndentWriter; +use owo_colors::OwoColorize; + +use crate::{ + output::{ + display_api_spec, display_error, display_summary, headers::*, plural, + OutputOpts, Styles, + }, + spec::{all_apis, OverwriteStatus}, + FAILURE_EXIT_CODE, +}; + +#[derive(Clone, Copy, Debug)] +pub(crate) enum GenerateResult { + Success, + Failures, +} + +impl GenerateResult { + pub(crate) fn to_exit_code(self) -> ExitCode { + match self { + GenerateResult::Success => ExitCode::SUCCESS, + GenerateResult::Failures => FAILURE_EXIT_CODE.into(), + } + } +} + +pub(crate) fn generate_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(); + let total = all_apis.len(); + let count_width = total.to_string().len(); + let continued_indent = continued_indent(count_width); + + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + + eprintln!( + "{:>HEADER_WIDTH$} {} OpenAPI {}...", + GENERATING.style(styles.success_header), + total.style(styles.bold), + plural::documents(total), + ); + let mut num_updated = 0; + let mut num_unchanged = 0; + let mut num_failed = 0; + + for (ix, spec) in all_apis.iter().enumerate() { + let count = ix + 1; + + match spec.overwrite(&dir) { + Ok((status, summary)) => match status { + OverwriteStatus::Updated => { + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: {}", + UPDATED.style(styles.success_header), + display_api_spec(spec, &styles), + display_summary(&summary, &styles), + ); + num_updated += 1; + } + OverwriteStatus::Unchanged => { + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}: {}", + UNCHANGED.style(styles.unchanged_header), + display_api_spec(spec, &styles), + display_summary(&summary, &styles), + ); + num_unchanged += 1; + } + }, + Err(err) => { + eprintln!( + "{:>HEADER_WIDTH$} [{count:>count_width$}/{total}] {}", + FAILURE.style(styles.failure_header), + display_api_spec(spec, &styles), + ); + let display = display_error(&err, styles.failure); + write!( + IndentWriter::new(&continued_indent, std::io::stderr()), + "{}", + display, + )?; + + num_failed += 1; + } + }; + } + + eprintln!("{:>HEADER_WIDTH$}", SEPARATOR); + + let status_header = if num_failed > 0 { + FAILURE.style(styles.failure_header) + } else { + SUCCESS.style(styles.success_header) + }; + + eprintln!( + "{:>HEADER_WIDTH$} {} {} generated: \ + {} updated, {} unchanged, {} failed", + status_header, + total.style(styles.bold), + plural::documents(total), + num_updated.style(styles.bold), + num_unchanged.style(styles.bold), + num_failed.style(styles.bold), + ); + + if num_failed > 0 { + Ok(GenerateResult::Failures) + } else { + Ok(GenerateResult::Success) + } +} diff --git a/dev-tools/openapi-manager/src/lib.rs b/dev-tools/openapi-manager/src/lib.rs new file mode 100644 index 0000000000..0f79c5f9f4 --- /dev/null +++ b/dev-tools/openapi-manager/src/lib.rs @@ -0,0 +1,21 @@ +// 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/. + +//! OpenAPI manager for Omicron. +//! +//! This tool generates and checks OpenAPI specifications for Omicron OpenAPI +//! documents. In the future, all OpenAPI documents will be generated by this +//! tool, but work to make that happen is ongoing. +//! +//! This is meant to be invoked as `cargo xtask openapi`, but is a separate +//! binary to avoid compiling a bunch of extra code when running `cargo xtask`. + +mod check; +mod dispatch; +mod generate; +mod list; +mod output; +mod spec; + +pub use dispatch::*; diff --git a/dev-tools/openapi-manager/src/list.rs b/dev-tools/openapi-manager/src/list.rs new file mode 100644 index 0000000000..bf1920c69d --- /dev/null +++ b/dev-tools/openapi-manager/src/list.rs @@ -0,0 +1,127 @@ +// 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 indent_write::io::IndentWriter; +use owo_colors::OwoColorize; + +use crate::{ + output::{display_api_spec, display_error, OutputOpts, Styles}, + spec::all_apis, +}; + +pub(crate) fn list_impl( + verbose: bool, + output: &OutputOpts, +) -> anyhow::Result<()> { + let mut styles = Styles::default(); + if output.use_color(supports_color::Stream::Stdout) { + styles.colorize(); + } + let mut out = std::io::stdout(); + + let all_apis = all_apis(); + 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 = " ".repeat(count_width + 1 + 2); + // This plus 4 more for continued indentation. + let continued_indent = " ".repeat(count_width + 1 + 2 + 4); + + for (ix, api) in all_apis.iter().enumerate() { + let count = ix + 1; + + writeln!( + &mut out, + "{count:count_width$}) {}", + api.filename.style(styles.bold), + )?; + + writeln!( + &mut out, + "{initial_indent} {}: {} v{}", + "title".style(styles.header), + api.title, + api.version, + )?; + + write!( + &mut out, + "{initial_indent} {}: ", + "description".style(styles.header) + )?; + writeln!( + IndentWriter::new_skip_initial(&continued_indent, &mut out), + "{}", + api.description, + )?; + + writeln!( + &mut out, + "{initial_indent} {}: {}", + "boundary".style(styles.header), + api.boundary, + )?; + + match api.to_openapi_doc() { + Ok(openapi) => { + let num_paths = openapi.paths.paths.len(); + let num_schemas = openapi.components.map_or_else( + || "(data missing)".to_owned(), + |c| c.schemas.len().to_string(), + ); + writeln!( + &mut out, + "{initial_indent} {}: {} paths, {} schemas", + "details".style(styles.header), + num_paths.style(styles.bold), + num_schemas.style(styles.bold), + )?; + } + Err(error) => { + write!( + &mut out, + "{initial_indent} {}: ", + "error".style(styles.failure), + )?; + let display = display_error(&error, styles.failure); + write!( + IndentWriter::new_skip_initial( + &continued_indent, + std::io::stderr(), + ), + "{}", + display, + )?; + } + }; + + if ix + 1 < total { + writeln!(&mut out)?; + } + } + } else { + for (ix, spec) in all_apis.iter().enumerate() { + let count = ix + 1; + + writeln!( + &mut out, + "{count:count_width$}) {}", + display_api_spec(spec, &styles), + )?; + } + + writeln!( + &mut out, + "note: run with {} for more information", + "-v".style(styles.bold), + )?; + } + + Ok(()) +} diff --git a/dev-tools/openapi-manager/src/main.rs b/dev-tools/openapi-manager/src/main.rs new file mode 100644 index 0000000000..422a1553b0 --- /dev/null +++ b/dev-tools/openapi-manager/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 clap::Parser; +use openapi_manager::App; + +fn main() -> ExitCode { + let app = App::parse(); + app.exec().unwrap() +} diff --git a/dev-tools/openapi-manager/src/output.rs b/dev-tools/openapi-manager/src/output.rs new file mode 100644 index 0000000000..6cd578e778 --- /dev/null +++ b/dev-tools/openapi-manager/src/output.rs @@ -0,0 +1,253 @@ +// 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, fmt::Write, io}; + +use camino::Utf8Path; +use clap::{Args, ColorChoice}; +use indent_write::fmt::IndentWriter; +use owo_colors::{OwoColorize, Style}; +use similar::{ChangeTag, DiffableStr, TextDiff}; + +use crate::spec::{ApiSpec, DocumentSummary}; + +#[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) header: Style, + pub(crate) success_header: Style, + pub(crate) failure: Style, + 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, +} + +impl Styles { + pub(crate) fn colorize(&mut self) { + self.bold = Style::new().bold(); + self.header = Style::new().purple(); + self.success_header = Style::new().green().bold(); + self.failure = Style::new().red(); + 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(); + } +} + +// 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(()) +} + +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!( + "{} {}", + summary.path_count.style(styles.bold), + plural::paths(summary.path_count), + ); + + if let Some(schema_count) = summary.schema_count { + summary_str.push_str(&format!( + ", {} {}", + schema_count.style(styles.bold), + plural::schemas(schema_count), + )); + } else { + summary_str.push_str(&format!( + ", {} for schemas", + "data missing".style(styles.failure) + )); + } + + summary_str +} + +pub(crate) fn display_error( + error: &anyhow::Error, + failure_style: Style, +) -> impl fmt::Display + '_ { + struct DisplayError<'a> { + error: &'a anyhow::Error, + failure_style: Style, + } + + 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); + +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(()) + } +} + +/// Output headers. +pub(crate) mod headers { + // Same width as Cargo's output. + pub(crate) const HEADER_WIDTH: usize = 12; + + pub(crate) static SEPARATOR: &str = "-------"; + + pub(crate) static CHECKING: &str = "Checking"; + pub(crate) static GENERATING: &str = "Generating"; + + pub(crate) static UP_TO_DATE: &str = "Up-to-date"; + pub(crate) static STALE: &str = "Stale"; + pub(crate) static MISSING: &str = "Missing"; + + pub(crate) static UPDATED: &str = "Updated"; + pub(crate) static UNCHANGED: &str = "Unchanged"; + + 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 { + pub(crate) fn documents(count: usize) -> &'static str { + if count == 1 { + "document" + } else { + "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" + } + } +} diff --git a/dev-tools/openapi-manager/src/spec.rs b/dev-tools/openapi-manager/src/spec.rs new file mode 100644 index 0000000000..37330d6922 --- /dev/null +++ b/dev-tools/openapi-manager/src/spec.rs @@ -0,0 +1,260 @@ +// 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::Write}; + +use anyhow::{Context, Result}; +use atomicwrites::AtomicFile; +use camino::{Utf8Path, Utf8PathBuf}; +use dropshot::{ApiDescription, ApiDescriptionBuildErrors, StubContext}; +use fs_err as fs; +use openapiv3::OpenAPI; + +/// All APIs managed by openapi-manager. +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(), + boundary: ApiBoundary::Internal, + api_description: + nexus_internal_api::nexus_internal_api_mod::stub_api_description, + filename: "nexus-internal.json".to_string(), + extra_validation: None, + }, + // Add your APIs here! Please keep this list sorted by filename. + ] +} + +pub struct ApiSpec { + /// The title. + pub title: String, + + /// The version. + pub version: String, + + /// The description string. + pub description: String, + + /// Whether this API is internal or external. + pub boundary: ApiBoundary, + + /// The API description function, typically a reference to + /// `stub_api_description`. + pub api_description: + fn() -> Result, ApiDescriptionBuildErrors>, + + /// The JSON filename to write the API description to. + pub filename: String, + + /// Extra validation to perform on the OpenAPI spec, if any. + pub extra_validation: Option anyhow::Result<()>>, +} + +impl ApiSpec { + pub(crate) fn overwrite( + &self, + dir: &Utf8Path, + ) -> Result<(OverwriteStatus, DocumentSummary)> { + let contents = self.to_json_bytes()?; + + let summary = self + .validate_json(&contents) + .context("OpenAPI document validation failed")?; + + let full_path = dir.join(&self.filename); + let status = overwrite_file(&full_path, &contents)?; + + Ok((status, summary)) + } + + pub(crate) fn check(&self, dir: &Utf8Path) -> Result { + let contents = self.to_json_bytes()?; + let summary = self + .validate_json(&contents) + .context("OpenAPI document validation failed")?; + + 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(summary)) + } + Some(existing_contents) => Ok(CheckStatus::Stale { + full_path, + actual: existing_contents, + expected: contents, + }), + None => Ok(CheckStatus::Missing), + } + } + + pub(crate) fn to_openapi_doc(&self) -> Result { + // It's a bit weird to first convert to bytes and then back to OpenAPI, + // but this is the easiest way to do so (currently, Dropshot doesn't + // return the OpenAPI type directly). It is also consistent with the + // other code paths. + let contents = self.to_json_bytes()?; + contents_to_openapi(&contents) + } + + fn to_json_bytes(&self) -> Result> { + let description = (self.api_description)().map_err(|error| { + // ApiDescriptionBuildError is actually a list of errors so it + // doesn't implement std::error::Error itself. Its Display + // impl formats the errors appropriately. + anyhow::anyhow!("{}", error) + })?; + let mut openapi_def = description.openapi(&self.title, &self.version); + openapi_def + .description(&self.description) + .contact_url("https://oxide.computer") + .contact_email("api@oxide.computer"); + + // 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(); + openapi_def.write(&mut contents)?; + Ok(contents) + } + + fn validate_json(&self, contents: &[u8]) -> Result { + let openapi_doc = contents_to_openapi(contents) + .context("JSON returned by ApiDescription is not valid OpenAPI")?; + + // Check for lint errors. + let errors = match self.boundary { + ApiBoundary::Internal => openapi_lint::validate(&openapi_doc), + ApiBoundary::External => { + openapi_lint::validate_external(&openapi_doc) + } + }; + if !errors.is_empty() { + return Err(anyhow::anyhow!("{}", errors.join("\n\n"))); + } + + if let Some(extra_validation) = self.extra_validation { + extra_validation(&openapi_doc)?; + } + + Ok(DocumentSummary::new(&openapi_doc)) + } +} + +fn contents_to_openapi(contents: &[u8]) -> Result { + serde_json::from_slice(&contents) + .context("JSON returned by ApiDescription is not valid OpenAPI") +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ApiBoundary { + Internal, + #[allow(dead_code)] // Remove this when we start managing an external API. + External, +} + +impl fmt::Display for ApiBoundary { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + ApiBoundary::Internal => write!(f, "internal"), + ApiBoundary::External => write!(f, "external"), + } + } +} + +#[derive(Debug)] +#[must_use] +pub(crate) enum OverwriteStatus { + Updated, + Unchanged, +} + +#[derive(Debug)] +#[must_use] +pub(crate) enum CheckStatus { + Ok(DocumentSummary), + Stale { full_path: Utf8PathBuf, actual: Vec, expected: Vec }, + Missing, +} + +#[derive(Debug)] +#[must_use] +pub(crate) struct DocumentSummary { + pub(crate) path_count: usize, + // None if data is missing. + pub(crate) schema_count: Option, +} + +impl DocumentSummary { + fn new(doc: &OpenAPI) -> Self { + Self { + path_count: doc.paths.paths.len(), + schema_count: doc + .components + .as_ref() + .map_or(None, |c| Some(c.schemas.len())), + } + } +} + +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 crate is two levels down from the root of omicron, 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) +} + +/// 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 { + // Only overwrite the file if the contents are actually different. + 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>> { + 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/dev-tools/xtask/src/main.rs b/dev-tools/xtask/src/main.rs index 3d8acceb3d..d0a61272a9 100644 --- a/dev-tools/xtask/src/main.rs +++ b/dev-tools/xtask/src/main.rs @@ -46,6 +46,11 @@ enum Cmds { /// Download binaries, OpenAPI specs, and other out-of-repo utilities. Download(download::DownloadArgs), + /// Manage OpenAPI specifications. + /// + /// For more information, see dev-tools/openapi-manager/README.adoc. + Openapi(external::External), + #[cfg(target_os = "illumos")] /// Build a TUF repo Releng(external::External), @@ -88,6 +93,7 @@ async fn main() -> Result<()> { Cmds::Clippy(args) => clippy::run_cmd(args), Cmds::CheckWorkspaceDeps => check_workspace_deps::run_cmd(), Cmds::Download(args) => download::run_cmd(args).await, + Cmds::Openapi(external) => external.exec_bin("openapi-manager"), #[cfg(target_os = "illumos")] Cmds::Releng(external) => { external.cargo_args(["--release"]).exec_bin("omicron-releng") diff --git a/nexus/Cargo.toml b/nexus/Cargo.toml index fb6a07969d..359ea616d4 100644 --- a/nexus/Cargo.toml +++ b/nexus/Cargo.toml @@ -45,6 +45,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 ca7c76c0ae..461e71d88a 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -112,7 +112,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 region::RegionAllocationFor; diff --git a/nexus/db-queries/src/db/datastore/probe.rs b/nexus/db-queries/src/db/datastore/probe.rs index a96f857163..f3e0614552 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 { @@ -141,7 +112,7 @@ impl super::DataStore { result.push(ProbeInfo { id: probe.id(), - name: probe.name().clone().into(), + name: probe.name().clone(), sled: probe.sled, interface, external_ips, @@ -184,7 +155,7 @@ impl super::DataStore { Ok(ProbeInfo { id: probe.id(), - name: probe.name().clone().into(), + name: probe.name().clone(), sled: probe.sled, interface, external_ips, diff --git a/nexus/internal-api/Cargo.toml b/nexus/internal-api/Cargo.toml new file mode 100644 index 0000000000..76fa6bd59a --- /dev/null +++ b/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/nexus/internal-api/src/lib.rs b/nexus/internal-api/src/lib.rs new file mode 100644 index 0000000000..b2d68036bb --- /dev/null +++ b/nexus/internal-api/src/lib.rs @@ -0,0 +1,591 @@ +// 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::api_description { + // Named something different to let 'import nexus_internal_api::*;' work. + module = "nexus_internal_api_mod", +}] +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 + /// disk_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 { + #[serde(rename = "saga_id")] + 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/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/bin/nexus.rs b/nexus/src/bin/nexus.rs index 452e033ce6..33870b39e3 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)] @@ -27,19 +26,10 @@ struct Args { short = 'O', long = "openapi", help = "Print the external OpenAPI Spec document and exit", - conflicts_with = "openapi_internal", action )] openapi: bool, - #[clap( - short = 'I', - long = "openapi-internal", - help = "Print the internal OpenAPI Spec document and exit", - action - )] - openapi_internal: bool, - #[clap(name = "CONFIG_FILE_PATH", action)] config_file_path: Option, } @@ -56,8 +46,6 @@ async fn do_run() -> Result<(), CmdError> { if args.openapi { run_openapi_external().map_err(|err| CmdError::Failure(anyhow!(err))) - } else if args.openapi_internal { - run_openapi_internal().map_err(|err| CmdError::Failure(anyhow!(err))) } else { let config_path = match args.config_file_path { Some(path) => path, diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 1f185ae820..d23f0d035a 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -34,13 +34,13 @@ use dropshot::{ApiDescription, StreamingBody}; use dropshot::{ApiDescriptionRegisterError, HttpError}; 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; @@ -7023,7 +7023,7 @@ async fn probe_list( probes, &|_, p: &ProbeInfo| match paginated_by { PaginatedBy::Id(_) => NameOrId::Id(p.id), - PaginatedBy::Name(_) => NameOrId::Name(p.name.clone().into()), + PaginatedBy::Name(_) => NameOrId::Name(p.name.clone()), }, )?)) }; diff --git a/nexus/src/internal_api/http_entrypoints.rs b/nexus/src/internal_api/http_entrypoints.rs index 8e7b39c111..f324ea787d 100644 --- a/nexus/src/internal_api/http_entrypoints.rs +++ b/nexus/src/internal_api/http_entrypoints.rs @@ -6,9 +6,7 @@ use super::params::{OximeterInfo, RackInitializationRequest}; use crate::context::ApiContext; -use dropshot::endpoint; use dropshot::ApiDescription; -use dropshot::ApiDescriptionRegisterError; use dropshot::FreeformBody; use dropshot::HttpError; use dropshot::HttpResponseCreated; @@ -21,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; @@ -36,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; @@ -51,1067 +50,805 @@ 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::GenericUuid; use omicron_uuid_kinds::InstanceUuid; -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<(), ApiDescriptionRegisterError> { - 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)?; + nexus_internal_api_mod::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, - &InstanceUuid::from_untyped_uuid(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, + &InstanceUuid::from_untyped_uuid(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/src/lib.rs b/nexus/src/lib.rs index e48ec83d98..a359ead038 100644 --- a/nexus/src/lib.rs +++ b/nexus/src/lib.rs @@ -65,16 +65,6 @@ pub fn run_openapi_external() -> Result<(), String> { .map_err(|e| e.to_string()) } -pub fn run_openapi_internal() -> Result<(), String> { - internal_api() - .openapi("Nexus internal API", "0.0.1") - .description("Nexus internal API") - .contact_url("https://oxide.computer") - .contact_email("api@oxide.computer") - .write(&mut std::io::stdout()) - .map_err(|e| e.to_string()) -} - /// A partially-initialized Nexus server, which exposes an internal interface, /// but is not ready to receive external requests. pub struct InternalServer { diff --git a/nexus/tests/integration_tests/commands.rs b/nexus/tests/integration_tests/commands.rs index 1a6e717345..3e133e8681 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/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 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/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 6d380891aa..27430c7599 100644 --- a/openapi/nexus-internal.json +++ b/openapi/nexus-internal.json @@ -628,8 +628,8 @@ }, "/disk/{disk_id}/remove-read-only-parent": { "post": { - "summary": "Request removal of a read_only_parent from a disk", - "description": "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.", + "summary": "Request removal of a read_only_parent from a disk.", + "description": "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 disk_remove_read_only_parent saga on it.", "operationId": "cpapi_disk_remove_read_only_parent", "parameters": [ { @@ -1347,7 +1347,7 @@ }, "/volume/{volume_id}/remove-read-only-parent": { "post": { - "summary": "Request removal of a read_only_parent from a volume", + "summary": "Request removal of a read_only_parent from a volume.", "description": "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.", "operationId": "cpapi_volume_remove_read_only_parent", "parameters": [ @@ -3201,14 +3201,6 @@ "time_updated" ] }, - "IpKind": { - "type": "string", - "enum": [ - "snat", - "floating", - "ephemeral" - ] - }, "IpNet": { "x-rust-type": { "crate": "oxnet", @@ -3940,7 +3932,7 @@ "format": "ip" }, "kind": { - "$ref": "#/components/schemas/IpKind" + "$ref": "#/components/schemas/ProbeExternalIpKind" }, "last_port": { "type": "integer", @@ -3955,6 +3947,14 @@ "last_port" ] }, + "ProbeExternalIpKind": { + "type": "string", + "enum": [ + "snat", + "floating", + "ephemeral" + ] + }, "ProbeInfo": { "type": "object", "properties": { diff --git a/openapi/nexus.json b/openapi/nexus.json index 51278f3f6d..c9d85a8ee3 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -15638,14 +15638,6 @@ } ] }, - "IpKind": { - "type": "string", - "enum": [ - "snat", - "floating", - "ephemeral" - ] - }, "IpNet": { "x-rust-type": { "crate": "oxnet", @@ -16871,7 +16863,7 @@ "format": "ip" }, "kind": { - "$ref": "#/components/schemas/IpKind" + "$ref": "#/components/schemas/ProbeExternalIpKind" }, "last_port": { "type": "integer", @@ -16886,6 +16878,14 @@ "last_port" ] }, + "ProbeExternalIpKind": { + "type": "string", + "enum": [ + "snat", + "floating", + "ephemeral" + ] + }, "ProbeInfo": { "type": "object", "properties": { diff --git a/workspace-hack/Cargo.toml b/workspace-hack/Cargo.toml index cd76fd2611..796cf0bf63 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"] } @@ -95,7 +96,7 @@ semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.204", features = ["alloc", "derive", "rc"] } serde_json = { version = "1.0.120", 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" } @@ -129,7 +130,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"] } @@ -199,7 +201,7 @@ semver = { version = "1.0.23", features = ["serde"] } serde = { version = "1.0.204", features = ["alloc", "derive", "rc"] } serde_json = { version = "1.0.120", 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" }