Skip to content

Commit 289146b

Browse files
authored
[nexus] Project-scoped OxQL endpoint (#6873)
#7047 moves the existing `/v1/timeseries/query` endpoint (which requires the fleet viewer role) to `/v1/system/timeseries/query`. This PR is on top of #7047 and adds `/v1/timeseries/query?project=my-project`, using a sneaky trick to let us ensure that the user has read permissions on a project before we let them see metrics for that project. See #5298 (comment) for a discussion of the OxQL authz problem more broadly. - [x] Add `/v1/timeseries/query` with required project query param - [x] Integration tests showing the authz works in an expected scenarios - ~~Add a list schemas endpoint that only lists schemas with a `project_id` field~~ - ~~Move schema filtering logic inside oximeter client~~ - ~~Fully test schema list endpoint~~ ## The trick 1. Require the user to say (in a query param) what project they're interested in 2. Look up that project and make sure they can read it 3. Jam `| filter silo_id == "<silo_id>" && project_id == "<project_id>"` on the end of whatever query they passed in It sounds silly, but I talked it over with @bnaecker and we couldn't find any holes. If the user tries to fetch metrics from another project inside their query, the query will end up with the filter `project_id == "def" && project_id == "xyz"` and the result set will always be empty. If they try to query a metric that doesn't have a `project_id` on it, it will error out. It works! ## API design Initially I had the endpoint as `/v1/timeseries/query/project/{project}`. This is really horrible. It should be `/projects/`, but that doesn't feel any better. I also considered `/v1/projects/{project}/timeseries/query`, which has some precedent: https://github.com/oxidecomputer/omicron/blob/45f5f1cc2c7eec2a1de0a5143e85e0794134f175/nexus/external-api/output/nexus_tags.txt#L59-L64 But it also feels awful. I like the query param approach. Right now, there are only fleet-scoped metrics and project-scoped metrics, so requiring the project ID makes sense, but the query params are nicely flexible in that we could make project optional or add other optional fields and just do different auth checks based on what's passed in. Neither path nor operation ID mention projects. <details> <summary>Examples of fleet- and project-scoped metrics</summary https://github.com/oxidecomputer/omicron/blob/45f5f1cc2c7eec2a1de0a5143e85e0794134f175/oximeter/oximeter/schema/virtual-machine.toml#L3-L18 https://github.com/oxidecomputer/omicron/blob/45f5f1cc2c7eec2a1de0a5143e85e0794134f175/oximeter/oximeter/schema/switch-data-link.toml#L3-L19 </details> ## No list endpoint yet I backed out the schema list endpoint. We can't list project-scoped schemas because `authz_scope` is not in the database! See #5942. Currently the schema list endpoint hard-codes `authz_scope: Fleet`. https://github.com/oxidecomputer/omicron/blob/69de8b6288fde36fbcd6cabb4d632d62851230ad/oximeter/db/src/model/from_block.rs#L142 I am using the tag `hidden` on the query endpoint so that it goes in the OpenAPI definition (so I can use it in the console) but it will not show up on the docs site.
1 parent 9285a7c commit 289146b

File tree

7 files changed

+337
-40
lines changed

7 files changed

+337
-40
lines changed

nexus/external-api/output/nexus_tags.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ probe_create POST /experimental/v1/probes
3030
probe_delete DELETE /experimental/v1/probes/{probe}
3131
probe_list GET /experimental/v1/probes
3232
probe_view GET /experimental/v1/probes/{probe}
33+
timeseries_query POST /v1/timeseries/query
3334

3435
API operations found with tag "images"
3536
OPERATION ID METHOD URL PATH

nexus/external-api/src/lib.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2567,6 +2567,26 @@ pub trait NexusExternalApi {
25672567
body: TypedBody<params::TimeseriesQuery>,
25682568
) -> Result<HttpResponseOk<views::OxqlQueryResult>, HttpError>;
25692569

2570+
// TODO: list endpoint for project-scoped schemas is blocked on
2571+
// https://github.com/oxidecomputer/omicron/issues/5942: the authz scope for
2572+
// each schema is not stored in Clickhouse yet.
2573+
2574+
/// Run project-scoped timeseries query
2575+
///
2576+
/// Queries are written in OxQL. Project must be specified by name or ID in
2577+
/// URL query parameter. The OxQL query will only return timeseries data
2578+
/// from the specified project.
2579+
#[endpoint {
2580+
method = POST,
2581+
path = "/v1/timeseries/query",
2582+
tags = ["hidden"],
2583+
}]
2584+
async fn timeseries_query(
2585+
rqctx: RequestContext<Self::Context>,
2586+
query_params: Query<params::ProjectSelector>,
2587+
body: TypedBody<params::TimeseriesQuery>,
2588+
) -> Result<HttpResponseOk<views::OxqlQueryResult>, HttpError>;
2589+
25702590
// Updates
25712591

25722592
/// Upload TUF repository

nexus/src/app/metrics.rs

