Skip to content

Commit b707fd0

Browse files
committed
silo-scoped endpoint to list project-scoped schemas
1 parent 5d69621 commit b707fd0

File tree

7 files changed

+183
-4
lines changed

7 files changed

+183
-4
lines changed

nexus/external-api/output/nexus_tags.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,7 @@ API operations found with tag "metrics"
7474
OPERATION ID METHOD URL PATH
7575
silo_metric GET /v1/metrics/{metric_name}
7676
timeseries_query POST /v1/timeseries/query
77+
timeseries_schema_list GET /v1/timeseries/schema
7778

7879
API operations found with tag "policy"
7980
OPERATION ID METHOD URL PATH

nexus/external-api/src/lib.rs

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2567,9 +2567,27 @@ pub trait NexusExternalApi {
25672567
body: TypedBody<params::TimeseriesQuery>,
25682568
) -> Result<HttpResponseOk<views::OxqlQueryResult>, HttpError>;
25692569

2570+
/// List project-scoped timeseries schemas
2571+
///
2572+
/// List schemas that can be queried through the `/v1/timeseries/query` endpoint.
2573+
#[endpoint {
2574+
method = GET,
2575+
path = "/v1/timeseries/schema",
2576+
tags = ["metrics"],
2577+
}]
2578+
async fn timeseries_schema_list(
2579+
rqctx: RequestContext<Self::Context>,
2580+
pag_params: Query<TimeseriesSchemaPaginationParams>,
2581+
) -> Result<
2582+
HttpResponseOk<ResultsPage<oximeter_types::TimeseriesSchema>>,
2583+
HttpError,
2584+
>;
2585+
25702586
/// Run project-scoped timeseries query
25712587
///
2572-
/// Queries are written in OxQL. Queries can only refer to timeseries data from the specified project.
2588+
/// Queries are written in OxQL. Project must be specified by name or ID in
2589+
/// URL query parameter. The OxQL query will only return timeseries data
2590+
/// from the specified project.
25732591
#[endpoint {
25742592
method = POST,
25752593
path = "/v1/timeseries/query",

nexus/src/app/metrics.rs

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use nexus_db_queries::{
1414
use nexus_external_api::TimeseriesSchemaPaginationParams;
1515
use nexus_types::external_api::params::SystemMetricName;
1616
use omicron_common::api::external::{Error, InternalContext};
17+
use oximeter::AuthzScope;
1718
use oximeter_db::{Measurement, TimeseriesSchema};
1819
use std::num::NonZeroU32;
1920

@@ -166,7 +167,7 @@ impl super::Nexus {
166167
}
167168

168169
/// Run an OxQL query against the timeseries database, scoped to a specific project.
169-
pub(crate) async fn timeseries_query_project(
170+
pub(crate) async fn project_timeseries_query(
170171
&self,
171172
_opctx: &OpContext,
172173
project_lookup: &lookup::Project<'_>,
@@ -201,4 +202,45 @@ impl super::Nexus {
201202
_ => Error::InternalError { internal_message: e.to_string() },
202203
})
203204
}
205+
206+
/// List available project-scoped timeseries schema
207+
pub(crate) async fn project_timeseries_schema_list(
208+
&self,
209+
opctx: &OpContext,
210+
pagination: &TimeseriesSchemaPaginationParams,
211+
limit: NonZeroU32,
212+
) -> Result<dropshot::ResultsPage<TimeseriesSchema>, Error> {
213+
// any authenticated user should be able to do this
214+
let authz_silo = opctx
215+
.authn
216+
.silo_required()
217+
.internal_context("listing project-scoped timeseries schemas")?;
218+
opctx.authorize(authz::Action::ListChildren, &authz_silo).await?;
219+
220+
self.timeseries_client
221+
.timeseries_schema_list(&pagination.page, limit)
222+
.await
223+
.and_then(|schemas| {
224+
let filtered = schemas
225+
.items
226+
.into_iter()
227+
.filter(|schema| schema.authz_scope == AuthzScope::Project)
228+
.collect();
229+
dropshot::ResultsPage::new(
230+
filtered,
231+
&dropshot::EmptyScanParams {},
232+
|schema, _| schema.timeseries_name.clone(),
233+
)
234+
.map_err(|err| oximeter_db::Error::Database(err.to_string()))
235+
})
236+
.map_err(|e| match e {
237+
oximeter_db::Error::DatabaseUnavailable(_)
238+
| oximeter_db::Error::Connection(_) => {
239+
Error::ServiceUnavailable {
240+
internal_message: e.to_string(),
241+
}
242+
}
243+
_ => Error::InternalError { internal_message: e.to_string() },
244+
})
245+
}
204246
}

