diff --git a/nexus/db-model/src/lib.rs b/nexus/db-model/src/lib.rs index 66723e0b18..d28c41b8ed 100644 --- a/nexus/db-model/src/lib.rs +++ b/nexus/db-model/src/lib.rs @@ -63,6 +63,7 @@ mod switch_interface; mod switch_port; mod v2p_mapping; mod vmm_state; +mod webhook_delivery; // These actually represent subqueries, not real table. // However, they must be defined in the same crate as our tables // for join-based marker trait generation. @@ -226,6 +227,7 @@ pub use vpc_firewall_rule::*; pub use vpc_route::*; pub use vpc_router::*; pub use vpc_subnet::*; +pub use webhook_delivery::*; pub use zpool::*; // TODO: The existence of both impl_enum_type and impl_enum_wrapper is a diff --git a/nexus/db-model/src/schema.rs b/nexus/db-model/src/schema.rs index 399da81ea4..adb0084ba9 100644 --- a/nexus/db-model/src/schema.rs +++ b/nexus/db-model/src/schema.rs @@ -2108,3 +2108,78 @@ table! { region_snapshot_snapshot_id -> Nullable, } } + +table! { + webhook_rx (id) { + id -> Uuid, + name -> Text, + endpoint -> Text, + time_created -> Timestamptz, + time_modified -> Nullable, + time_deleted -> Nullable, + } +} + +table! { + webhook_rx_secret (rx_id, signature_id) { + rx_id -> Uuid, + signature_id -> Text, + secret -> Binary, + time_created -> Timestamptz, + time_deleted -> Nullable, + } +} + +allow_tables_to_appear_in_same_query!(webhook_rx, webhook_rx_secret); +joinable!(webhook_rx_secret -> webhook_rx (rx_id)); + +table! { + webhook_rx_subscription (rx_id, event_class) { + rx_id -> Uuid, + event_class -> Text, + time_created -> Timestamptz, + } +} + +allow_tables_to_appear_in_same_query!(webhook_rx, webhook_rx_subscription); +joinable!(webhook_rx_subscription -> webhook_rx (rx_id)); + +table! { + webhook_event (id) { + id -> Uuid, + time_created -> Timestamptz, + time_dispatched -> Nullable, + event_class -> Text, + event -> Jsonb, + } +} + +table! { + webhook_delivery (id) { + id -> Uuid, + rx_id -> Uuid, + payload -> Jsonb, + time_created -> Timestamptz, + time_completed -> Nullable, + } +} + +allow_tables_to_appear_in_same_query!(webhook_rx, webhook_delivery); +joinable!(webhook_delivery -> webhook_rx (rx_id)); + +table! { + webhook_delivery_attempt (id) { + id -> Uuid, + delivery_id -> Uuid, + result -> crate::WebhookDeliveryResultEnum, + response_status -> Nullable, + response_duration -> Nullable, + time_created -> Timestamptz, + } +} + +allow_tables_to_appear_in_same_query!( + webhook_delivery, + webhook_delivery_attempt +); +joinable!(webhook_delivery_attempt -> webhook_delivery (delivery_id)); diff --git a/nexus/db-model/src/schema_versions.rs b/nexus/db-model/src/schema_versions.rs index 02646bc6dd..dd5a3218cd 100644 --- a/nexus/db-model/src/schema_versions.rs +++ b/nexus/db-model/src/schema_versions.rs @@ -17,7 +17,7 @@ use std::collections::BTreeMap; /// /// This must be updated when you change the database schema. Refer to /// schema/crdb/README.adoc in the root of this repository for details. -pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(116, 0, 0); +pub const SCHEMA_VERSION: SemverVersion = SemverVersion::new(117, 0, 0); /// List of all past database schema versions, in *reverse* order /// @@ -29,6 +29,7 @@ static KNOWN_VERSIONS: Lazy> = Lazy::new(|| { // | leaving the first copy as an example for the next person. // v // KnownVersion::new(next_int, "unique-dirname-with-the-sql-files"), + KnownVersion::new(117, "add-webhooks"), KnownVersion::new(116, "bp-physical-disk-disposition"), KnownVersion::new(115, "inv-omicron-physical-disks-generation"), KnownVersion::new(114, "crucible-ref-count-records"), diff --git a/nexus/db-model/src/webhook_delivery.rs b/nexus/db-model/src/webhook_delivery.rs new file mode 100644 index 0000000000..6819b5a240 --- /dev/null +++ b/nexus/db-model/src/webhook_delivery.rs @@ -0,0 +1,31 @@ +// 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 super::impl_enum_type; +use serde::Deserialize; +use serde::Serialize; + +impl_enum_type!( + #[derive(SqlType, Debug, Clone)] + #[diesel(postgres_type(name = "webhook_delivery_result", schema = "public"))] + pub struct WebhookDeliveryResultEnum; + + #[derive( + Copy, + Clone, + Debug, + PartialEq, + AsExpression, + FromSqlRow, + Serialize, + Deserialize, + )] + #[diesel(sql_type = WebhookDeliveryResultEnum)] + pub enum WebhookDeliveryResult; + + FailedHttpError => b"failed_http_error" + FailedUnreachable => b"failed_unreachable" + FailedTimeout => b"failed_timeout" + Succeeded => b"succeeded" +); diff --git a/schema/crdb/add-webhooks/README.adoc b/schema/crdb/add-webhooks/README.adoc new file mode 100644 index 0000000000..6f724c75f1 --- /dev/null +++ b/schema/crdb/add-webhooks/README.adoc @@ -0,0 +1,51 @@ +# Overview + +This migration adds initial tables required for webhook delivery. + +## Upgrade steps + +The individual transactions in this upgrade do the following: + +* *Webhook receivers*: +** `up01.sql` creates the `omicron.public.webhook_rx` table, which stores +the receiver endpoints that receive webhook events. +** *Receiver secrets*: +*** `up02.sql` creates the `omicron.public.webhook_rx_secret` table, which +associates webhook receivers with secret keys and their IDs. +*** `up03.sql` creates the `lookup_webhook_secrets_by_rx` index on that table, +for looking up all secrets associated with a receiver. +** *Receiver subscriptions*: +*** `up04.sql` creates the `omicron.public.webhook_rx_subscription` table, which +associates a webhook receiver with multiple event classes that the receiver is +subscribed to. +*** `up05.sql` creates an index `lookup_webhook_subscriptions_by_rx` for +looking up all event classes that a receiver ID is subscribed to. +*** `up06.sql` creates an index `lookup_webhook_rxs_for_event` on +`omicron.public.webhook_rx_subscription` for looking up all receivers subscribed +to a particular event class. +* *Webhook message queue*: +** `up07.sql` creates the `omicron.public.webhook_event` table, which contains the +queue of un-dispatched webhook events. The dispatcher operates on entries in +this queue, dispatching the event to receivers and generating the payload for +each receiver. +** `up08.sql` creates the `lookup_undispatched_webhook_events` index on +`omicron.public.webhook_event` for looking up webhook messages which have not yet been +dispatched and ordering by their creation times. +* *Webhook message dispatching and delivery attempts*: +** *Dispatch table*: +*** `up09.sql` creates the table `omicron.public.webhook_delivery`, which +tracks the webhook messages that have been dispatched to receivers. +*** `up10.sql` creates an index `lookup_webhook_dispatched_to_rx` for looking up +entries in `omicron.public.webhook_delivery` by receiver ID. +*** `up11.sql` creates an index `webhook_delivery_in_flight` for looking up all currently in-flight webhook +messages (entries in `omicron.public.webhook_delivery` where the +`time_completed` field has not been set). +** *Delivery attempts*: +*** `up12.sql` creates the enum `omicron.public.webhook_delivery_result`, +representing the potential outcomes of a webhook delivery attempt. +*** `up13.sql` creates the table `omicron.public.webhook_delivery_attempt`, +which records each individual delivery attempt for a webhook message in the +`webhook_delivery` table. +*** `up14.sql` creates an index `lookup_webhook_delivery_attempts_for_msg` on +`omicron.public.webhook_delivery_attempt`, for looking up all attempts to +deliver a message with a given dispatch ID. diff --git a/schema/crdb/add-webhooks/up01.sql b/schema/crdb/add-webhooks/up01.sql new file mode 100644 index 0000000000..58799496b0 --- /dev/null +++ b/schema/crdb/add-webhooks/up01.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx ( + id UUID PRIMARY KEY, + -- A human-readable identifier for this webhook receiver. + name STRING(63) NOT NULL, + -- URL of the endpoint webhooks are delivered to. + endpoint STRING(512) 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/add-webhooks/up02.sql b/schema/crdb/add-webhooks/up02.sql new file mode 100644 index 0000000000..df945f4299 --- /dev/null +++ b/schema/crdb/add-webhooks/up02.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_secret ( + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + -- ID of this secret. + signature_id STRING(63) NOT NULL, + -- Secret value. + secret BYTES NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + PRIMARY KEY (signature_id, rx_id) +); diff --git a/schema/crdb/add-webhooks/up03.sql b/schema/crdb/add-webhooks/up03.sql new file mode 100644 index 0000000000..5a79908857 --- /dev/null +++ b/schema/crdb/add-webhooks/up03.sql @@ -0,0 +1,5 @@ +CREATE INDEX IF NOT EXISTS lookup_webhook_secrets_by_rx +ON omicron.public.webhook_rx_secret ( + rx_id +) WHERE + time_deleted IS NULL; diff --git a/schema/crdb/add-webhooks/up04.sql b/schema/crdb/add-webhooks/up04.sql new file mode 100644 index 0000000000..7911418a78 --- /dev/null +++ b/schema/crdb/add-webhooks/up04.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_subscription ( + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + -- An event class to which this receiver is subscribed. + event_class STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + + PRIMARY KEY (rx_id, event_class) +); diff --git a/schema/crdb/add-webhooks/up05.sql b/schema/crdb/add-webhooks/up05.sql new file mode 100644 index 0000000000..4ffe7cbce0 --- /dev/null +++ b/schema/crdb/add-webhooks/up05.sql @@ -0,0 +1,4 @@ +CREATE INDEX IF NOT EXISTS lookup_webhook_subscriptions_by_rx +ON omicron.public.webhook_rx_subscription ( + rx_id +); diff --git a/schema/crdb/add-webhooks/up06.sql b/schema/crdb/add-webhooks/up06.sql new file mode 100644 index 0000000000..407424440c --- /dev/null +++ b/schema/crdb/add-webhooks/up06.sql @@ -0,0 +1,6 @@ +-- Look up all webhook receivers subscribed to an event class. This is used by +-- the dispatcher to determine who is interested in a particular event. +CREATE INDEX IF NOT EXISTS lookup_webhook_rxs_for_event +ON omicron.public.webhook_rx_subscription ( + event_class +); diff --git a/schema/crdb/add-webhooks/up07.sql b/schema/crdb/add-webhooks/up07.sql new file mode 100644 index 0000000000..99e00dfaeb --- /dev/null +++ b/schema/crdb/add-webhooks/up07.sql @@ -0,0 +1,10 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_event ( + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + -- Set when dispatch entries have been created for this event. + time_dispatched TIMESTAMPTZ, + -- The class of event that this is. + event_class STRING(512) NOT NULL, + -- Actual event data. The structure of this depends on the event class. + event JSONB NOT NULL +); diff --git a/schema/crdb/add-webhooks/up08.sql b/schema/crdb/add-webhooks/up08.sql new file mode 100644 index 0000000000..c64ae2db0d --- /dev/null +++ b/schema/crdb/add-webhooks/up08.sql @@ -0,0 +1,7 @@ +-- Look up webhook messages in need of dispatching. +-- +-- This is used by the message dispatcher when looking for messages to dispatch. +CREATE INDEX IF NOT EXISTS lookup_undispatched_webhook_events +ON omicron.public.webhook_event ( + id, time_created +) WHERE time_dispatched IS NULL; diff --git a/schema/crdb/add-webhooks/up09.sql b/schema/crdb/add-webhooks/up09.sql new file mode 100644 index 0000000000..41c9993413 --- /dev/null +++ b/schema/crdb/add-webhooks/up09.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_delivery ( + -- UUID of this dispatch. + id UUID PRIMARY KEY, + --- UUID of the event (foreign key into `omicron.public.webhook_event`). + event_id UUID NOT NULL, + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + payload JSONB NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + -- If this is set, then this webhook message has either been delivered + -- successfully, or is considered permanently failed. + time_completed TIMESTAMPTZ +); diff --git a/schema/crdb/add-webhooks/up10.sql b/schema/crdb/add-webhooks/up10.sql new file mode 100644 index 0000000000..0e67ca550f --- /dev/null +++ b/schema/crdb/add-webhooks/up10.sql @@ -0,0 +1,5 @@ +-- Index for looking up all webhook messages dispatched to a receiver ID +CREATE INDEX IF NOT EXISTS lookup_webhook_dispatched_to_rx +ON omicron.public.webhook_delivery ( + rx_id, event_id +); diff --git a/schema/crdb/add-webhooks/up11.sql b/schema/crdb/add-webhooks/up11.sql new file mode 100644 index 0000000000..b3c36debbb --- /dev/null +++ b/schema/crdb/add-webhooks/up11.sql @@ -0,0 +1,7 @@ +-- Index for looking up all currently in-flight webhook deliveries, and ordering +-- them by their creation times. +CREATE INDEX IF NOT EXISTS webhook_delivery_in_flight +ON omicron.public.webhook_delivery ( + time_created, id +) WHERE + time_completed IS NULL; diff --git a/schema/crdb/add-webhooks/up12.sql b/schema/crdb/add-webhooks/up12.sql new file mode 100644 index 0000000000..06ce311062 --- /dev/null +++ b/schema/crdb/add-webhooks/up12.sql @@ -0,0 +1,12 @@ +CREATE TYPE IF NOT EXISTS omicron.public.webhook_delivery_result as ENUM ( + -- The delivery attempt failed with an HTTP error. + 'failed_http_error', + -- The delivery attempt failed because the receiver endpoint was + -- unreachable. + 'failed_unreachable', + --- The delivery attempt connected successfully but no response was received + -- within the timeout. + 'failed_timeout', + -- The delivery attempt succeeded. + 'succeeded' +); diff --git a/schema/crdb/add-webhooks/up13.sql b/schema/crdb/add-webhooks/up13.sql new file mode 100644 index 0000000000..3497555f78 --- /dev/null +++ b/schema/crdb/add-webhooks/up13.sql @@ -0,0 +1,30 @@ +CREATE TABLE IF NOT EXISTS omicron.public.webhook_delivery_attempt ( + -- Foreign key into `omicron.public.webhook_delivery`. + delivery_id UUID NOT NULL, + -- attempt number. + attempt INT2 NOT NULL, + result omicron.public.webhook_delivery_result NOT NULL, + response_status INT2, + response_duration INTERVAL, + time_created TIMESTAMPTZ NOT NULL, + + PRIMARY KEY (delivery_id, attempt), + + CONSTRAINT response_iff_not_unreachable CHECK ( + ( + -- If the result is 'succeedeed' or 'failed_http_error', response + -- data must be present. + (result = 'succeeded' OR result = 'failed_http_error') AND ( + response_status IS NOT NULL AND + response_duration IS NOT NULL + ) + ) OR ( + -- If the result is 'failed_unreachable' or 'failed_timeout', no + -- response data is present. + (result = 'failed_unreachable' OR result = 'failed_timeout') AND ( + response_status IS NULL AND + response_duration IS NULL + ) + ) + ) +); diff --git a/schema/crdb/add-webhooks/up14.sql b/schema/crdb/add-webhooks/up14.sql new file mode 100644 index 0000000000..05ec2ba0c9 --- /dev/null +++ b/schema/crdb/add-webhooks/up14.sql @@ -0,0 +1,4 @@ +CREATE INDEX IF NOT EXISTS lookup_webhook_delivery_attempts_for_msg +ON omicron.public.webhook_delivery_attempts ( + delivery_id +); diff --git a/schema/crdb/dbinit.sql b/schema/crdb/dbinit.sql index 75b7dbaf08..b6a0e67854 100644 --- a/schema/crdb/dbinit.sql +++ b/schema/crdb/dbinit.sql @@ -4683,6 +4683,176 @@ CREATE UNIQUE INDEX IF NOT EXISTS one_record_per_volume_resource_usage on omicro region_snapshot_snapshot_id ); +/* + * WEBHOOKS + */ + + +/* + * Webhook receivers, receiver secrets, and receiver subscriptions. + */ + +CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx ( + id UUID PRIMARY KEY, + -- A human-readable identifier for this webhook receiver. + name STRING(63) NOT NULL, + -- URL of the endpoint webhooks are delivered to. + endpoint STRING(512) 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 ( + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + -- ID of this secret. + signature_id STRING(63) NOT NULL, + -- Secret value. + secret BYTES NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + time_deleted TIMESTAMPTZ, + + PRIMARY KEY (signature_id, rx_id) +); + +CREATE INDEX IF NOT EXISTS lookup_webhook_secrets_by_rx +ON omicron.public.webhook_rx_secret ( + rx_id +) WHERE + time_deleted IS NULL; + +CREATE TABLE IF NOT EXISTS omicron.public.webhook_rx_subscription ( + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + -- An event class to which this receiver is subscribed. + event_class STRING(512) NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + + PRIMARY KEY (rx_id, event_class) +); + +CREATE INDEX IF NOT EXISTS lookup_webhook_subscriptions_by_rx +ON omicron.public.webhook_rx_subscription ( + rx_id +); + +-- Look up all webhook receivers subscribed to an event class. This is used by +-- the dispatcher to determine who is interested in a particular event. +CREATE INDEX IF NOT EXISTS lookup_webhook_rxs_for_event +ON omicron.public.webhook_rx_subscription ( + event_class +); + +/* + * Webhook event message queue. + */ + +CREATE TABLE IF NOT EXISTS omicron.public.webhook_event ( + id UUID PRIMARY KEY, + time_created TIMESTAMPTZ NOT NULL, + -- Set when dispatch entries have been created for this event. + time_dispatched TIMESTAMPTZ, + -- The class of event that this is. + event_class STRING(512) NOT NULL, + -- Actual event data. The structure of this depends on the event class. + event JSONB NOT NULL +); + +-- Look up webhook events in need of dispatching. +-- +-- This is used by the message dispatcher when looking for events to dispatch. +CREATE INDEX IF NOT EXISTS lookup_undispatched_webhook_events +ON omicron.public.webhook_event ( + id, time_created +) WHERE time_dispatched IS NULL; + + +/* + * Webhook message dispatching and delivery attempts. + */ + +CREATE TABLE IF NOT EXISTS omicron.public.webhook_delivery ( + -- UUID of this dispatch. + id UUID PRIMARY KEY, + --- UUID of the event (foreign key into `omicron.public.webhook_event`). + event_id UUID NOT NULL, + -- UUID of the webhook receiver (foreign key into + -- `omicron.public.webhook_rx`) + rx_id UUID NOT NULL, + payload JSONB NOT NULL, + time_created TIMESTAMPTZ NOT NULL, + -- If this is set, then this webhook message has either been delivered + -- successfully, or is considered permanently failed. + time_completed TIMESTAMPTZ +); + +-- Index for looking up all webhook messages dispatched to a receiver ID +CREATE INDEX IF NOT EXISTS lookup_webhook_dispatched_to_rx +ON omicron.public.webhook_delivery ( + rx_id, event_id +); + +-- Index for looking up all currently in-flight webhook messages, and ordering +-- them by their creation times. +CREATE INDEX IF NOT EXISTS webhook_delivery_in_flight +ON omicron.public.webhook_delivery ( + time_created, id +) WHERE + time_completed IS NULL; + +CREATE TYPE IF NOT EXISTS omicron.public.webhook_delivery_result as ENUM ( + -- The delivery attempt failed with an HTTP error. + 'failed_http_error', + -- The delivery attempt failed because the receiver endpoint was + -- unreachable. + 'failed_unreachable', + --- The delivery attempt connected successfully but no response was received + -- within the timeout. + 'failed_timeout', + -- The delivery attempt succeeded. + 'succeeded' +); + +CREATE TABLE IF NOT EXISTS omicron.public.webhook_delivery_attempt ( + -- Foreign key into `omicron.public.webhook_delivery`. + delivery_id UUID NOT NULL, + -- attempt number. + attempt INT2 NOT NULL, + result omicron.public.webhook_delivery_result NOT NULL, + response_status INT2, + response_duration INTERVAL, + time_created TIMESTAMPTZ NOT NULL, + + PRIMARY KEY (delivery_id, attempt), + + CONSTRAINT response_iff_not_unreachable CHECK ( + ( + -- If the result is 'succeedeed' or 'failed_http_error', response + -- data must be present. + (result = 'succeeded' OR result = 'failed_http_error') AND ( + response_status IS NOT NULL AND + response_duration IS NOT NULL + ) + ) OR ( + -- If the result is 'failed_unreachable' or 'failed_timeout', no + -- response data is present. + (result = 'failed_unreachable' OR result = 'failed_timeout') AND ( + response_status IS NULL AND + response_duration IS NULL + ) + ) + ) +); + +CREATE INDEX IF NOT EXISTS lookup_webhook_delivery_attempts_for_msg +ON omicron.public.webhook_delivery_attempts ( + delivery_id +); + /* * Keep this at the end of file so that the database does not contain a version * until it is fully populated. @@ -4694,7 +4864,7 @@ INSERT INTO omicron.public.db_metadata ( version, target_version ) VALUES - (TRUE, NOW(), NOW(), '116.0.0', NULL) + (TRUE, NOW(), NOW(), '117.0.0', NULL) ON CONFLICT DO NOTHING; COMMIT;