Skip to content

Commit 5d69621

Browse files
committed
project-scoped timeseries endpoint + tests
1 parent 38878f7 commit 5d69621

File tree

7 files changed

+304
-7
lines changed

7 files changed

+304
-7
lines changed

nexus/external-api/output/nexus_tags.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ login_saml POST /login/{silo_name}/saml/{provi
7373
API operations found with tag "metrics"
7474
OPERATION ID METHOD URL PATH
7575
silo_metric GET /v1/metrics/{metric_name}
76+
timeseries_query POST /v1/timeseries/query
7677

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

nexus/external-api/src/lib.rs

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

2570+
/// Run project-scoped timeseries query
2571+
///
2572+
/// Queries are written in OxQL. Queries can only refer to timeseries data from the specified project.
2573+
#[endpoint {
2574+
method = POST,
2575+
path = "/v1/timeseries/query",
2576+
tags = ["metrics"],
2577+
}]
2578+
async fn timeseries_query(
2579+
rqctx: RequestContext<Self::Context>,
2580+
query_params: Query<params::ProjectSelector>,
2581+
body: TypedBody<params::TimeseriesQuery>,
2582+
) -> Result<HttpResponseOk<views::OxqlQueryResult>, HttpError>;
2583+
25702584
// Updates
25712585

25722586
/// Upload TUF repository

nexus/src/app/metrics.rs

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,4 +164,41 @@ impl super::Nexus {
164164
_ => Error::InternalError { internal_message: e.to_string() },
165165
})
166166
}
167+
168+
/// Run an OxQL query against the timeseries database, scoped to a specific project.
169+
pub(crate) async fn timeseries_query_project(
170+
&self,
171+
_opctx: &OpContext,
172+
project_lookup: &lookup::Project<'_>,
173+
query: impl AsRef<str>,
174+
) -> Result<Vec<oxql_types::Table>, Error> {
175+
// Ensure the user has read access to the project
176+
let (authz_silo, authz_project) =
177+
project_lookup.lookup_for(authz::Action::Read).await?;
178+
179+
// Ensure the query only refers to the project
180+
let filtered_query = format!(
181+
"{} | filter silo_id == \"{}\" && project_id == \"{}\"",
182+
query.as_ref(),
183+
authz_silo.id(),
184+
authz_project.id()
185+
);
186+
187+
self.timeseries_client
188+
.oxql_query(filtered_query)
189+
.await
190+
.map(|result| result.tables)
191+
.map_err(|e| match e {
192+
oximeter_db::Error::DatabaseUnavailable(_) => {
193+
Error::ServiceUnavailable {
194+
internal_message: e.to_string(),
195+
}
196+
}
197+
oximeter_db::Error::Oxql(_)
198+
| oximeter_db::Error::TimeseriesNotFound(_) => {
199+
Error::invalid_request(e.to_string())
200+
}
201+
_ => Error::InternalError { internal_message: e.to_string() },
202+
})
203+
}
167204
}

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
@@ -946,10 +946,14 @@ pub static DEMO_SILO_METRICS_URL: Lazy<String> = Lazy::new(|| {
946946
)
947947
});
948948

949-
pub static TIMESERIES_LIST_URL: Lazy<String> =
949+
pub static TIMESERIES_QUERY_URL: Lazy<String> = Lazy::new(|| {
950+
format!("/v1/timeseries/query?project={}", *DEMO_PROJECT_NAME)
951+
});
952+
953+
pub static SYSTEM_TIMESERIES_LIST_URL: Lazy<String> =
950954
Lazy::new(|| String::from("/v1/system/timeseries/schema"));
951955

952-
pub static TIMESERIES_QUERY_URL: Lazy<String> =
956+
pub static SYSTEM_TIMESERIES_QUERY_URL: Lazy<String> =
953957
Lazy::new(|| String::from("/v1/system/timeseries/query"));
954958

955959
pub static DEMO_TIMESERIES_QUERY: Lazy<params::TimeseriesQuery> =
@@ -2206,7 +2210,18 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
22062210
},
22072211

