Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions nexus/db-model/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
25 changes: 25 additions & 0 deletions nexus/db-model/src/alert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -125,3 +127,26 @@ impl Alert {
}
}
}

impl From<Alert> 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,
}
}
}
35 changes: 34 additions & 1 deletion nexus/db-model/src/alert_subscription.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<impl Iterator<Item = AlertClass>, 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<AlertSubscriptionKind> for alert::AlertSubscription {
Expand Down
45 changes: 45 additions & 0 deletions nexus/db-queries/src/db/datastore/alert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -88,6 +89,8 @@ pub struct AlertFilters {
cases: Vec<DbTypedUuid<CaseKind>>,

/// Include only alerts with the specified alert classes.
///
/// If this is empty, all alert classes will be included.
classes: Vec<model::AlertClass>,

/// If `true`, include only alerts that have been fully dispatched.
Expand Down Expand Up @@ -239,6 +242,45 @@ impl AlertFilters {
}
}

impl TryFrom<external_api::AlertListParams> for AlertFilters {
type Error = Error;
fn try_from(
params: external_api::AlertListParams,
) -> Result<Self, Self::Error> {
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::<Vec<_>>();

// 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.
///
Expand Down Expand Up @@ -464,6 +506,7 @@ impl DataStore {
pagparams: &DataPageParams<'_, (DateTime<Utc>, Uuid)>,
) -> ListResultVec<(Alert, Option<fm::RendezvousAlertCreated>)> {
opctx.authorize(authz::Action::ListChildren, &authz::FLEET).await?;

Self::alert_list_matching_query(filters, pagparams)
.load_async(&*self.pool_connection_authorized(opctx).await?)
.await
Expand All @@ -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(),
Expand Down
2 changes: 2 additions & 0 deletions nexus/external-api/output/nexus_tags.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions nexus/external-api/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down Expand Up @@ -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<Self::Context>,
pagination: Query<PaginatedByTimeAndId<latest::alert::AlertListParams>>,
) -> Result<HttpResponseOk<ResultsPage<latest::alert::Alert>>, HttpError>;

/// Fetch alert
#[endpoint {
method = GET,
path = "/v1/alerts/{alert_id}",
tags = ["system/alerts"],
versions = VERSION_ALERT_LIST..
}]
async fn alert_view(
rqctx: RequestContext<Self::Context>,
path_params: Path<latest::alert::AlertSelector>,
) -> Result<HttpResponseOk<latest::alert::Alert>, HttpError>;

/// List alert classes
#[endpoint {
method = GET,
Expand Down
102 changes: 44 additions & 58 deletions nexus/src/app/alert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -237,6 +238,26 @@ impl Nexus {
Ok(event)
}

//
// Alerts
//

pub async fn alert_list(
Comment thread
hawkw marked this conversation as resolved.
&self,
opctx: &OpContext,
params: &alert::AlertListParams,
pagparams: &DataPageParams<'_, (DateTime<Utc>, Uuid)>,
) -> ListResultVec<alert::Alert> {
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
//
Expand All @@ -257,68 +278,33 @@ impl Nexus {
alert::AlertClassFilter { filter }: alert::AlertClassFilter,
pagparams: DataPageParams<'_, alert::AlertClassPage>,
) -> ListResultVec<alert::AlertClass> {
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::<Vec<_>>();
Ok(result)
}
Expand Down
Loading
Loading