diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index 82430ad2e0..5624514763 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -74,6 +74,7 @@ API operations found with tag "metrics" OPERATION ID METHOD URL PATH silo_metric GET /v1/metrics/{metric_name} timeseries_query POST /v1/timeseries/query +timeseries_schema_list GET /v1/timeseries/schema API operations found with tag "policy" OPERATION ID METHOD URL PATH diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index c62e1b7d27..fd2055c517 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -2567,6 +2567,22 @@ pub trait NexusExternalApi { body: TypedBody, ) -> Result, HttpError>; + /// List project-scoped timeseries schemas + /// + /// List schemas that can be queried through the `/v1/timeseries/query` endpoint. + #[endpoint { + method = GET, + path = "/v1/timeseries/schema", + tags = ["metrics"], + }] + async fn timeseries_schema_list( + rqctx: RequestContext, + pag_params: Query, + ) -> Result< + HttpResponseOk>, + HttpError, + >; + /// Run project-scoped timeseries query /// /// Queries are written in OxQL. Project must be specified by name or ID in diff --git a/nexus/src/app/metrics.rs b/nexus/src/app/metrics.rs index 9e5db02cc3..91c0558f0d 100644 --- a/nexus/src/app/metrics.rs +++ b/nexus/src/app/metrics.rs @@ -14,6 +14,7 @@ use nexus_db_queries::{ use nexus_external_api::TimeseriesSchemaPaginationParams; use nexus_types::external_api::params::SystemMetricName; use omicron_common::api::external::{Error, InternalContext}; +use oximeter::AuthzScope; use oximeter_db::{Measurement, TimeseriesSchema}; use std::num::NonZeroU32; @@ -166,7 +167,7 @@ impl super::Nexus { } /// Run an OxQL query against the timeseries database, scoped to a specific project. - pub(crate) async fn timeseries_query_project( + pub(crate) async fn project_timeseries_query( &self, _opctx: &OpContext, project_lookup: &lookup::Project<'_>, @@ -201,4 +202,45 @@ impl super::Nexus { _ => Error::InternalError { internal_message: e.to_string() }, }) } + + /// List available project-scoped timeseries schema + pub(crate) async fn project_timeseries_schema_list( + &self, + opctx: &OpContext, + pagination: &TimeseriesSchemaPaginationParams, + limit: NonZeroU32, + ) -> Result, Error> { + // any authenticated user should be able to do this + let authz_silo = opctx + .authn + .silo_required() + .internal_context("listing project-scoped timeseries schemas")?; + opctx.authorize(authz::Action::ListChildren, &authz_silo).await?; + + self.timeseries_client + .timeseries_schema_list(&pagination.page, limit) + .await + .and_then(|schemas| { + let filtered = schemas + .items + .into_iter() + .filter(|schema| schema.authz_scope == AuthzScope::Project) + .collect(); + dropshot::ResultsPage::new( + filtered, + &dropshot::EmptyScanParams {}, + |schema, _| schema.timeseries_name.clone(), + ) + .map_err(|err| oximeter_db::Error::Database(err.to_string())) + }) + .map_err(|e| match e { + oximeter_db::Error::DatabaseUnavailable(_) + | oximeter_db::Error::Connection(_) => { + Error::ServiceUnavailable { + internal_message: e.to_string(), + } + } + _ => Error::InternalError { internal_message: e.to_string() }, + }) + } } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index 740895b7e4..de23c5e6b9 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -5544,6 +5544,33 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn timeseries_schema_list( + rqctx: RequestContext, + pag_params: Query, + ) -> Result< + HttpResponseOk>, + HttpError, + > { + let apictx = rqctx.context(); + let handler = async { + let nexus = &apictx.context.nexus; + let opctx = + crate::context::op_context_for_external_api(&rqctx).await?; + let pagination = pag_params.into_inner(); + let limit = rqctx.page_limit(&pagination)?; + nexus + .project_timeseries_schema_list(&opctx, &pagination, limit) + .await + .map(HttpResponseOk) + .map_err(HttpError::from) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn timeseries_query( rqctx: RequestContext, query_params: Query, @@ -5559,7 +5586,7 @@ impl NexusExternalApi for NexusExternalApiImpl { let project_lookup = nexus.project_lookup(&opctx, project_selector)?; nexus - .timeseries_query_project(&opctx, &project_lookup, &query) + .project_timeseries_query(&opctx, &project_lookup, &query) .await .map(|tables| HttpResponseOk(views::OxqlQueryResult { tables })) .map_err(HttpError::from) diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index bb7f8c4d91..96bf65d035 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -949,6 +949,8 @@ pub static DEMO_SILO_METRICS_URL: Lazy = Lazy::new(|| { pub static TIMESERIES_QUERY_URL: Lazy = Lazy::new(|| { format!("/v1/timeseries/query?project={}", *DEMO_PROJECT_NAME) }); +pub static TIMESERIES_LIST_URL: Lazy = + Lazy::new(|| String::from("/v1/timeseries/schema")); pub static SYSTEM_TIMESERIES_LIST_URL: Lazy = Lazy::new(|| String::from("/v1/system/timeseries/schemas")); @@ -2209,6 +2211,15 @@ pub static VERIFY_ENDPOINTS: Lazy> = Lazy::new(|| { ], }, + VerifyEndpoint { + url: &TIMESERIES_LIST_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![ + AllowedMethod::GetVolatile, + ], + }, + VerifyEndpoint { url: &TIMESERIES_QUERY_URL, visibility: Visibility::Protected, diff --git a/nexus/tests/integration_tests/metrics.rs b/nexus/tests/integration_tests/metrics.rs index 0a6c3e7fb3..a12505ee5c 100644 --- a/nexus/tests/integration_tests/metrics.rs +++ b/nexus/tests/integration_tests/metrics.rs @@ -270,6 +270,33 @@ async fn test_timeseries_schema_list( let nexus_id = cptestctx.server.server_context().nexus.id(); wait_for_producer(&cptestctx.oximeter, nexus_id).await; + // We should be able to fetch the list of timeseries, and it should include + // Nexus's HTTP latency distribution. This is defined in Nexus itself, and + // should always exist after we've registered as a producer and start + // producing data. Force a collection to ensure that happens. + cptestctx.oximeter.force_collect().await; + let client = &cptestctx.external_client; + let url = "/v1/timeseries/schema"; + let schema = + objects_list_page_authz::(client, &url).await; + // request latency metric that shows up in the system endpoint is filtered out here + assert!(schema.items.is_empty()); + + // TODO: add a project-scoped metric and fetch again + // TODO: I think even unprivileged user should be able to list these +} + +/// Test that we can correctly list some timeseries schema. +#[nexus_test] +async fn test_system_timeseries_schema_list( + cptestctx: &ControlPlaneTestContext, +) { + // Nexus registers itself as a metric producer on startup, with its own UUID + // as the producer ID. Wait for this to show up in the registered lists of + // producers. + let nexus_id = cptestctx.server.server_context().nexus.id(); + wait_for_producer(&cptestctx.oximeter, nexus_id).await; + // We should be able to fetch the list of timeseries, and it should include // Nexus's HTTP latency distribution. This is defined in Nexus itself, and // should always exist after we've registered as a producer and start diff --git a/openapi/nexus.json b/openapi/nexus.json index f8195bc964..80c1134435 100644 --- a/openapi/nexus.json +++ b/openapi/nexus.json @@ -8939,6 +8939,59 @@ } } }, + "/v1/timeseries/schema": { + "get": { + "tags": [ + "metrics" + ], + "summary": "List project-scoped timeseries schemas", + "description": "List schemas that can be queried through the `/v1/timeseries/query` endpoint.", + "operationId": "timeseries_schema_list", + "parameters": [ + { + "in": "query", + "name": "limit", + "description": "Maximum number of items returned by a single call", + "schema": { + "nullable": true, + "type": "integer", + "format": "uint32", + "minimum": 1 + } + }, + { + "in": "query", + "name": "page_token", + "description": "Token returned by previous call to retrieve the subsequent page", + "schema": { + "nullable": true, + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TimeseriesSchemaResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, "/v1/users": { "get": { "tags": [