diff --git a/nexus/db-model/Cargo.toml b/nexus/db-model/Cargo.toml index e4be094b39b..29544ecac69 100644 --- a/nexus/db-model/Cargo.toml +++ b/nexus/db-model/Cargo.toml @@ -34,6 +34,7 @@ parse-display.workspace = true pq-sys = "*" rand.workspace = true ref-cast.workspace = true +regex.workspace = true schemars = { workspace = true, features = ["chrono", "uuid1"] } semver.workspace = true serde.workspace = true diff --git a/nexus/db-model/src/alert.rs b/nexus/db-model/src/alert.rs index f8b90462ca4..7c46ec27561 100644 --- a/nexus/db-model/src/alert.rs +++ b/nexus/db-model/src/alert.rs @@ -9,7 +9,9 @@ use chrono::{DateTime, Utc}; use db_macros::Asset; use nexus_db_schema::schema::alert; use nexus_types::alert::AlertPayload; +use nexus_types::external_api; use nexus_types::fm::case; +use nexus_types::identity::Asset; use omicron_common::api::external::Error; use omicron_uuid_kinds::AlertUuid; use omicron_uuid_kinds::CaseKind; @@ -125,3 +127,26 @@ impl Alert { } } } + +impl From for external_api::alert::Alert { + fn from(alert: Alert) -> Self { + let identity = alert.identity(); + let Alert { + identity: _, // we already converted this above + class, + version, + payload: alert, + // internal dispatch data is not included in the API model + num_dispatched: _, + time_dispatched: _, + // internal FM case ID is not included in the API model. + case_id: _, + } = alert; + Self { + identity, + class: class.to_string(), + version: version.into(), + alert, + } + } +} diff --git a/nexus/db-model/src/alert_subscription.rs b/nexus/db-model/src/alert_subscription.rs index 87e40b4dd56..8a6b6b3a1ff 100644 --- a/nexus/db-model/src/alert_subscription.rs +++ b/nexus/db-model/src/alert_subscription.rs @@ -91,12 +91,45 @@ impl AlertSubscriptionKind { if class == AlertClass::Probe { return Err(Error::invalid_value( "alert_class", - "webhook receivers cannot subscribe to probes", + "the 'probe' alert class is a synthetic alert used \ + only for webhook liveness probes, and is not included in alert \ + lists and cannot be subscribed to", )); } Ok(Self::Exact(class)) } + + /// Returns an iterator over the alert classes that match this subscription. + /// + /// If this is an exact subscription, the iterator contains only the single + /// alert class. Otherwise, if the subscription is a glob, this returns any + /// currently existing alert classes that match the glob. + pub fn matching_classes( + &self, + ) -> Result, Error> { + use itertools::Either; + + match self { + Self::Exact(class) => Ok(Either::Left(std::iter::once(*class))), + Self::Glob(glob) => { + let regex = regex::Regex::new(&glob.regex).map_err( + |error| Error::InternalError { + internal_message: format!( + "valid alert class glob {:?} produced an invalid \ + regular expression: {error}", + glob.glob, + ), + }, + )?; + let iter = AlertClass::ALL_CLASSES + .iter() + .copied() + .filter(move |class| regex.is_match(class.as_str())); + Ok(Either::Right(iter)) + } + } + } } impl TryFrom for alert::AlertSubscription { diff --git a/nexus/db-queries/src/db/datastore/alert.rs b/nexus/db-queries/src/db/datastore/alert.rs index 7d2d0d7ebf8..953ff1c5ebd 100644 --- a/nexus/db-queries/src/db/datastore/alert.rs +++ b/nexus/db-queries/src/db/datastore/alert.rs @@ -27,6 +27,7 @@ use diesel::result::OptionalExtension; use nexus_db_errors::ErrorHandler; use nexus_db_errors::public_error_from_diesel; use nexus_db_schema::schema::alert::dsl as alert_dsl; +use nexus_types::external_api::alert as external_api; use nexus_types::fm::case::AlertRequest; use nexus_types::identity::Asset; use omicron_common::api::external::CreateResult; @@ -88,6 +89,8 @@ pub struct AlertFilters { cases: Vec>, /// Include only alerts with the specified alert classes. + /// + /// If this is empty, all alert classes will be included. classes: Vec, /// If `true`, include only alerts that have been fully dispatched. @@ -239,6 +242,45 @@ impl AlertFilters { } } +impl TryFrom for AlertFilters { + type Error = Error; + fn try_from( + params: external_api::AlertListParams, + ) -> Result { + let external_api::AlertListParams { alert_class, start_time, end_time } = + params; + + let mut filters = Self::default(); + if let Some(start_time) = start_time { + filters = filters.after(start_time)?; + } + if let Some(end_time) = end_time { + filters = filters.before(end_time)?; + } + + if let Some(alert_class) = alert_class { + let subscription = + model::AlertSubscriptionKind::try_from(alert_class)?; + let classes = subscription.matching_classes()?.collect::>(); + + // If the provided glob doesn't match any classes, give up. + if classes.is_empty() { + return Err(Error::non_resourcetype_not_found(format!( + "alert class glob '{subscription}' does not match any \ + existing alert classes" + ))); + } + + // The `AlertFilters::with_classes` method will `into_iter()` the + // argument and `extend()` the existing list of classes with it. + // This is not necessary here, since we just created the alert + // filters and are not going to add any other classes to it. + filters.classes = classes; + } + Ok(filters) + } +} + impl DataStore { /// Insert an alert row, returning the inserted alert on success. /// @@ -464,6 +506,7 @@ impl DataStore { pagparams: &DataPageParams<'_, (DateTime, Uuid)>, ) -> ListResultVec<(Alert, Option)> { opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?; + Self::alert_list_matching_query(filters, pagparams) .load_async(&*self.pool_connection_authorized(opctx).await?) .await @@ -482,6 +525,8 @@ impl DataStore { (alert_dsl::time_created, alert_dsl::id), &pagparams, ) + // The singleton probe alert should not appear in the alert list. + .filter(alert_dsl::alert_class.ne(model::AlertClass::Probe)) .left_join(marker_dsl::rendezvous_alert_created) .select(( Alert::as_select(), diff --git a/nexus/external-api/output/nexus_tags.txt b/nexus/external-api/output/nexus_tags.txt index ba630ccdc85..e2c68552507 100644 --- a/nexus/external-api/output/nexus_tags.txt +++ b/nexus/external-api/output/nexus_tags.txt @@ -195,12 +195,14 @@ OPERATION ID METHOD URL PATH alert_class_list GET /v1/alert-classes alert_delivery_list GET /v1/alert-receivers/{receiver}/deliveries alert_delivery_resend POST /v1/alerts/{alert_id}/resend +alert_list GET /v1/alerts alert_receiver_delete DELETE /v1/alert-receivers/{receiver} alert_receiver_list GET /v1/alert-receivers alert_receiver_probe POST /v1/alert-receivers/{receiver}/probe alert_receiver_subscription_add POST /v1/alert-receivers/{receiver}/subscriptions alert_receiver_subscription_remove DELETE /v1/alert-receivers/{receiver}/subscriptions/{subscription} alert_receiver_view GET /v1/alert-receivers/{receiver} +alert_view GET /v1/alerts/{alert_id} webhook_receiver_create POST /v1/webhook-receivers webhook_receiver_update PUT /v1/webhook-receivers/{receiver} webhook_secrets_add POST /v1/webhook-secrets diff --git a/nexus/external-api/src/lib.rs b/nexus/external-api/src/lib.rs index 5fb555e11ad..7d6ad70a0de 100644 --- a/nexus/external-api/src/lib.rs +++ b/nexus/external-api/src/lib.rs @@ -86,6 +86,7 @@ api_versions!([ // | date-based version should be at the top of the list. // v // (next_yyyy_mm_dd_nn, IDENT), + (2026_08_14_00, ALERT_LIST), (2026_08_12_00, SLED_SLOT), (2026_07_31_00, SET_TARGET_RELEASE_UPDATE_RECOVERY_DOCS), (2026_07_28_00, INTERNET_GATEWAY_CASCADE_DOCS), @@ -9063,6 +9064,33 @@ pub trait NexusExternalApi { // Alerts + /// List alerts + /// + /// Alerts may be filtered by alert class or alert class glob and by an + /// inclusive creation time range. + #[endpoint { + method = GET, + path = "/v1/alerts", + tags = ["system/alerts"], + versions = VERSION_ALERT_LIST.. + }] + async fn alert_list( + rqctx: RequestContext, + pagination: Query>, + ) -> Result>, HttpError>; + + /// Fetch alert + #[endpoint { + method = GET, + path = "/v1/alerts/{alert_id}", + tags = ["system/alerts"], + versions = VERSION_ALERT_LIST.. + }] + async fn alert_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, HttpError>; + /// List alert classes #[endpoint { method = GET, diff --git a/nexus/src/app/alert.rs b/nexus/src/app/alert.rs index 606c739a67a..efb9b6b8139 100644 --- a/nexus/src/app/alert.rs +++ b/nexus/src/app/alert.rs @@ -154,6 +154,7 @@ use nexus_db_queries::db::model::Alert; use nexus_db_queries::db::model::AlertClass; use nexus_db_queries::db::model::AlertDeliveryState; use nexus_db_queries::db::model::AlertDeliveryTrigger; +use nexus_db_queries::db::model::AlertSubscriptionKind; use nexus_db_queries::db::model::WebhookDelivery; use nexus_db_queries::db::model::WebhookReceiverConfig; use nexus_types::external_api::alert; @@ -237,6 +238,26 @@ impl Nexus { Ok(event) } + // + // Alerts + // + + pub async fn alert_list( + &self, + opctx: &OpContext, + params: &alert::AlertListParams, + pagparams: &DataPageParams<'_, (DateTime, Uuid)>, + ) -> ListResultVec { + let filters = params.clone().try_into()?; + Ok(self + .datastore() + .alert_list_matching(opctx, &filters, pagparams) + .await? + .into_iter() + .map(|(alert, _)| alert.into()) + .collect()) + } + // // Alert class API // @@ -257,68 +278,33 @@ impl Nexus { alert::AlertClassFilter { filter }: alert::AlertClassFilter, pagparams: DataPageParams<'_, alert::AlertClassPage>, ) -> ListResultVec { - use nexus_db_model::AlertSubscriptionKind; - - let regex = if let Some(filter) = filter { - let sub = AlertSubscriptionKind::try_from(filter)?; - let regex_string = match sub { - AlertSubscriptionKind::Exact(class) => class.as_str(), - AlertSubscriptionKind::Glob(ref glob) => glob.regex.as_str(), - }; - let re = regex::Regex::new(regex_string).map_err(|e| { - // This oughtn't happen, provided the code for producing the - // regex for a glob is correct. - Error::InternalError { - internal_message: format!( - "valid alert class globs ({sub:?}) should always \ - produce a valid regex, and yet: {e:?}" - ), - } - })?; - Some(re) - } else { - None - }; - - // If we're resuming a previous scan, figure out where to start. - let start = if let Some(alert::AlertClassPage { last_seen }) = - pagparams.marker - { - let start = AlertClass::ALL_CLASSES.iter().enumerate().find_map( - |(idx, class)| { - if class.as_str() == last_seen { Some(idx) } else { None } - }, - ); - match start { - Some(start) => start + 1, - None => return Ok(Vec::new()), - } - } else { - 0 - }; - - // This shouldn't ever happen, but...don't panic I guess. - if start > AlertClass::ALL_CLASSES.len() { - return Ok(Vec::new()); - } - - let result = AlertClass::ALL_CLASSES[start..] - .iter() - .filter_map(|&class| { + use itertools::Either; + + let subscription = + filter.map(AlertSubscriptionKind::try_from).transpose()?; + let mut classes = subscription + .as_ref() + .map(AlertSubscriptionKind::matching_classes) + .transpose()? + .map(Either::Left) + .unwrap_or(Either::Right(AlertClass::ALL_CLASSES.iter().cloned())) + .filter(|&class| { // Skip test classes, as they should not be used in the public // API, except in test builds, where we need them // for, you know... testing... - if !cfg!(test) && class.is_test() { - return None; - } - if let Some(ref regex) = regex { - if !regex.is_match(class.as_str()) { - return None; - } - } - Some(nexus_types::alert::AlertClass::from(class).into()) - }) + cfg!(test) || !class.is_test() + }); + + // If we're resuming a previous scan, advance the iterator to discard + // all earlier classes. + if let Some(alert::AlertClassPage { last_seen }) = pagparams.marker { + classes.by_ref().find(|&class| class.as_str() == last_seen); + } + + // Okay, collect the matching classes. + let result = classes .take(pagparams.limit.get() as usize) + .map(|class| nexus_types::alert::AlertClass::from(class).into()) .collect::>(); Ok(result) } diff --git a/nexus/src/external_api/http_entrypoints.rs b/nexus/src/external_api/http_entrypoints.rs index d3566242da3..1046148aca1 100644 --- a/nexus/src/external_api/http_entrypoints.rs +++ b/nexus/src/external_api/http_entrypoints.rs @@ -8913,6 +8913,58 @@ impl NexusExternalApi for NexusExternalApiImpl { .await } + async fn alert_list( + rqctx: RequestContext, + pag_params: Query>, + ) -> Result>, 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 query = pag_params.into_inner(); + let scan_params = ScanByTimeAndId::from_query(&query)?; + let pag_params = data_page_params_for(&rqctx, &query)?; + let alerts = nexus + .alert_list(&opctx, &scan_params.selector, &pag_params) + .await?; + + Ok(HttpResponseOk(ScanByTimeAndId::results_page( + &query, + alerts, + &|_, alert: &alert::Alert| { + (alert.identity.time_created, alert.identity.id) + }, + )?)) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + + async fn alert_view( + rqctx: RequestContext, + path_params: Path, + ) -> Result, 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 selector = path_params.into_inner(); + let (_, alert) = + nexus.alert_lookup(&opctx, selector)?.fetch().await?; + Ok(HttpResponseOk(alert.into())) + }; + apictx + .context + .external_latencies + .instrument_dropshot_handler(&rqctx, handler) + .await + } + async fn alert_class_list( rqctx: RequestContext, pag_params: Query< diff --git a/nexus/tests/integration_tests/alerts.rs b/nexus/tests/integration_tests/alerts.rs new file mode 100644 index 00000000000..15ae238af97 --- /dev/null +++ b/nexus/tests/integration_tests/alerts.rs @@ -0,0 +1,244 @@ +// 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/. + +//! Alerts + +use chrono::{DateTime, TimeDelta, Utc}; +use http::{Method, StatusCode}; +use nexus_db_queries::context::OpContext; +use nexus_test_utils::http_testing::{AuthnMode, NexusRequest, RequestBuilder}; +use nexus_test_utils_macros::nexus_test; +use nexus_types::alert::test_alerts; +use nexus_types::external_api::alert::{Alert, AlertListParams}; +use omicron_common::api::external::http_pagination::TimeAndIdSortMode; +use omicron_uuid_kinds::{AlertUuid, GenericUuid}; + +const ALERTS_URL: &str = "/v1/alerts"; + +type ControlPlaneTestContext = + nexus_test_utils::ControlPlaneTestContext; + +fn alert_list_query( + params: &AlertListParams, + sort_by: Option, +) -> String { + let mut query = serde_urlencoded::to_string(params) + .expect("alert list parameters should serialize"); + if let Some(sort_by) = sort_by { + if !query.is_empty() { + query.push('&'); + } + query.push_str( + &serde_urlencoded::to_string([("sort_by", sort_by)]) + .expect("alert list sort mode should serialize"), + ); + } + query +} + +async fn alert_list( + client: &dropshot::test_util::ClientTestContext, + params: &AlertListParams, + sort_by: Option, + limit: Option, +) -> Vec { + let query = alert_list_query(params, sort_by); + let collection = match NexusRequest::iter_collection_authn( + client, ALERTS_URL, &query, limit, + ) + .await + { + Ok(collection) => collection, + Err(e) => panic!("listing alerts with params '{query}' failed: {e}"), + }; + collection.all_items +} + +async fn alert_list_expect_error( + client: &dropshot::test_util::ClientTestContext, + params: &AlertListParams, + status: StatusCode, +) { + let query = alert_list_query(params, None); + let url = format!("{ALERTS_URL}?{query}"); + NexusRequest::new( + RequestBuilder::new(client, Method::GET, &url) + .expect_status(Some(status)), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute() + .await + .unwrap_or_else(|error| { + panic!("listing alerts with params '{query}' failed: {error}") + }); +} + +#[nexus_test] +async fn test_alert_list_and_view(ctx: &ControlPlaneTestContext) { + let client = &ctx.external_client; + let nexus = &ctx.server.server_context().nexus; + let datastore = nexus.datastore(); + let opctx = + OpContext::for_tests(ctx.logctx.log.new(o!()), datastore.clone()); + + let foo_id = AlertUuid::new_v4(); + let foo_bar_id = AlertUuid::new_v4(); + let quux_bar_id = AlertUuid::new_v4(); + nexus + .alert_publish( + &opctx, + foo_id, + &test_alerts::Foo(serde_json::json!({ "sequence": 1 })), + ) + .await + .expect("publishing test.foo alert"); + nexus + .alert_publish( + &opctx, + foo_bar_id, + &test_alerts::FooBar(serde_json::json!({ "sequence": 2 })), + ) + .await + .expect("publishing test.foo.bar alert"); + nexus + .alert_publish( + &opctx, + quux_bar_id, + &test_alerts::QuuxBar(serde_json::json!({ "sequence": 3 })), + ) + .await + .expect("publishing test.quux.bar alert"); + + // Giving all three rows the same timestamp makes this exercise the UUID + // component of the pagination marker, rather than merely checking that the + // marker type happens to contain a UUID. + let timestamp: DateTime = "2026-08-11T12:00:00Z".parse().unwrap(); + { + use async_bb8_diesel::AsyncRunQueryDsl; + use diesel::prelude::*; + use nexus_db_schema::schema::alert; + + let ids = [foo_id, foo_bar_id, quux_bar_id] + .map(GenericUuid::into_untyped_uuid); + diesel::update(alert::table.filter(alert::id.eq_any(ids))) + .set(( + alert::time_created.eq(timestamp), + alert::time_modified.eq(timestamp), + )) + .execute_async( + &*datastore.pool_connection_for_tests().await.unwrap(), + ) + .await + .expect("setting deterministic alert timestamps"); + } + + let mut ascending_ids = + [foo_id, foo_bar_id, quux_bar_id].map(GenericUuid::into_untyped_uuid); + ascending_ids.sort(); + + let all_test_alerts = AlertListParams { + alert_class: Some("test.**".parse().unwrap()), + start_time: None, + end_time: None, + }; + let bounded_test_alerts = AlertListParams { + start_time: Some(timestamp), + end_time: Some(timestamp), + ..all_test_alerts.clone() + }; + + // The class and time selectors are recovered from the page token: only the + // initial request carries them explicitly. + let ascending = + alert_list(client, &bounded_test_alerts, None, Some(1)).await; + assert_eq!( + ascending.iter().map(|alert| alert.identity.id).collect::>(), + ascending_ids, + ); + + let descending = alert_list( + client, + &bounded_test_alerts, + Some(TimeAndIdSortMode::TimeAndIdDescending), + Some(1), + ) + .await; + assert_eq!( + descending.iter().map(|alert| alert.identity.id).collect::>(), + ascending_ids.into_iter().rev().collect::>(), + ); + + let params_for_classes = |classes: &str| AlertListParams { + alert_class: Some(classes.parse().unwrap()), + start_time: None, + end_time: None, + }; + let exact = + alert_list(client, ¶ms_for_classes("test.foo"), None, None).await; + assert_eq!(exact.len(), 1); + assert_eq!(exact[0].identity.id, foo_id.into_untyped_uuid()); + + let glob = + alert_list(client, ¶ms_for_classes("test.foo.**"), None, None) + .await; + assert_eq!(glob.len(), 1); + assert_eq!(glob[0].identity.id, foo_bar_id.into_untyped_uuid()); + + alert_list_expect_error( + client, + ¶ms_for_classes("unmatched.**"), + StatusCode::NOT_FOUND, + ) + .await; + + assert_eq!( + alert_list( + client, + &AlertListParams { + start_time: Some(timestamp + TimeDelta::microseconds(1)), + ..all_test_alerts.clone() + }, + None, + None, + ) + .await, + Vec::new(), + ); + assert_eq!( + alert_list( + client, + &AlertListParams { + end_time: Some(timestamp - TimeDelta::microseconds(1)), + ..all_test_alerts + }, + None, + None, + ) + .await, + Vec::new(), + ); + + let view: Alert = NexusRequest::object_get( + client, + &format!("{ALERTS_URL}/{}", foo_bar_id.into_untyped_uuid()), + ) + .authn_as(AuthnMode::PrivilegedUser) + .execute_and_parse_unwrap() + .await; + assert_eq!(view.identity.id, foo_bar_id.into_untyped_uuid()); + assert_eq!(view.class, "test.foo.bar"); + assert_eq!(view.version, 0); + assert_eq!(view.alert, serde_json::json!({ "sequence": 2 })); + + alert_list_expect_error( + client, + &AlertListParams { + alert_class: None, + start_time: Some(timestamp + TimeDelta::microseconds(1)), + end_time: Some(timestamp), + }, + StatusCode::BAD_REQUEST, + ) + .await; +} diff --git a/nexus/tests/integration_tests/endpoints.rs b/nexus/tests/integration_tests/endpoints.rs index b97572a6740..e348450598b 100644 --- a/nexus/tests/integration_tests/endpoints.rs +++ b/nexus/tests/integration_tests/endpoints.rs @@ -1525,6 +1525,9 @@ pub static DEMO_TARGET_RELEASE: LazyLock = }); // Alerts +pub static ALERTS_URL: &'static str = "/v1/alerts"; +pub static DEMO_ALERT_URL: &'static str = + "/v1/alerts/001de000-7768-4000-8000-000000000001"; pub static ALERT_CLASSES_URL: &'static str = "/v1/alert-classes"; pub static ALERT_RECEIVERS_URL: &'static str = "/v1/alert-receivers"; pub static WEBHOOK_RECEIVERS_URL: &'static str = "/v1/webhook-receivers"; @@ -3574,6 +3577,18 @@ pub static VERIFY_ENDPOINTS: LazyLock> = LazyLock::new( ], }, // Alerts + VerifyEndpoint { + url: &ALERTS_URL, + visibility: Visibility::Public, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, + VerifyEndpoint { + url: &DEMO_ALERT_URL, + visibility: Visibility::Protected, + unprivileged_access: UnprivilegedAccess::None, + allowed_methods: vec![AllowedMethod::Get], + }, VerifyEndpoint { url: &WEBHOOK_RECEIVERS_URL, visibility: Visibility::Public, diff --git a/nexus/tests/integration_tests/mod.rs b/nexus/tests/integration_tests/mod.rs index dc1fc84c2b4..195ccbb0f11 100644 --- a/nexus/tests/integration_tests/mod.rs +++ b/nexus/tests/integration_tests/mod.rs @@ -9,6 +9,7 @@ mod address_lots; mod affinity; +mod alerts; mod allow_list; mod audit_log; mod authn_http; diff --git a/nexus/tests/integration_tests/webhooks.rs b/nexus/tests/integration_tests/webhooks.rs index 05a227e8f1f..b8b99097d0d 100644 --- a/nexus/tests/integration_tests/webhooks.rs +++ b/nexus/tests/integration_tests/webhooks.rs @@ -579,11 +579,10 @@ async fn test_cannot_subscribe_to_probes(cptestctx: &ControlPlaneTestContext) { http::StatusCode::BAD_REQUEST, ) .await; - assert!( - dbg!(&error) - .message - .contains("webhook receivers cannot subscribe to probes"), - ); + assert!(dbg!(&error).message.contains( + "the 'probe' alert class is a synthetic alert used only for \ + webhook liveness probes" + ),); } #[nexus_test] diff --git a/nexus/types/versions/src/alert_list/alert.rs b/nexus/types/versions/src/alert_list/alert.rs new file mode 100644 index 00000000000..04d966c7180 --- /dev/null +++ b/nexus/types/versions/src/alert_list/alert.rs @@ -0,0 +1,62 @@ +// 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 schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +use crate::v2025_11_20_00::alert::AlertSubscription; +use crate::v2025_11_20_00::asset::AssetIdentityMetadata; + +/// An alert. +/// +/// Alerts provide notifications about events that occurred in the system at a +/// point in time. See the guide-level documentation on alerts for details. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema)] +pub struct Alert { + #[serde(flatten)] + pub identity: AssetIdentityMetadata, + /// The alert's class. + /// + /// See the guide-level documentation on alerts for details on alert + /// classes. + pub class: String, + /// The schema version of this alert's data payload. + /// + /// Alert schemas are versioned on a per-alert-class basis. The schema + /// version for a particular alert class does not correspond to an Oxide API + /// version. Clients should expect to encounter earlier schema versions when + /// retrieving alerts recorded by an earlier version of the system software. + /// + /// See the guide-level documentation on alerts for details. + pub version: u32, + /// The alert's data payload. + /// + /// The schema for this object depends on the alert class and version. + pub alert: serde_json::Value, +} + +/// Query parameters for listing alerts +#[derive(Clone, Debug, Deserialize, JsonSchema, PartialEq, Serialize)] +pub struct AlertListParams { + /// Optional alert class or glob pattern used to filter alerts. + /// + /// If this is included, only alerts with the specified class or matching + /// the glob pattern (as appropriate) will be returned. Otherwise, alerts of + /// all classes will be returned. + /// + /// See the guide-level documentation on alerts for details on alert classes + /// and alert class glob patterns. + pub alert_class: Option, + /// Inclusive lower bound on the alert creation time. + /// + /// If this is included, only alerts created at or after this time will be + /// returned. + pub start_time: Option>, + /// Inclusive upper bound on the alert creation time + /// + /// If this is included, only alerts created at or before this time will be + /// returned. + pub end_time: Option>, +} diff --git a/nexus/types/versions/src/alert_list/mod.rs b/nexus/types/versions/src/alert_list/mod.rs new file mode 100644 index 00000000000..841d5bc89bc --- /dev/null +++ b/nexus/types/versions/src/alert_list/mod.rs @@ -0,0 +1,10 @@ +// 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/. + +//! Version `ALERT_LIST` of the Nexus external API. +//! +//! Adds an `alert_list` endpoint, and brings the existing alert types into the +//! versioned API. + +pub mod alert; diff --git a/nexus/types/versions/src/latest.rs b/nexus/types/versions/src/latest.rs index 69e6dcd2e25..38d11bb09cf 100644 --- a/nexus/types/versions/src/latest.rs +++ b/nexus/types/versions/src/latest.rs @@ -56,6 +56,9 @@ pub mod alert { pub use crate::v2025_11_20_00::alert::WebhookSecretCreate; pub use crate::v2025_11_20_00::alert::WebhookSecretSelector; pub use crate::v2025_11_20_00::alert::WebhookSecrets; + + pub use crate::v2026_08_14_00::alert::Alert; + pub use crate::v2026_08_14_00::alert::AlertListParams; } pub mod audit { diff --git a/nexus/types/versions/src/lib.rs b/nexus/types/versions/src/lib.rs index 98bd3cde7a5..231850a6c55 100644 --- a/nexus/types/versions/src/lib.rs +++ b/nexus/types/versions/src/lib.rs @@ -99,3 +99,5 @@ pub mod v2026_06_10_00; pub mod v2026_06_11_00; #[path = "sled_slot/mod.rs"] pub mod v2026_08_12_00; +#[path = "alert_list/mod.rs"] +pub mod v2026_08_14_00; diff --git a/openapi/nexus/nexus-2026081200.0.0-6594c1.json.gitstub b/openapi/nexus/nexus-2026081200.0.0-6594c1.json.gitstub new file mode 100644 index 00000000000..62a28597aaf --- /dev/null +++ b/openapi/nexus/nexus-2026081200.0.0-6594c1.json.gitstub @@ -0,0 +1 @@ +906f6804f3c4393d2f15f97835ba9797b41cc75b:openapi/nexus/nexus-2026081200.0.0-6594c1.json diff --git a/openapi/nexus/nexus-2026081200.0.0-6594c1.json b/openapi/nexus/nexus-2026081400.0.0-b9cf69.json similarity index 99% rename from openapi/nexus/nexus-2026081200.0.0-6594c1.json rename to openapi/nexus/nexus-2026081400.0.0-b9cf69.json index e085607d05a..98675905358 100644 --- a/openapi/nexus/nexus-2026081200.0.0-6594c1.json +++ b/openapi/nexus/nexus-2026081400.0.0-b9cf69.json @@ -7,7 +7,7 @@ "url": "https://oxide.computer", "email": "api@oxide.computer" }, - "version": "2026081200.0.0" + "version": "2026081400.0.0" }, "paths": { "/device/auth": { @@ -1673,6 +1673,133 @@ } } }, + "/v1/alerts": { + "get": { + "tags": [ + "system/alerts" + ], + "summary": "List alerts", + "description": "Alerts may be filtered by alert class or alert class glob and by an inclusive creation time range.", + "operationId": "alert_list", + "parameters": [ + { + "in": "query", + "name": "alert_class", + "description": "Optional alert class or glob pattern used to filter alerts.\n\nIf this is included, only alerts with the specified class or matching the glob pattern (as appropriate) will be returned. Otherwise, alerts of all classes will be returned.\n\nSee the guide-level documentation on alerts for details on alert classes and alert class glob patterns.", + "schema": { + "$ref": "#/components/schemas/AlertSubscription" + } + }, + { + "in": "query", + "name": "end_time", + "description": "Inclusive upper bound on the alert creation time\n\nIf this is included, only alerts created at or before this time will be returned.", + "schema": { + "nullable": true, + "type": "string", + "format": "date-time" + } + }, + { + "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" + } + }, + { + "in": "query", + "name": "sort_by", + "schema": { + "$ref": "#/components/schemas/TimeAndIdSortMode" + } + }, + { + "in": "query", + "name": "start_time", + "description": "Inclusive lower bound on the alert creation time.\n\nIf this is included, only alerts created at or after this time will be returned.", + "schema": { + "nullable": true, + "type": "string", + "format": "date-time" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AlertResultsPage" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + }, + "x-dropshot-pagination": { + "required": [] + } + } + }, + "/v1/alerts/{alert_id}": { + "get": { + "tags": [ + "system/alerts" + ], + "summary": "Fetch alert", + "operationId": "alert_view", + "parameters": [ + { + "in": "path", + "name": "alert_id", + "description": "UUID of the alert", + "required": true, + "schema": { + "type": "string", + "format": "uuid" + } + } + ], + "responses": { + "200": { + "description": "successful operation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Alert" + } + } + } + }, + "4XX": { + "$ref": "#/components/responses/Error" + }, + "5XX": { + "$ref": "#/components/responses/Error" + } + } + } + }, "/v1/alerts/{alert_id}/resend": { "post": { "tags": [ @@ -16112,6 +16239,48 @@ "switch_histories" ] }, + "Alert": { + "description": "An alert.\n\nAlerts provide notifications about events that occurred in the system at a point in time. See the guide-level documentation on alerts for details.", + "type": "object", + "properties": { + "alert": { + "description": "The alert's data payload.\n\nThe schema for this object depends on the alert class and version." + }, + "class": { + "description": "The alert's class.\n\nSee the guide-level documentation on alerts for details on alert classes.", + "type": "string" + }, + "id": { + "description": "Unique, immutable, system-controlled identifier for each resource", + "type": "string", + "format": "uuid" + }, + "time_created": { + "description": "Timestamp when this resource was created", + "type": "string", + "format": "date-time" + }, + "time_modified": { + "description": "Timestamp when this resource was last modified", + "type": "string", + "format": "date-time" + }, + "version": { + "description": "The schema version of this alert's data payload.\n\nAlert schemas are versioned on a per-alert-class basis. The schema version for a particular alert class does not correspond to an Oxide API version. Clients should expect to encounter earlier schema versions when retrieving alerts recorded by an earlier version of the system software.\n\nSee the guide-level documentation on alerts for details.", + "type": "integer", + "format": "uint32", + "minimum": 0 + } + }, + "required": [ + "alert", + "class", + "id", + "time_created", + "time_modified", + "version" + ] + }, "AlertClass": { "description": "An alert class.", "type": "object", @@ -16457,6 +16626,27 @@ "items" ] }, + "AlertResultsPage": { + "description": "A single page of results", + "type": "object", + "properties": { + "items": { + "description": "list of items on this page of results", + "type": "array", + "items": { + "$ref": "#/components/schemas/Alert" + } + }, + "next_page": { + "nullable": true, + "description": "token used to fetch the next page of results (if any)", + "type": "string" + } + }, + "required": [ + "items" + ] + }, "AlertSubscription": { "title": "A webhook event class subscription", "description": "A webhook event class subscription matches either a single event class exactly, or a glob pattern including wildcards that may match multiple event classes", diff --git a/openapi/nexus/nexus-latest.json b/openapi/nexus/nexus-latest.json index a27ef3b003e..514d8d51424 120000 --- a/openapi/nexus/nexus-latest.json +++ b/openapi/nexus/nexus-latest.json @@ -1 +1 @@ -nexus-2026081200.0.0-6594c1.json \ No newline at end of file +nexus-2026081400.0.0-b9cf69.json \ No newline at end of file