diff --git a/Cargo.lock b/Cargo.lock index dd35052592..15ce404eb2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6117,6 +6117,7 @@ dependencies = [ "openapi-lint", "openapiv3", "owo-colors", + "oximeter-api", "serde_json", "similar", "supports-color", @@ -6313,6 +6314,19 @@ dependencies = [ "uuid", ] +[[package]] +name = "oximeter-api" +version = "0.1.0" +dependencies = [ + "chrono", + "dropshot", + "omicron-common", + "omicron-workspace-hack", + "schemars", + "serde", + "uuid", +] + [[package]] name = "oximeter-client" version = "0.1.0" @@ -6349,6 +6363,7 @@ dependencies = [ "openapi-lint", "openapiv3", "oximeter", + "oximeter-api", "oximeter-client", "oximeter-db", "rand 0.8.5", diff --git a/Cargo.toml b/Cargo.toml index a9832f636f..53c16ceba5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -61,6 +61,7 @@ members = [ "nexus/test-utils-macros", "nexus/test-utils", "nexus/types", + "oximeter/api", "oximeter/collector", "oximeter/db", "oximeter/impl", @@ -158,6 +159,7 @@ default-members = [ "nexus/test-utils-macros", "nexus/test-utils", "nexus/types", + "oximeter/api", "oximeter/collector", "oximeter/db", "oximeter/impl", @@ -397,6 +399,7 @@ opte-ioctl = { git = "https://github.com/oxidecomputer/opte", rev = "3dc9a3dd8d3 oso = "0.27" owo-colors = "4.0.0" oximeter = { path = "oximeter/oximeter" } +oximeter-api = { path = "oximeter/api" } oximeter-client = { path = "clients/oximeter-client" } oximeter-db = { path = "oximeter/db/", default-features = false } oximeter-collector = { path = "oximeter/collector" } diff --git a/dev-tools/openapi-manager/Cargo.toml b/dev-tools/openapi-manager/Cargo.toml index aa0cfacfd5..f0c0f744bd 100644 --- a/dev-tools/openapi-manager/Cargo.toml +++ b/dev-tools/openapi-manager/Cargo.toml @@ -22,6 +22,7 @@ omicron-workspace-hack.workspace = true openapiv3.workspace = true openapi-lint.workspace = true owo-colors.workspace = true +oximeter-api.workspace = true serde_json.workspace = true similar.workspace = true supports-color.workspace = true diff --git a/dev-tools/openapi-manager/src/spec.rs b/dev-tools/openapi-manager/src/spec.rs index 83f0f4dd57..41952f1123 100644 --- a/dev-tools/openapi-manager/src/spec.rs +++ b/dev-tools/openapi-manager/src/spec.rs @@ -45,6 +45,16 @@ pub fn all_apis() -> Vec { filename: "nexus-internal.json", extra_validation: None, }, + ApiSpec { + title: "Oxide Oximeter API", + version: "0.0.1", + description: "API for interacting with oximeter", + boundary: ApiBoundary::Internal, + api_description: + oximeter_api::oximeter_api_mod::stub_api_description, + filename: "oximeter.json", + extra_validation: None, + }, ApiSpec { title: "Oxide Technician Port Control Service", version: "0.0.1", diff --git a/openapi/oximeter.json b/openapi/oximeter.json index d4a37957ab..f596ac6ee6 100644 --- a/openapi/oximeter.json +++ b/openapi/oximeter.json @@ -12,6 +12,7 @@ "paths": { "/info": { "get": { + "summary": "Return identifying information about this collector.", "operationId": "collector_info", "responses": { "200": { @@ -35,6 +36,7 @@ }, "/producers": { "get": { + "summary": "List all producers.", "operationId": "producers_list", "parameters": [ { @@ -81,6 +83,7 @@ } }, "post": { + "summary": "Handle a request from Nexus to register a new producer with this collector.", "operationId": "producers_post", "requestBody": { "content": { @@ -107,6 +110,7 @@ }, "/producers/{producer_id}": { "delete": { + "summary": "Delete a producer by ID.", "operationId": "producer_delete", "parameters": [ { diff --git a/oximeter/api/Cargo.toml b/oximeter/api/Cargo.toml new file mode 100644 index 0000000000..72189aa645 --- /dev/null +++ b/oximeter/api/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "oximeter-api" +version = "0.1.0" +edition = "2021" + +[lints] +workspace = true + +[dependencies] +chrono.workspace = true +dropshot.workspace = true +omicron-common.workspace = true +omicron-workspace-hack.workspace = true +schemars.workspace = true +serde.workspace = true +uuid.workspace = true diff --git a/oximeter/api/src/lib.rs b/oximeter/api/src/lib.rs new file mode 100644 index 0000000000..3cda501fa7 --- /dev/null +++ b/oximeter/api/src/lib.rs @@ -0,0 +1,79 @@ +// 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 chrono::{DateTime, Utc}; +use dropshot::{ + EmptyScanParams, HttpError, HttpResponseDeleted, HttpResponseOk, + HttpResponseUpdatedNoContent, PaginationParams, Query, RequestContext, + ResultsPage, TypedBody, +}; +use omicron_common::api::internal::nexus::ProducerEndpoint; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +#[dropshot::api_description { + module = "oximeter_api_mod", +}] +pub trait OximeterApi { + type Context; + + /// Handle a request from Nexus to register a new producer with this collector. + #[endpoint { + method = POST, + path = "/producers", + }] + async fn producers_post( + request_context: RequestContext, + body: TypedBody, + ) -> Result; + + /// List all producers. + #[endpoint { + method = GET, + path = "/producers", + }] + async fn producers_list( + request_context: RequestContext, + query: Query>, + ) -> Result>, HttpError>; + + /// Delete a producer by ID. + #[endpoint { + method = DELETE, + path = "/producers/{producer_id}", + }] + async fn producer_delete( + request_context: RequestContext, + path: dropshot::Path, + ) -> Result; + + /// Return identifying information about this collector. + #[endpoint { + method = GET, + path = "/info", + }] + async fn collector_info( + request_context: RequestContext, + ) -> Result, HttpError>; +} + +/// Parameters for paginating the list of producers. +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub struct ProducerPage { + pub id: Uuid, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub struct ProducerIdPathParams { + pub producer_id: Uuid, +} + +#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] +pub struct CollectorInfo { + /// The collector's UUID. + pub id: Uuid, + /// Last time we refreshed our producer list with Nexus. + pub last_refresh: Option>, +} diff --git a/oximeter/collector/Cargo.toml b/oximeter/collector/Cargo.toml index 01f484f5f4..cd1e012e5c 100644 --- a/oximeter/collector/Cargo.toml +++ b/oximeter/collector/Cargo.toml @@ -19,6 +19,7 @@ internal-dns.workspace = true nexus-types.workspace = true omicron-common.workspace = true oximeter.workspace = true +oximeter-api.workspace = true oximeter-client.workspace = true oximeter-db.workspace = true rand.workspace = true diff --git a/oximeter/collector/src/bin/oximeter.rs b/oximeter/collector/src/bin/oximeter.rs index d97ae5e72e..efbc53cace 100644 --- a/oximeter/collector/src/bin/oximeter.rs +++ b/oximeter/collector/src/bin/oximeter.rs @@ -10,7 +10,6 @@ use anyhow::{anyhow, Context}; use clap::Parser; use omicron_common::cmd::fatal; use omicron_common::cmd::CmdError; -use oximeter_collector::oximeter_api; use oximeter_collector::standalone_nexus_api; use oximeter_collector::Config; use oximeter_collector::Oximeter; @@ -23,16 +22,6 @@ use std::net::SocketAddrV6; use std::path::PathBuf; use uuid::Uuid; -pub fn run_openapi() -> Result<(), String> { - oximeter_api() - .openapi("Oxide Oximeter API", "0.0.1") - .description("API for interacting with oximeter") - .contact_url("https://oxide.computer") - .contact_email("api@oxide.computer") - .write(&mut std::io::stdout()) - .map_err(|e| e.to_string()) -} - pub fn run_standalone_openapi() -> Result<(), String> { standalone_nexus_api() .openapi("Oxide Nexus API", "0.0.1") @@ -47,9 +36,6 @@ pub fn run_standalone_openapi() -> Result<(), String> { #[derive(Parser)] #[clap(name = "oximeter", about = "See README.adoc for more information")] enum Args { - /// Print the external OpenAPI Spec document and exit - Openapi, - /// Start an Oximeter server Run { /// Path to TOML file with configuration for the server @@ -133,9 +119,6 @@ async fn main() { async fn do_run() -> Result<(), CmdError> { let args = Args::parse(); match args { - Args::Openapi => { - run_openapi().map_err(|err| CmdError::Failure(anyhow!(err))) - } Args::Run { config_file, id, address } => { let config = Config::from_file(config_file).unwrap(); let args = OximeterArguments { id, address }; diff --git a/oximeter/collector/src/http_entrypoints.rs b/oximeter/collector/src/http_entrypoints.rs index e876ed047d..daf75bbbd1 100644 --- a/oximeter/collector/src/http_entrypoints.rs +++ b/oximeter/collector/src/http_entrypoints.rs @@ -7,9 +7,6 @@ // Copyright 2023 Oxide Computer Company use crate::OximeterAgent; -use chrono::DateTime; -use chrono::Utc; -use dropshot::endpoint; use dropshot::ApiDescription; use dropshot::EmptyScanParams; use dropshot::HttpError; @@ -23,117 +20,73 @@ use dropshot::ResultsPage; use dropshot::TypedBody; use dropshot::WhichPage; use omicron_common::api::internal::nexus::ProducerEndpoint; -use schemars::JsonSchema; -use serde::Deserialize; -use serde::Serialize; +use oximeter_api::*; use std::sync::Arc; -use uuid::Uuid; // Build the HTTP API internal to the control plane pub fn oximeter_api() -> ApiDescription> { - let mut api = ApiDescription::new(); - api.register(producers_post) - .expect("Could not register producers_post API handler"); - api.register(producers_list) - .expect("Could not register producers_list API handler"); - api.register(producer_delete) - .expect("Could not register producers_delete API handler"); - api.register(collector_info) - .expect("Could not register collector_info API handler"); - api + oximeter_api_mod::api_description::() + .expect("registered entrypoints") } -// Handle a request from Nexus to register a new producer with this collector. -#[endpoint { - method = POST, - path = "/producers", -}] -async fn producers_post( - request_context: RequestContext>, - body: TypedBody, -) -> Result { - let agent = request_context.context(); - let producer_info = body.into_inner(); - agent - .register_producer(producer_info) - .await - .map_err(HttpError::from) - .map(|_| HttpResponseUpdatedNoContent()) -} +enum OximeterApiImpl {} -// Parameters for paginating the list of producers. -#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] -struct ProducerPage { - id: Uuid, -} +impl OximeterApi for OximeterApiImpl { + type Context = Arc; -// List all producers -#[endpoint { - method = GET, - path = "/producers", -}] -async fn producers_list( - request_context: RequestContext>, - query: Query>, -) -> Result>, HttpError> { - let agent = request_context.context(); - let pagination = query.into_inner(); - let limit = request_context.page_limit(&pagination)?.get() as usize; - let start = match &pagination.page { - WhichPage::First(..) => None, - WhichPage::Next(ProducerPage { id }) => Some(*id), - }; - let producers = agent.list_producers(start, limit).await; - ResultsPage::new( - producers, - &EmptyScanParams {}, - |info: &ProducerEndpoint, _| ProducerPage { id: info.id }, - ) - .map(HttpResponseOk) -} + async fn producers_post( + request_context: RequestContext, + body: TypedBody, + ) -> Result { + let agent = request_context.context(); + let producer_info = body.into_inner(); + agent + .register_producer(producer_info) + .await + .map_err(HttpError::from) + .map(|_| HttpResponseUpdatedNoContent()) + } -#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] -struct ProducerIdPathParams { - producer_id: Uuid, -} + async fn producers_list( + request_context: RequestContext>, + query: Query>, + ) -> Result>, HttpError> { + let agent = request_context.context(); + let pagination = query.into_inner(); + let limit = request_context.page_limit(&pagination)?.get() as usize; + let start = match &pagination.page { + WhichPage::First(..) => None, + WhichPage::Next(ProducerPage { id }) => Some(*id), + }; + let producers = agent.list_producers(start, limit).await; + ResultsPage::new( + producers, + &EmptyScanParams {}, + |info: &ProducerEndpoint, _| ProducerPage { id: info.id }, + ) + .map(HttpResponseOk) + } -// Delete a producer by ID. -#[endpoint { - method = DELETE, - path = "/producers/{producer_id}", -}] -async fn producer_delete( - request_context: RequestContext>, - path: dropshot::Path, -) -> Result { - let agent = request_context.context(); - let producer_id = path.into_inner().producer_id; - agent - .delete_producer(producer_id) - .await - .map_err(HttpError::from) - .map(|_| HttpResponseDeleted()) -} - -#[derive(Clone, Copy, Debug, Deserialize, JsonSchema, Serialize)] -pub struct CollectorInfo { - /// The collector's UUID. - pub id: Uuid, - /// Last time we refreshed our producer list with Nexus. - pub last_refresh: Option>, -} + async fn producer_delete( + request_context: RequestContext, + path: dropshot::Path, + ) -> Result { + let agent = request_context.context(); + let producer_id = path.into_inner().producer_id; + agent + .delete_producer(producer_id) + .await + .map_err(HttpError::from) + .map(|_| HttpResponseDeleted()) + } -// Return identifying information about this collector -#[endpoint { - method = GET, - path = "/info", -}] -async fn collector_info( - request_context: RequestContext>, -) -> Result, HttpError> { - let agent = request_context.context(); - let id = agent.id; - let last_refresh = *agent.last_refresh_time.lock().unwrap(); - let info = CollectorInfo { id, last_refresh }; - Ok(HttpResponseOk(info)) + async fn collector_info( + request_context: RequestContext, + ) -> Result, HttpError> { + let agent = request_context.context(); + let id = agent.id; + let last_refresh = *agent.last_refresh_time.lock().unwrap(); + let info = CollectorInfo { id, last_refresh }; + Ok(HttpResponseOk(info)) + } } diff --git a/oximeter/collector/tests/output/cmd-oximeter-noargs-stderr b/oximeter/collector/tests/output/cmd-oximeter-noargs-stderr index 3f0fd4726d..e1330452b2 100644 --- a/oximeter/collector/tests/output/cmd-oximeter-noargs-stderr +++ b/oximeter/collector/tests/output/cmd-oximeter-noargs-stderr @@ -3,7 +3,6 @@ See README.adoc for more information Usage: oximeter Commands: - openapi Print the external OpenAPI Spec document and exit run Start an Oximeter server standalone Run `oximeter` in standalone mode for development standalone-openapi Print the fake Nexus's standalone API diff --git a/oximeter/collector/tests/output/cmd-oximeter-openapi-stderr b/oximeter/collector/tests/output/cmd-oximeter-openapi-stderr deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/oximeter/collector/tests/test_commands.rs b/oximeter/collector/tests/test_commands.rs index d3d66be058..3a772d736c 100644 --- a/oximeter/collector/tests/test_commands.rs +++ b/oximeter/collector/tests/test_commands.rs @@ -4,14 +4,12 @@ // Copyright 2021 Oxide Computer Company -use std::{fs, path::PathBuf}; +use std::path::PathBuf; use expectorate::assert_contents; use omicron_test_utils::dev::test_cmds::{ - assert_exit_code, path_to_executable, run_command, temp_file_path, - EXIT_SUCCESS, EXIT_USAGE, + assert_exit_code, path_to_executable, run_command, EXIT_USAGE, }; -use openapiv3::OpenAPI; use subprocess::Exec; /// name of the "oximeter" executable @@ -21,15 +19,6 @@ fn path_to_oximeter() -> PathBuf { path_to_executable(CMD_OXIMETER) } -/// Write the requested string to a temporary file and return the path to that -/// file. -fn write_config(config: &str) -> PathBuf { - let file_path = temp_file_path("test_commands_config"); - eprintln!("writing temp config: {}", file_path.display()); - fs::write(&file_path, config).expect("failed to write config file"); - file_path -} - #[test] fn test_oximeter_no_args() { let exec = Exec::cmd(path_to_oximeter()); @@ -38,33 +27,3 @@ fn test_oximeter_no_args() { assert_contents("tests/output/cmd-oximeter-noargs-stdout", &stdout_text); assert_contents("tests/output/cmd-oximeter-noargs-stderr", &stderr_text); } - -#[test] -fn test_oximeter_openapi() { - // This is a little goofy: we need a config file for the program. - // (Arguably, --openapi shouldn't require a config file, but it's - // conceivable that the API metadata or the exposed endpoints would depend - // on the configuration.) We ship a config file in "examples", and we may - // as well use it here -- it would be a bug if that one didn't work for this - // purpose. However, it's not clear how to reliably locate it at runtime. - // But we do know where it is at compile time, so we load it then. - let config = include_str!("../../collector/config.toml"); - let config_path = write_config(config); - let exec = Exec::cmd(path_to_oximeter()).arg("openapi"); - let (exit_status, stdout_text, stderr_text) = run_command(exec); - fs::remove_file(&config_path).expect("failed to remove temporary file"); - assert_exit_code(exit_status, EXIT_SUCCESS, &stderr_text); - assert_contents("tests/output/cmd-oximeter-openapi-stderr", &stderr_text); - - 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/oximeter.json", &stdout_text); -}