nexus/src/external_api/http_entrypoints.rs

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5544,6 +5544,33 @@ impl NexusExternalApi for NexusExternalApiImpl {
55445544
.await
55455545
}
55465546

5547+
async fn timeseries_schema_list(
5548+
rqctx: RequestContext<ApiContext>,
5549+
pag_params: Query<TimeseriesSchemaPaginationParams>,
5550+
) -> Result<
5551+
HttpResponseOk<ResultsPage<oximeter_db::TimeseriesSchema>>,
5552+
HttpError,
5553+
> {
5554+
let apictx = rqctx.context();
5555+
let handler = async {
5556+
let nexus = &apictx.context.nexus;
5557+
let opctx =
5558+
crate::context::op_context_for_external_api(&rqctx).await?;
5559+
let pagination = pag_params.into_inner();
5560+
let limit = rqctx.page_limit(&pagination)?;
5561+
nexus
5562+
.project_timeseries_schema_list(&opctx, &pagination, limit)
5563+
.await
5564+
.map(HttpResponseOk)
5565+
.map_err(HttpError::from)
5566+
};
5567+
apictx
5568+
.context
5569+
.external_latencies
5570+
.instrument_dropshot_handler(&rqctx, handler)
5571+
.await
5572+
}
5573+
55475574
async fn timeseries_query(
55485575
rqctx: RequestContext<ApiContext>,
55495576
query_params: Query<params::ProjectSelector>,
@@ -5559,7 +5586,7 @@ impl NexusExternalApi for NexusExternalApiImpl {
55595586
let project_lookup =
55605587
nexus.project_lookup(&opctx, project_selector)?;
55615588
nexus
5562-
.timeseries_query_project(&opctx, &project_lookup, &query)
5589+
.project_timeseries_query(&opctx, &project_lookup, &query)
55635590
.await
55645591
.map(|tables| HttpResponseOk(views::OxqlQueryResult { tables }))
55655592
.map_err(HttpError::from)

nexus/tests/integration_tests/endpoints.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,8 @@ pub static DEMO_SILO_METRICS_URL: Lazy<String> = Lazy::new(|| {
949949
pub static TIMESERIES_QUERY_URL: Lazy<String> = Lazy::new(|| {
950950
format!("/v1/timeseries/query?project={}", *DEMO_PROJECT_NAME)
951951
});
952+
pub static TIMESERIES_LIST_URL: Lazy<String> =
953+
Lazy::new(|| String::from("/v1/timeseries/schema"));
952954

953955
pub static SYSTEM_TIMESERIES_LIST_URL: Lazy<String> =
954956
Lazy::new(|| String::from("/v1/system/timeseries/schema"));
@@ -2209,6 +2211,15 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
22092211
],
22102212
},
22112213

2214+
VerifyEndpoint {
2215+
url: &TIMESERIES_LIST_URL,
2216+
visibility: Visibility::Public,
2217+
unprivileged_access: UnprivilegedAccess::None,
2218+
allowed_methods: vec![
2219+
AllowedMethod::GetVolatile,
2220+
],
2221+
},
2222+
22122223
VerifyEndpoint {
22132224
url: &TIMESERIES_QUERY_URL,
22142225
visibility: Visibility::Protected,

nexus/tests/integration_tests/metrics.rs

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,33 @@ async fn test_timeseries_schema_list(
270270
let nexus_id = cptestctx.server.server_context().nexus.id();
271271
wait_for_producer(&cptestctx.oximeter, nexus_id).await;
272272

273+
// We should be able to fetch the list of timeseries, and it should include
274+
// Nexus's HTTP latency distribution. This is defined in Nexus itself, and
275+
// should always exist after we've registered as a producer and start
276+
// producing data. Force a collection to ensure that happens.
277+
cptestctx.oximeter.force_collect().await;
278+
let client = &cptestctx.external_client;
279+
let url = "/v1/timeseries/schema";
280+
let schema =
281+
objects_list_page_authz::<TimeseriesSchema>(client, &url).await;
282+
// request latency metric that shows up in the system endpoint is filtered out here
283+
assert!(schema.items.is_empty());
284+
285+
// TODO: add a project-scoped metric and fetch again
286+
// TODO: I think even unprivileged user should be able to list these
287+
}
288+
289+
/// Test that we can correctly list some timeseries schema.
290+
#[nexus_test]
291+
async fn test_system_timeseries_schema_list(
292+
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,
293+
) {
294+
// Nexus registers itself as a metric producer on startup, with its own UUID
295+
// as the producer ID. Wait for this to show up in the registered lists of
296+
// producers.
297+
let nexus_id = cptestctx.server.server_context().nexus.id();
298+
wait_for_producer(&cptestctx.oximeter, nexus_id).await;
299+
273300
// We should be able to fetch the list of timeseries, and it should include
274301
// Nexus's HTTP latency distribution. This is defined in Nexus itself, and
275302
// should always exist after we've registered as a producer and start

openapi/nexus.json

Lines changed: 54 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8896,7 +8896,7 @@
88968896
"metrics"
88978897
],
88988898
"summary": "Run project-scoped timeseries query",
8899-
"description": "Queries are written in OxQL. Queries can only refer to timeseries data from the specified project.",
8899+
"description": "Queries are written in OxQL. Project must be specified by name or ID in URL query parameter. The OxQL query will only return timeseries data from the specified project.",
89008900
"operationId": "timeseries_query",
89018901
"parameters": [
89028902
{
@@ -8939,6 +8939,59 @@
89398939
}
89408940
}
89418941
},
8942+
"/v1/timeseries/schema": {
8943+
"get": {
8944+
"tags": [
8945+
"metrics"
8946+
],
8947+
"summary": "List project-scoped timeseries schemas",
8948+
"description": "List schemas that can be queried through the `/v1/timeseries/query` endpoint.",
8949+
"operationId": "timeseries_schema_list",
8950+
"parameters": [
8951+
{
8952+
"in": "query",
8953+
"name": "limit",
8954+
"description": "Maximum number of items returned by a single call",
8955+
"schema": {
8956+
"nullable": true,
8957+
"type": "integer",
8958+
"format": "uint32",
8959+
"minimum": 1
8960+
}
8961+
},
8962+
{
8963+
"in": "query",
8964+
"name": "page_token",
8965+
"description": "Token returned by previous call to retrieve the subsequent page",
8966+
"schema": {
8967+
"nullable": true,
8968+
"type": "string"
8969+
}
8970+
}
8971+
],
8972+
"responses": {
8973+
"200": {
8974+
"description": "successful operation",
8975+
"content": {
8976+
"application/json": {
8977+
"schema": {
8978+
"$ref": "#/components/schemas/TimeseriesSchemaResultsPage"
8979+
}
8980+
}
8981+
}
8982+
},
8983+
"4XX": {
8984+
"$ref": "#/components/responses/Error"
8985+
},
8986+
"5XX": {
8987+
"$ref": "#/components/responses/Error"
8988+
}
8989+
},
8990+
"x-dropshot-pagination": {
8991+
"required": []
8992+
}
8993+
}
8994+
},
89428995
"/v1/users": {
89438996
"get": {
89448997
"tags": [

0 commit comments

Comments
 (0)