22082212
VerifyEndpoint {
2209-
url: &TIMESERIES_LIST_URL,
2213+
url: &TIMESERIES_QUERY_URL,
2214+
visibility: Visibility::Protected,
2215+
unprivileged_access: UnprivilegedAccess::None,
2216+
allowed_methods: vec![
2217+
AllowedMethod::Post(
2218+
serde_json::to_value(&*DEMO_TIMESERIES_QUERY).unwrap()
2219+
),
2220+
],
2221+
},
2222+
2223+
VerifyEndpoint {
2224+
url: &SYSTEM_TIMESERIES_LIST_URL,
22102225
visibility: Visibility::Public,
22112226
unprivileged_access: UnprivilegedAccess::None,
22122227
allowed_methods: vec![
@@ -2215,7 +2230,7 @@ pub static VERIFY_ENDPOINTS: Lazy<Vec<VerifyEndpoint>> = Lazy::new(|| {
22152230
},
22162231

22172232
VerifyEndpoint {
2218-
url: &TIMESERIES_QUERY_URL,
2233+
url: &SYSTEM_TIMESERIES_QUERY_URL,
22192234
visibility: Visibility::Public,
22202235
unprivileged_access: UnprivilegedAccess::None,
22212236
allowed_methods: vec![

nexus/tests/integration_tests/metrics.rs

Lines changed: 157 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,19 @@ use crate::integration_tests::instances::{
99
};
1010
use chrono::Utc;
1111
use dropshot::test_util::ClientTestContext;
12-
use dropshot::ResultsPage;
12+
use dropshot::{HttpErrorResponseBody, ResultsPage};
1313
use http::{Method, StatusCode};
14+
use nexus_auth::authn::USER_TEST_UNPRIVILEGED;
15+
use nexus_db_queries::db::identity::Asset;
16+
use nexus_test_utils::background::activate_background_task;
1417
use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder};
1518
use nexus_test_utils::resource_helpers::{
1619
create_default_ip_pool, create_disk, create_instance, create_project,
17-
objects_list_page_authz, DiskTest,
20+
grant_iam, object_create_error, objects_list_page_authz, DiskTest,
1821
};
1922
use nexus_test_utils::ControlPlaneTestContext;
2023
use nexus_test_utils_macros::nexus_test;
24+
use nexus_types::external_api::shared::ProjectRole;
2125
use nexus_types::external_api::views::OxqlQueryResult;
2226
use nexus_types::silo::DEFAULT_SILO_ID;
2327
use omicron_test_utils::dev::poll::{wait_for_condition, CondCheckError};
@@ -287,6 +291,28 @@ async fn test_timeseries_schema_list(
287291
pub async fn timeseries_query(
288292
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,
289293
query: impl ToString,
294+
) -> Vec<oxql_types::Table> {
295+
execute_timeseries_query(cptestctx, "/v1/system/timeseries/query", query)
296+
.await
297+
}
298+
299+
pub async fn project_timeseries_query(
300+
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,
301+
project: &str,
302+
query: impl ToString,
303+
) -> Vec<oxql_types::Table> {
304+
execute_timeseries_query(
305+
cptestctx,
306+
&format!("/v1/timeseries/query?project={}", project),
307+
query,
308+
)
309+
.await
310+
}
311+
312+
async fn execute_timeseries_query(
313+
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,
314+
endpoint: &str,
315+
query: impl ToString,
290316
) -> Vec<oxql_types::Table> {
291317
// first, make sure the latest timeseries have been collected.
292318
cptestctx.oximeter.force_collect().await;
@@ -300,7 +326,7 @@ pub async fn timeseries_query(
300326
nexus_test_utils::http_testing::RequestBuilder::new(
301327
&cptestctx.external_client,
302328
http::Method::POST,
303-
"/v1/system/timeseries/query",
329+
endpoint,
304330
)
305331
.body(Some(&body)),
306332
)
@@ -527,6 +553,134 @@ async fn test_instance_watcher_metrics(
527553
assert_gte!(ts2_running, 2);
528554
}
529555

556+
#[nexus_test]
557+
async fn test_project_timeseries_query(
558+
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,
559+
) {
560+
let client = &cptestctx.external_client;
561+
562+
create_default_ip_pool(&client).await; // needed for instance create to work
563+
564+
// Create two projects
565+
let p1 = create_project(&client, "project1").await;
566+
let _p2 = create_project(&client, "project2").await;
567+
568+
// Create resources in each project
569+
let i1 = create_instance(&client, "project1", "instance1").await;
570+
let _i2 = create_instance(&client, "project2", "instance2").await;
571+
572+
let internal_client = &cptestctx.internal_client;
573+
574+
// get the instance metrics to show up
575+
let _ =
576+
activate_background_task(&internal_client, "instance_watcher").await;
577+
578+
// Query with no project specified
579+
let q1 = "get virtual_machine:check";
580+
581+
let result = project_timeseries_query(&cptestctx, "project1", q1).await;
582+
assert_eq!(result.len(), 1);
583+
assert!(result[0].timeseries().len() > 0);
584+
585+
// also works with project ID
586+
let result =
587+
project_timeseries_query(&cptestctx, &p1.identity.id.to_string(), q1)
588+
.await;
589+
assert_eq!(result.len(), 1);
590+
assert!(result[0].timeseries().len() > 0);
591+
592+
let result = project_timeseries_query(&cptestctx, "project2", q1).await;
593+
assert_eq!(result.len(), 1);
594+
assert!(result[0].timeseries().len() > 0);
595+
596+
// with project specified
597+
let q2 = &format!("{} | filter project_id == \"{}\"", q1, p1.identity.id);
598+
599+
let result = project_timeseries_query(&cptestctx, "project1", q2).await;
600+
assert_eq!(result.len(), 1);
601+
assert!(result[0].timeseries().len() > 0);
602+
603+
let result = project_timeseries_query(&cptestctx, "project2", q2).await;
604+
assert_eq!(result.len(), 1);
605+
assert_eq!(result[0].timeseries().len(), 0);
606+
607+
// with instance specified
608+
let q3 = &format!("{} | filter instance_id == \"{}\"", q1, i1.identity.id);
609+
610+
// project containing instance gives me something
611+
let result = project_timeseries_query(&cptestctx, "project1", q3).await;
612+
assert_eq!(result.len(), 1);
613+
assert_eq!(result[0].timeseries().len(), 1);
614+
615+
// should be empty or error
616+
let result = project_timeseries_query(&cptestctx, "project2", q3).await;
617+
assert_eq!(result.len(), 1);
618+
assert_eq!(result[0].timeseries().len(), 0);
619+
620+
// expect error when querying a metric that has no project_id on it
621+
let q4 = "get integration_target:integration_metric";
622+
let url = "/v1/timeseries/query?project=project1";
623+
let body = nexus_types::external_api::params::TimeseriesQuery {
624+
query: q4.to_string(),
625+
};
626+
let result =
627+
object_create_error(client, url, &body, StatusCode::BAD_REQUEST).await;
628+
assert_eq!(result.error_code.unwrap(), "InvalidRequest");
629+
// Notable that the error confirms that the metric exists and says what the
630+
// fields are. This is helpful generally, but here it would be better if
631+
// we could say something more like "you can't query this timeseries from
632+
// this endpoint"
633+
assert_eq!(result.message, "The filter expression contains identifiers that are not valid for its input timeseries. Invalid identifiers: [\"project_id\", \"silo_id\"], timeseries fields: {\"datum\", \"metric_name\", \"target_name\", \"timestamp\"}");
634+
635+
// nonexistent project
636+
let url = "/v1/timeseries/query?project=nonexistent";
637+
let body = nexus_types::external_api::params::TimeseriesQuery {
638+
query: q4.to_string(),
639+
};
640+
let result =
641+
object_create_error(client, url, &body, StatusCode::NOT_FOUND).await;
642+
assert_eq!(result.message, "not found: project with name \"nonexistent\"");
643+
644+
// unprivileged user gets 404 on project that exists, but which they can't read
645+
let url = "/v1/timeseries/query?project=project1";
646+
let body = nexus_types::external_api::params::TimeseriesQuery {
647+
query: q1.to_string(),
648+
};
649+
650+
let request = RequestBuilder::new(client, Method::POST, url)
651+
.body(Some(&body))
652+
.expect_status(Some(StatusCode::NOT_FOUND));
653+
let result = NexusRequest::new(request)
654+
.authn_as(AuthnMode::UnprivilegedUser)
655+
.execute()
656+
.await
657+
.unwrap()
658+
.parsed_body::<HttpErrorResponseBody>()
659+
.unwrap();
660+
assert_eq!(result.message, "not found: project with name \"project1\"");
661+
662+
// now grant the user access to that project only
663+
grant_iam(
664+
client,
665+
"/v1/projects/project1",
666+
ProjectRole::Viewer,
667+
USER_TEST_UNPRIVILEGED.id(),
668+
AuthnMode::PrivilegedUser,
669+
)
670+
.await;
671+
672+
// now they can access the timeseries. how cool is that
673+
let request = RequestBuilder::new(client, Method::POST, url)
674+
.body(Some(&body))
675+
.expect_status(Some(StatusCode::OK));
676+
let result = NexusRequest::new(request)
677+
.authn_as(AuthnMode::UnprivilegedUser)
678+
.execute_and_parse_unwrap::<OxqlQueryResult>()
679+
.await;
680+
assert_eq!(result.tables.len(), 1);
681+
assert_eq!(result.tables[0].timeseries().len(), 1);
682+
}
683+
530684
#[nexus_test]
531685
async fn test_mgs_metrics(
532686
cptestctx: &ControlPlaneTestContext<omicron_nexus::Server>,

0 commit comments

Comments
 (0)