From 0b80c8fa977b07b9538455bad24518df5e0430a7 Mon Sep 17 00:00:00 2001 From: Eliza Weisman Date: Tue, 7 Jan 2025 17:23:39 -0800 Subject: [PATCH] rudimentary receiver create gotta redo this to take the params but need to rebase --- common/src/api/external/mod.rs | 1 + nexus/auth/src/authz/api_resources.rs | 9 ++ nexus/db-model/src/schema.rs | 5 +- nexus/db-model/src/webhook_rx.rs | 76 ++++++++--- nexus/db-queries/src/db/datastore/mod.rs | 1 + .../db-queries/src/db/datastore/webhook_rx.rs | 122 ++++++++++++++++++ schema/crdb/add-webhooks/up01.sql | 10 +- schema/crdb/dbinit.sql | 11 +- 8 files changed, 206 insertions(+), 29 deletions(-) create mode 100644 nexus/db-queries/src/db/datastore/webhook_rx.rs diff --git a/common/src/api/external/mod.rs b/common/src/api/external/mod.rs index 38a9de0564..612fe3815a 100644 --- a/common/src/api/external/mod.rs +++ b/common/src/api/external/mod.rs @@ -1010,6 +1010,7 @@ pub enum ResourceType { FloatingIp, Probe, ProbeNetworkInterface, + WebhookReceiver, } // IDENTITY METADATA diff --git a/nexus/auth/src/authz/api_resources.rs b/nexus/auth/src/authz/api_resources.rs index bfde3d3b97..61e58d5ccc 100644 --- a/nexus/auth/src/authz/api_resources.rs +++ b/nexus/auth/src/authz/api_resources.rs @@ -1035,3 +1035,12 @@ authz_resource! { roles_allowed = false, polar_snippet = FleetChild, } + +authz_resource! { + name = "WebhookReceiver", + parent = "Fleet", + primary_key = { uuid_kind = WebhookReceiverKind }, + roles_allowed = false, + polar_snippet = FleetChild, + +} diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 152ddeb0c1..2320899258 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -2114,11 +2114,12 @@ table! { id -> Uuid, name -> Text, description -> Text, - endpoint -> Text, - probes_enabled -> Bool, time_created -> Timestamptz, time_modified -> Nullable, time_deleted -> Nullable, + rcgen -> Int8, + endpoint -> Text, + probes_enabled -> Bool, } } diff --git a/nexus/db-model/src/webhook_rx.rs b/nexus/db-model/src/webhook_rx.rs index 081ae909ce..4d8b27da6d 100644 --- a/nexus/db-model/src/webhook_rx.rs +++ b/nexus/db-model/src/webhook_rx.rs @@ -2,12 +2,17 @@ // 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 crate::collection::DatastoreCollectionConfig; use crate::schema::{webhook_rx, webhook_rx_secret, webhook_rx_subscription}; use crate::typed_uuid::DbTypedUuid; +use crate::Generation; use chrono::{DateTime, Utc}; use db_macros::Resource; +use omicron_common::api::external::Error; use omicron_uuid_kinds::{WebhookReceiverKind, WebhookReceiverUuid}; use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use uuid::Uuid; /// A webhook receiver configuration. #[derive( @@ -23,11 +28,31 @@ use serde::{Deserialize, Serialize}; #[diesel(table_name = webhook_rx)] pub struct WebhookReceiver { #[diesel(embed)] - identity: WebhookReceiverIdentity, + pub identity: WebhookReceiverIdentity, pub probes_enabled: bool, pub endpoint: String, + + /// child resource generation number, per RFD 192 + pub rcgen: Generation, +} + +impl DatastoreCollectionConfig for WebhookReceiver { + type CollectionId = Uuid; + type GenerationNumberColumn = webhook_rx::dsl::rcgen; + type CollectionTimeDeletedColumn = webhook_rx::dsl::time_deleted; + type CollectionIdColumn = webhook_rx_secret::dsl::rx_id; +} + +impl DatastoreCollectionConfig for WebhookReceiver { + type CollectionId = Uuid; + type GenerationNumberColumn = webhook_rx::dsl::rcgen; + type CollectionTimeDeletedColumn = webhook_rx::dsl::time_deleted; + type CollectionIdColumn = webhook_rx_subscription::dsl::rx_id; } +// TODO(eliza): should deliveries/delivery attempts also be treated as children +// of a webhook receiver? + #[derive( Clone, Debug, Queryable, Selectable, Insertable, Serialize, Deserialize, )] @@ -46,13 +71,23 @@ pub struct WebhookRxSecret { #[diesel(table_name = webhook_rx_subscription)] pub struct WebhookRxSubscription { pub rx_id: DbTypedUuid, + #[diesel(embed)] + pub glob: WebhookGlob, + pub time_created: DateTime, +} + +#[derive( + Clone, Debug, Queryable, Selectable, Insertable, Serialize, Deserialize, +)] +#[diesel(table_name = webhook_rx_subscription)] +pub struct WebhookGlob { pub event_class: String, pub similar_to: String, - pub time_created: DateTime, } -impl WebhookRxSubscription { - pub fn new(rx_id: WebhookReceiverUuid, event_class: String) -> Self { +impl FromStr for WebhookGlob { + type Err = Error; + fn from_str(event_class: &str) -> Result { fn seg2regex(segment: &str, similar_to: &mut String) { match segment { // Match one segment (i.e. any number of segment characters) @@ -63,6 +98,7 @@ impl WebhookRxSubscription { // Because `_` his a metacharacter in Postgres' SIMILAR TO // regexes, we've gotta go through and escape them. s => { + // TODO(eliza): validate what characters are in the segment... for s in s.split_inclusive('_') { // Handle the fact that there might not be a `_` in the // string at all @@ -87,19 +123,19 @@ impl WebhookRxSubscription { seg2regex(segment, &mut similar_to); } } else { - // TODO(eliza): we should probably validate that the event class has - // at least one segment... + return Err(Error::invalid_value( + "event_class", + "must not be empty", + )); }; - // `_` is a metacharacter in Postgres' SIMILAR TO regexes, so escape - // them. + Ok(Self { event_class: event_class.to_string(), similar_to }) + } +} - Self { - rx_id: DbTypedUuid(rx_id), - event_class, - similar_to, - time_created: Utc::now(), - } +impl WebhookRxSubscription { + pub fn new(rx_id: WebhookReceiverUuid, glob: WebhookGlob) -> Self { + Self { rx_id: DbTypedUuid(rx_id), glob, time_created: Utc::now() } } } @@ -119,13 +155,17 @@ mod test { ("foo_bar.baz", "foo\\_bar.baz"), ("foo_bar.*.baz", "foo\\_bar.[a-zA-Z0-9\\_\\-]+.baz"), ]; - let rx_id = WebhookReceiverUuid::new_v4(); for (class, regex) in CASES { - let subscription = - WebhookRxSubscription::new(rx_id, dbg!(class).to_string()); + let glob = match WebhookGlob::from_str(dbg!(class)) { + Ok(glob) => glob, + Err(error) => panic!( + "event class glob {class:?} should produce the regex + {regex:?}, but instead failed to parse: {error}" + ), + }; assert_eq!( dbg!(regex), - dbg!(&subscription.similar_to), + dbg!(&glob.similar_to), "event class {class:?} should produce the regex {regex:?}" ); } diff --git a/nexus/db-queries/src/db/datastore/mod.rs b/nexus/db-queries/src/db/datastore/mod.rs index f3070ec4d0..0369c6febb 100644 --- a/nexus/db-queries/src/db/datastore/mod.rs +++ b/nexus/db-queries/src/db/datastore/mod.rs @@ -107,6 +107,7 @@ mod volume; mod volume_repair; mod vpc; mod webhook_event; +mod webhook_rx; mod zpool; pub use address_lot::AddressLotCreateResult; diff --git a/nexus/db-queries/src/db/datastore/webhook_rx.rs b/nexus/db-queries/src/db/datastore/webhook_rx.rs new file mode 100644 index 0000000000..3880d3261f --- /dev/null +++ b/nexus/db-queries/src/db/datastore/webhook_rx.rs @@ -0,0 +1,122 @@ +// 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/. + +//! [`DataStore`] methods for webhook receiver management. + +use super::DataStore; +use crate::authz; +use crate::context::OpContext; +use crate::db::collection_insert::AsyncInsertError; +use crate::db::collection_insert::DatastoreCollection; +use crate::db::error::public_error_from_diesel; +use crate::db::error::retryable; +use crate::db::error::ErrorHandler; +use crate::db::model::WebhookGlob; +use crate::db::model::WebhookReceiver; +use crate::db::model::WebhookRxSubscription; +use crate::db::pool::DbConnection; +use crate::db::TransactionError; +use async_bb8_diesel::AsyncConnection; +use async_bb8_diesel::AsyncRunQueryDsl; +use diesel::prelude::*; +use diesel::result::OptionalExtension; +use nexus_types::identity::Resource; +use omicron_common::api::external::CreateResult; +use omicron_common::api::external::DeleteResult; +use omicron_common::api::external::Error; +use omicron_common::api::external::LookupType; +use omicron_common::api::external::ResourceType; +use omicron_uuid_kinds::{GenericUuid, WebhookReceiverUuid}; + +impl DataStore { + pub async fn webhook_rx_create( + &self, + opctx: &OpContext, + receiver: &WebhookReceiver, + subscriptions: &[WebhookGlob], + ) -> CreateResult { + use crate::db::schema::webhook_rx::dsl; + // TODO(eliza): someday we gotta allow creating webhooks with more + // restrictive permissions... + opctx.authorize(authz::Action::CreateChild, &authz::FLEET).await?; + + let conn = self.pool_connection_authorized(opctx).await?; + let rx_id = WebhookReceiverUuid::from_untyped_uuid(receiver.id()); + self.transaction_retry_wrapper("webhook_rx_create") + .transaction(&conn, |conn| { + let receiver = receiver.clone(); + async move { + diesel::insert_into(dsl::webhook_rx) + .values(receiver) + // .on_conflict(dsl::id) + // .do_update() + // .set(dsl::time_modified.eq(dsl::time_modified)) + // .returning(WebhookReceiver::as_returning()) + .execute_async(&conn) + .await?; + // .map_err(|e| { + // if retryable(&e) { + // return TransactionError::Database(e); + // }; + // TransactionError::CustomError(public_error_from_diesel( + // e, + // ErrorHandler::Conflict( + // ResourceType::WebhookReceiver, + // receiver.identity.name.as_str(), + // ), + // )) + // })?; + for glob in subscriptions { + match self + .webhook_add_subscription_on_conn( + WebhookRxSubscription::new(rx_id, glob.clone()), + &conn, + ) + .await + { + Ok(_) => {} + Err(AsyncInsertError::CollectionNotFound) => {} // we just created it? + Err(AsyncInsertError::DatabaseError(e)) => { + return Err(e); + } + } + } + // TODO(eliza): secrets go here... + Ok(()) + } + }) + .await + .map_err(|e| { + public_error_from_diesel( + e, + ErrorHandler::Conflict( + ResourceType::WebhookReceiver, + receiver.name().as_str(), + ), + ) + })?; + Ok(receiver.clone()) + } + + async fn webhook_add_subscription_on_conn( + &self, + subscription: WebhookRxSubscription, + conn: &async_bb8_diesel::Connection, + ) -> Result { + use crate::db::schema::webhook_rx_subscription::dsl; + let rx_id = subscription.rx_id.into_untyped_uuid(); + let subscription: WebhookRxSubscription = + WebhookReceiver::insert_resource( + rx_id, + diesel::insert_into(dsl::webhook_rx_subscription) + .values(subscription) + .on_conflict((dsl::rx_id, dsl::event_class)) + .do_update() + .set(dsl::time_created.eq(diesel::dsl::now)), + ) + .insert_and_get_result_async(conn) + .await?; + Ok(subscription) + } +} diff --git a/schema/crdb/add-webhooks/up01.sql b/schema/crdb/add-webhooks/up01.sql index c7b8d20747..523222e098 100644 --- a/schema/crdb/add-webhooks/up01.sql +++ b/schema/crdb/add-webhooks/up01.sql @@ -1,13 +1,15 @@ CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx ( + /* Identity metadata (resource) */ id UUID PRIMARY KEY, - -- A human-readable identifier for this webhook receiver. name STRING(63) NOT NULL, description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + -- Child resource generation + rcgen INT NOT NULL, -- URL of the endpoint webhooks are delivered to. endpoint STRING(512) NOT NULL, -- Whether or not liveness probes are sent to this receiver. probes_enabled BOOL NOT NULL, - -- TODO(eliza): how do we track which roles are assigned to a webhook? - time_created TIMESTAMPTZ NOT NULL, - time_deleted TIMESTAMPTZ ); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 4b7ffe4cff..74039bb69d 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -4693,18 +4693,19 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_record_per_volume_resource_usage on omicro */ CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx ( + /* Identity metadata (resource) */ id UUID PRIMARY KEY, - -- A human-readable identifier for this webhook receiver. name STRING(63) NOT NULL, description STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_modified TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + -- Child resource generation + rcgen INT NOT NULL, -- URL of the endpoint webhooks are delivered to. endpoint STRING(512) NOT NULL, -- Whether or not liveness probes are sent to this receiver. probes_enabled BOOL NOT NULL, - -- TODO(eliza): how do we track which roles are assigned to a webhook? - time_created TIMESTAMPTZ NOT NULL, - time_modified TIMESTAMPTZ, - time_deleted TIMESTAMPTZ ); CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_secret (