Lines changed: 47 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -140,28 +140,52 @@ impl super::Nexus {
140140
self.timeseries_client
141141
.oxql_query(query)
142142
.await
143-
.map(|result| {
144-
// TODO-observability: The query method returns information
145-
// about the duration of the OxQL query and the database
146-
// resource usage for each contained SQL query. We should
147-
// publish this as a timeseries itself, so that we can track
148-
// improvements to query processing.
149-
//
150-
// For now, simply return the tables alone.
151-
result.tables
152-
})
153-
.map_err(|e| match e {
154-
oximeter_db::Error::DatabaseUnavailable(_)
155-
| oximeter_db::Error::Connection(_) => {
156-
Error::ServiceUnavailable {
157-
internal_message: e.to_string(),
158-
}
159-
}
160-
oximeter_db::Error::Oxql(_)
161-
| oximeter_db::Error::TimeseriesNotFound(_) => {
162-
Error::invalid_request(e.to_string())
163-
}
164-
_ => Error::InternalError { internal_message: e.to_string() },
165-
})
143+
// TODO-observability: The query method returns information
144+
// about the duration of the OxQL query and the database
145+
// resource usage for each contained SQL query. We should
146+
// publish this as a timeseries itself, so that we can track
147+
// improvements to query processing.
148+
//
149+
// For now, simply return the tables alone.
150+
.map(|result| result.tables)
151+
.map_err(map_timeseries_err)
152+
}
153+
154+
/// Run an OxQL query against the timeseries database, scoped to a specific project.
155+
pub(crate) async fn timeseries_query_project(
156+
&self,
157+
_opctx: &OpContext,
158+
project_lookup: &lookup::Project<'_>,
159+
query: impl AsRef<str>,
160+
) -> Result<Vec<oxql_types::Table>, Error> {
161+
// Ensure the user has read access to the project
162+
let (authz_silo, authz_project) =
163+
project_lookup.lookup_for(authz::Action::Read).await?;
164+
165+
// Ensure the query only refers to the project
166+
let filtered_query = format!(
167+
"{} | filter silo_id == \"{}\" && project_id == \"{}\"",
168+
query.as_ref(),
169+
authz_silo.id(),
170+
authz_project.id()
171+
);
172+
173+
self.timeseries_client
174+
.oxql_query(filtered_query)
175+
.await
176+
.map(|result| result.tables)
177+
.map_err(map_timeseries_err)
178+
}
179+
}
180+
181+
fn map_timeseries_err(e: oximeter_db::Error) -> Error {
182+
match e {
183+
oximeter_db::Error::DatabaseUnavailable(_)
184+
| oximeter_db::Error::Connection(_) => Error::unavail(&e.to_string()),
185+
oximeter_db::Error::Oxql(_)
186+
| oximeter_db::Error::TimeseriesNotFound(_) => {
187+
Error::invalid_request(e.to_string())
188+
}
189+
_ => Error::internal_error(&e.to_string()),
166190
}
167191
}

nexus/src/external_api/http_entrypoints.rs

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

5547+
async fn timeseries_query(
5548+
rqctx: RequestContext<ApiContext>,
5549+
query_params: Query<params::ProjectSelector>,
5550+
body: TypedBody<params::TimeseriesQuery>,
5551+
) -> Result<HttpResponseOk<views::OxqlQueryResult>, HttpError> {
5552+
let apictx = rqctx.context();
5553+
let handler = async {
5554+
let nexus = &apictx.context.nexus;
5555+
let opctx =
5556+
crate::context::op_context_for_external_api(&rqctx).await?;
5557+
let project_selector = query_params.into_inner();
5558+
let query = body.into_inner().query;
5559+
let project_lookup =
5560+
nexus.project_lookup(&opctx, project_selector)?;
5561+
nexus
5562+
.timeseries_query_project(&opctx, &project_lookup, &query)
5563+
.await
5564+
.map(|tables| HttpResponseOk(views::OxqlQueryResult { tables }))
5565+
.map_err(HttpError::from)
5566+
};
5567+
apictx
5568+
.context
5569+
.external_latencies
5570+
.instrument_dropshot_handler(&rqctx, handler)
5571+
.await
5572+
}
5573+
55475574
// Updates
55485575

55495576
async fn system_update_put_repository(

nexus/tests/integration_tests/endpoints.rs

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -948,10 +948,14 @@ pub static DEMO_SILO_METRICS_URL: Lazy<String> = Lazy::new(|| {
948948
)
949949
});
950950

951-
pub static TIMESERIES_LIST_URL: Lazy<String> =
951+
pub static TIMESERIES_QUERY_URL: Lazy<String> = Lazy::new(|| {
952+
format!("/v1/timeseries/query?project={}", *DEMO_PROJECT_NAME)
953+
});
954+
955+
pub static SYSTEM_TIMESERIES_LIST_URL: Lazy<String> =
952956
Lazy::new(|| String::from("/v1/system/timeseries/schemas"));
953957

954-
pub static TIMESERIES_QUERY_URL: Lazy<String> =
958+
pub static SYSTEM_TIMESERIES_QUERY_URL: Lazy<String> =
955959
Lazy::new(|| String::from("/v1/system/timeseries/query"));
956960

957961
pub static DEMO_TIMESERIES_QUERY: Lazy<params::TimeseriesQuery> =
@@ -2208,7 +2212,18 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
22082212
},
22092213

22102214
VerifyEndpoint {
2211-
url: &TIMESERIES_LIST_URL,
2215+
url: &TIMESERIES_QUERY_URL,
2216+
visibility: Visibility::Protected,
2217+
unprivileged_access: UnprivilegedAccess::None,
2218+
allowed_methods: vec![
2219+
AllowedMethod::Post(
2220+
serde_json::to_value(&*DEMO_TIMESERIES_QUERY).unwrap()
2221+
),
2222+
],
2223+
},
2224+
2225+
VerifyEndpoint {
2226+
url: &SYSTEM_TIMESERIES_LIST_URL,
22122227
visibility: Visibility::Public,
22132228
unprivileged_access: UnprivilegedAccess::None,
22142229
allowed_methods: vec![
@@ -2217,7 +2232,7 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
22172232
},
22182233

22192234
VerifyEndpoint {
2220-
url: &TIMESERIES_QUERY_URL,
2235+
url: &SYSTEM_TIMESERIES_QUERY_URL,
22212236
visibility: Visibility::Public,
22222237
unprivileged_access: UnprivilegedAccess::None,
22232238
allowed_methods: vec![

0 commit comments

Comments
 (0)