diff --git a/api/openapi.yaml b/api/openapi.yaml index 485a7c2..9bc7bf4 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -987,6 +987,49 @@ paths: items: { $ref: "#/components/schemas/CheckRun" } default: { $ref: "#/components/responses/Problem" } + /devices/{device_id}/notifications: + get: + operationId: listDeviceNotifications + tags: [notifications] + summary: The notifications set up for a device, whether they work, and what was sent + description: | + Shows a customer their notification setup for one device. + + - `routes`: every alert route, with the filters the device satisfies + (`matches_because`), the checks and severities it takes, the delivery + health of its webhook, and `would_fire`. `would_fire` predicts whether + the next incident of this device would be delivered: it is false, with + a `reason`, when the route is disabled, matches nothing of the device, + is shadowed by an earlier route without `continue`, sends to a disabled + webhook, or sends to a webhook whose latest delivery failed. Routes are + returned on the first page only. + - `items`: the deliveries about this device, newest first, with the body + that was sent. Bodies hold no secret: signatures travel in headers, + which are not stored. A grouped event is listed for every device in + the group and its body lists all of them. + + The prediction does not know about maintenance windows or about + suppression by an upstream failure at the moment of the incident. + parameters: + - $ref: "#/components/parameters/DeviceID" + - name: status + in: query + schema: { type: string, enum: [pending, delivered, failed, cancelled] } + - name: event_type + in: query + schema: { type: string } + - $ref: "#/components/parameters/Cursor" + - name: limit + in: query + schema: { type: integer, minimum: 1, maximum: 100, default: 20 } + responses: + "200": + description: Routes (first page) and a page of deliveries + content: + application/json: + schema: { $ref: "#/components/schemas/DeviceNotifications" } + default: { $ref: "#/components/responses/Problem" } + /devices/{device_id}/checks: parameters: - $ref: "#/components/parameters/DeviceID" @@ -1640,6 +1683,23 @@ paths: replayed: { type: integer } default: { $ref: "#/components/responses/Problem" } + /notification-catalog: + get: + operationId: getNotificationCatalog + tags: [notifications] + summary: Every notification a webhook can receive, with example payloads + description: | + Static reference for building routes and receivers. `default` events are + delivered by routes that do not list `match.event_types`. `trigger` says + what has to be set up for the other events. + responses: + "200": + description: The catalog + content: + application/json: + schema: { $ref: "#/components/schemas/NotificationCatalog" } + default: { $ref: "#/components/responses/Problem" } + /alert-routes/test: post: operationId: testAlertRoutes @@ -3370,6 +3430,132 @@ components: `monitoring.incident.renotify`, to this route only. Null: never. external: { $ref: "#/components/schemas/ExternalRefWrite" } + NotificationCatalog: + type: object + required: [events, match_fields, group_by_fields] + properties: + events: + type: array + items: + type: object + required: [type, description, scope, default, trigger, example] + properties: + type: { type: string } + description: { type: string } + scope: { type: string, enum: [incident, tenant, test] } + default: { type: boolean, description: "Delivered by routes that do not name event types" } + trigger: { type: [string, "null"], description: "What has to be set up for the event to be sent; null when nothing" } + example: + type: object + additionalProperties: true + description: The CloudEvents body as the receiver gets it, with made-up values + match_fields: + type: object + description: The filters of an alert route's `match`, with what each tests + additionalProperties: { type: string } + group_by_fields: + type: array + items: { type: string } + + DeviceNotifications: + type: object + required: [routes, items, next_cursor] + properties: + routes: + type: array + items: { $ref: "#/components/schemas/DeviceNotificationRoute" } + items: + type: array + items: { $ref: "#/components/schemas/DeviceDelivery" } + next_cursor: { type: [string, "null"] } + + DeviceNotificationRoute: + type: object + required: [route_id, route_external, name, position, enabled, match, endpoint, matches_because, matched_checks, + health, would_fire, reason] + properties: + route_id: { type: string, format: uuid } + route_external: { oneOf: [ { $ref: "#/components/schemas/ExternalRef" }, { type: "null" } ] } + name: { type: string } + position: { type: integer } + enabled: { type: boolean } + match: { $ref: "#/components/schemas/AlertRouteMatch" } + endpoint: + type: object + required: [id, name, enabled, disabled_reason, external] + properties: + id: { type: string, format: uuid } + name: { type: string } + enabled: { type: boolean } + disabled_reason: { type: [string, "null"] } + external: { oneOf: [ { $ref: "#/components/schemas/ExternalRef" }, { type: "null" } ] } + matches_because: + type: array + description: | + The route's filters that this device satisfies, as codes: + `all_incidents`, `event_type:`, `severity:`, `device_type:`, `site:`, `device_id:`, + `check_id:`, `tag:=`. + items: { type: string } + matched_checks: + type: array + description: The device's enabled checks whose incidents the route takes + items: + type: object + required: [check_id, name, plugin, severities] + properties: + check_id: { type: string, format: uuid } + name: { type: string } + plugin: { type: string } + severities: + type: array + items: { type: string, enum: [warning, critical] } + health: { $ref: "#/components/schemas/EndpointHealth" } + would_fire: { type: boolean, description: "The next incident of this device would be delivered through this route" } + reason: + type: [string, "null"] + enum: [route_disabled, no_match, endpoint_disabled, shadowed, endpoint_failing, null] + description: Why `would_fire` is false; null when true + + EndpointHealth: + type: object + description: Delivery health of the route's webhook, over all devices + required: [last_attempt_at, last_status, last_status_code, last_error, failed_24h, failing] + properties: + last_attempt_at: { type: [string, "null"], format: date-time } + last_status: { type: [string, "null"], enum: [pending, delivered, failed, null] } + last_status_code: { type: [integer, "null"] } + last_error: { type: [string, "null"] } + failed_24h: { type: integer, description: Deliveries that failed for good in the last 24 hours } + failing: { type: boolean, description: The latest delivery failed or is being retried } + + DeviceDelivery: + type: object + required: [id, event_type, incident_id, check_id, route_id, route_name, route_external, endpoint_id, + endpoint_name, endpoint_external, status, attempts, last_status_code, last_error, last_attempt_at, + delivered_at, created_at, payload] + properties: + route_external: { oneOf: [ { $ref: "#/components/schemas/ExternalRef" }, { type: "null" } ] } + endpoint_external: { oneOf: [ { $ref: "#/components/schemas/ExternalRef" }, { type: "null" } ] } + id: { type: string, format: uuid } + event_type: { type: string } + incident_id: { type: [string, "null"], format: uuid } + check_id: { type: [string, "null"], format: uuid } + route_id: { type: [string, "null"], format: uuid } + route_name: { type: [string, "null"] } + endpoint_id: { type: string, format: uuid } + endpoint_name: { type: string } + status: { type: string, enum: [pending, delivered, failed, cancelled] } + attempts: { type: integer } + last_status_code: { type: [integer, "null"] } + last_error: { type: [string, "null"] } + last_attempt_at: { type: [string, "null"], format: date-time } + delivered_at: { type: [string, "null"], format: date-time } + created_at: { type: string, format: date-time } + payload: + type: object + additionalProperties: true + description: The body that was sent + RouteTestResult: type: object required: [id, name, matched, notifies, endpoint_id] diff --git a/docs/api/notification-catalog.md b/docs/api/notification-catalog.md new file mode 100644 index 0000000..9f05f30 --- /dev/null +++ b/docs/api/notification-catalog.md @@ -0,0 +1,183 @@ +# Notification catalog + +The same list is served by `GET /v1/notification-catalog`. `GET /v1/devices/{device_id}/notifications` shows one device's routes, webhook health and recent deliveries. + +What a customer can be notified about, what each notification looks like, and which alert-route settings control it. Sources: [F06](../features/F06-notifications.md), [ADR-0009](../adr/0009-webhook-delivery.md), `internal/incident/events.go`, `internal/webhook/routes.go`, `api/openapi.yaml`. + +## 1. Notifications + +| Event type | When it is sent | Delivered by default | Goes to | Stops when | +| --- | --- | --- | --- | --- | +| `monitoring.incident.opened` | A check crosses a threshold and an incident opens. Also sent when a suppressed incident stops being explained by its upstream failure. | Yes | Every matching route | n/a | +| `monitoring.incident.updated` | Severity changes (warning to critical or back) or the incident starts or stops flapping. | Yes | Every matching route | n/a | +| `monitoring.incident.acknowledged` | A user acknowledges the incident. | Yes | Every matching route | n/a | +| `monitoring.incident.resolved` | The check recovers, or the incident is resolved by a user, a deleted or disabled check, or a vanished object. | Yes | Every matching route | n/a | +| `monitoring.incident.renotify` | An incident is still open and unacknowledged after the route's `repeat_interval`. | Only if the route sets `repeat_interval_seconds` (300 to 604800) | The route that sent the original | Acknowledge or resolve | +| `monitoring.incident.escalated` | A later route step is due (`steps[n].after`). | Only if the route has more than one step | The route that owns the step | Resolved, suppressed, or acknowledged when the step sets `only_if_unacknowledged` | +| `monitoring.incident.commented` | A comment is added to an incident. | No. The route must list it in `match.event_types`. | Routes that list it | n/a | +| `monitoring.tenant.device_limit_reached` | A create brings the account to its device limit. | Yes, but only to routes with no device, check or severity filter | Routes without those filters | Never grouped, repeated or escalated | +| `monitoring.tenant.check_limit_reached` | A create brings the account to its check limit. | Same as above | Same as above | Same as above | +| `monitoring.webhook.test` | `POST /v1/webhooks/{id}/test`. | Sent on request to one endpoint | That endpoint | n/a | +| `monitoring.heartbeat` | The engine's own dead-man's switch (F11). Sent to operator-configured targets, not to tenant webhooks. | n/a | Operator targets | n/a | + +Not notified: suppressed incidents (an upstream failure explains them), unless they later become unsuppressed. + +## 2. Envelope (every event) + +CloudEvents 1.0, signed with Standard Webhooks (`webhook-id`, `webhook-timestamp`, `webhook-signature`). Receivers deduplicate on `webhook-id`. + +| Field | Meaning | +| --- | --- | +| `specversion` | Always `1.0` | +| `id` | UUIDv7. Same on every retry and equal to the `webhook-id` header. | +| `source` | `/monitoring//` | +| `type` | Event type from the table above | +| `time` | RFC 3339, UTC | +| `subject` | Incident ID. For a grouped event, `group:`. For a tenant event, `tenant:`. | +| `data.account_id` | PlusClouds account UUID, or `null` when standalone | +| `data.object_type` | `Monitoring\Incidents` (tenant events: `Monitoring\Tenants`) | +| `data.object` | The incident (section 3) | +| `data.device` | The device (section 4) | +| `data.check` | The check (section 5) | +| `data.route` | The route, step and labels that sent this delivery (section 6) | +| `data.actor` | Who acknowledged, resolved or commented. `null` otherwise. | +| `data.links.incident` | Panel link, only when the operator configured `notifier.incident_url_template` | + +## 3. Incident object (`data.object`) + +| Field | Type | Notes | +| --- | --- | --- | +| `id` | uuid | | +| `check_id`, `device_id` | uuid or null | | +| `object_key`, `object_name` | string or null | For collector checks, the interface or fan the incident is about. Null for the check itself. | +| `severity` | `warning` or `critical` | | +| `status` | `open`, `acknowledged`, `resolved` | | +| `summary` | string | Ready-made one-line text for a message title | +| `last_output` | string or null | Last check output | +| `rule_id`, `rule_name` | string or null | The threshold rule that fired | +| `flapping` | bool | | +| `suppressed` | bool | Suppressed incidents are not notified | +| `root_incident_id`, `root_device_id` | uuid or null | The upstream cause, if any | +| `opened_at`, `acknowledged_at`, `resolved_at`, `updated_at` | RFC 3339 or null | | +| `acknowledged_by`, `acknowledged_by_external_id` | uuid / string or null | | +| `resolved_by` | `recovery`, `manual`, `check-deleted`, `check-disabled`, `object-gone` or null | | + +## 4. Device (`data.device`) + +`id`, `name`, `address`, `type`, `site_id`, `tags` (map), `external` (`{source, type, id}`, the PlusClouds link) and `path`, the containers outermost first, each with `id`, `name` and `external`. A receiver can map the event to its own objects without a lookup. + +## 5. Check (`data.check`) + +`id`, `name`, `plugin`, `last_output`, `rule_id`, `rule_name`, `runbook_url`, `thresholds` (the check's rules as in the API). + +## 6. Route (`data.route`) + +`id`, `name`, `step` (0 for the first notification), `labels` (the route's labels merged with the step's labels). The receiver decides what labels mean, for example `recipient: on-call`. + +## 7. Example: incident opened + +```json +{ + "specversion": "1.0", + "id": "0192f7c4-5b1e-7a3c-9d2e-3f4a5b6c7d8e", + "source": "/monitoring/inst-01/0192f7b0-0000-7000-8000-000000000001", + "type": "monitoring.incident.opened", + "time": "2026-10-12T09:15:00Z", + "datacontenttype": "application/json", + "subject": "0192f7c4-4a00-7000-8000-00000000a001", + "data": { + "account_id": "9b1c2d3e-0000-4000-8000-000000000001", + "object_type": "Monitoring\\Incidents", + "object": { + "id": "0192f7c4-4a00-7000-8000-00000000a001", + "check_id": "0192f7a0-0000-7000-8000-00000000c001", + "device_id": "0192f7a0-0000-7000-8000-00000000d001", + "object_key": null, + "object_name": null, + "severity": "critical", + "status": "open", + "summary": "CPU load 97 % (critical above 90 %)", + "last_output": "load=97", + "rule_id": "cpu-critical", + "rule_name": "CPU critical", + "flapping": false, + "suppressed": false, + "root_incident_id": null, + "root_device_id": "0192f7a0-0000-7000-8000-00000000d001", + "opened_at": "2026-10-12T09:15:00Z", + "acknowledged_at": null, + "resolved_at": null, + "updated_at": "2026-10-12T09:15:00Z" + }, + "device": { + "id": "0192f7a0-0000-7000-8000-00000000d001", + "name": "web-01", + "address": "10.0.0.11", + "type": "server", + "site_id": "0192f7a0-0000-7000-8000-00000000e001", + "tags": { "rack": "a12" }, + "external": { "source": "plusclouds", "type": "NextDeveloper\\IAAS\\Database\\Models\\VirtualMachines", "id": "9b1c…" }, + "path": [ { "id": "0192f7a0-0000-7000-8000-00000000b001", "name": "hv-07", "external": null } ] + }, + "check": { + "id": "0192f7a0-0000-7000-8000-00000000c001", + "name": "CPU load", + "plugin": "cpu", + "last_output": "load=97", + "rule_id": "cpu-critical", + "rule_name": "CPU critical", + "runbook_url": null, + "thresholds": [] + }, + "route": { "id": "0192f7a0-0000-7000-8000-00000000f001", "name": "datacenter critical", "step": 0, "labels": { "recipient": "noc" } }, + "actor": null, + "links": { "incident": "https://panel.example.com/monitoring/incidents/0192f7c4-4a00-7000-8000-00000000a001" } + } +} +``` + +Values are illustrative. Field names and types come from the code and the OpenAPI schema. + +## 8. How each event differs from the example + +| Event | Change | +| --- | --- | +| `updated` | `object.severity` or `object.flapping` changed | +| `acknowledged` | `object.status = acknowledged`, `acknowledged_at` and `acknowledged_by*` set, `actor` filled | +| `resolved` | `object.status = resolved`, `resolved_at` and `resolved_by` set. Actor is filled for a manual resolve. | +| `renotify` | Same incident, still open. `data.route` is the route that repeats. | +| `escalated` | `data.route.step` is 1 or higher and `labels` are merged with the step's labels | +| `commented` | `actor` filled. `data.object.comment` holds the comment (`id`, `author_kind`, `author_user_id`, `author_external_id`, `body`, `created_at`). | +| Grouped (any incident type) | `subject = group:`. `data.object`, `data.device`, `data.check` are `null`. `data.objects` is a list of `{object, device, check}`. `data.group = {id, key, count}`. A group of one is sent as a plain event. | +| Tenant limit | `object_type = Monitoring\Tenants`, `object = {tenant_id, resource, limit, count}`, `device` and `check` are `null`, `subject = tenant:` | +| `webhook.test` | `subject` is the webhook ID. `object_type = Monitoring\\Webhooks`, `data.object` is the webhook (no secret), `data.actor` is the caller. | + +## 9. Alert routes: what decides who is notified + +A route has `match`, an `endpoint_id`, and optional grouping, repeat and steps. Routes are evaluated in order. The first match wins unless `continue` is true. + +| Setting | Values | Effect | +| --- | --- | --- | +| `match.event_types` | any type above | Empty means the default set (opened, updated, acknowledged, resolved, tenant limits) | +| `match.severity` | `warning`, `critical` | | +| `match.device_types` | device types | | +| `match.site_ids` | site UUIDs | | +| `match.device_ids` | device UUIDs | | +| `match.check_ids` | check UUIDs | | +| `match.tags` | key and value map | Device must carry every tag | +| `group_by` | `root_device_id`, `device_id`, `site_id`, `severity`, `check_id`, `plugin`, `device_type` | Combines incidents into one event | +| `group_wait_seconds` | 0 to 600, default 30 | Wait before sending a group | +| `repeat_interval_seconds` | 300 to 604800 | Enables `renotify` | +| `steps[]` | `after_seconds`, `labels`, `only_if_unacknowledged`, `schedule` | Enables `escalated`. A schedule has a time zone, weekdays and from/to times. The first step cannot have one. | +| `continue` | bool | Keep evaluating later routes after a match | +| Limits | `max_webhooks` 20, `max_alert_routes` 50 per tenant | Create beyond the limit returns 409 `limit-reached` | + +## 10. Delivery behavior customers should know + +- At least once. Deduplicate on `webhook-id`. +- Ordered per incident and per endpoint. No global order. +- Retries: 5 s, 30 s, 2 min, 10 min, 30 min, then hourly, for 24 h, then `failed`. Failed deliveries can be replayed (single or bulk). +- A `410 Gone` answer disables the endpoint, with `disabled_reason`. +- Delivery log: `GET /v1/webhooks/{id}/deliveries`, kept 30 days by default. +- Webhook URLs on private or blocked networks are refused when saved (422) and again at delivery. +- `POST /v1/alert-routes/test` shows which routes a sample incident would match. diff --git a/docs/features/F06-notifications.md b/docs/features/F06-notifications.md index e16b89f..402e28d 100644 --- a/docs/features/F06-notifications.md +++ b/docs/features/F06-notifications.md @@ -90,6 +90,18 @@ Completed after M4 part 1: Still not built: `monitoring.heartbeat` (planned with F11 in M5). +### Notification catalog and device view + +Two read endpoints make the setup visible to a customer. + +- `GET /v1/notification-catalog` lists every notification (type, description, scope, whether routes deliver it by default, what must be set up for it, an example payload) and the filters of a route's `match`. It is static reference data; the same content is in [docs/api/notification-catalog.md](../api/notification-catalog.md). +- `GET /v1/devices/{device_id}/notifications` answers "what will notify me about this device, and does it work?". + - **Routes.** Every route of the tenant, with the filters the device satisfies (`matches_because`), the device's checks and severities it takes (`matched_checks`), and the delivery health of its webhook (`last_status`, `last_status_code`, `last_error`, `failed_24h`, `failing`). Health covers the whole webhook, not one device. Routes are returned on the first page only. Each route carries `route_external` and `endpoint.external` (`{source, type, id}` or null), and each delivery `route_external` and `endpoint_external`, so a client can tell which routes it created. + - **`would_fire`.** For each enabled check of the device and each severity, the routes are walked as the router walks them. A route would fire when it takes an incident and its webhook is enabled and not failing. Otherwise `reason` is one of `route_disabled`, `no_match`, `endpoint_disabled`, `shadowed` (an earlier route without `continue` takes the incident first) and `endpoint_failing` (the webhook's latest delivery failed for good or is being retried). A device without checks is evaluated once, without a check. + - **Deliveries.** The deliveries about the device, newest first, filtered by `status` and `event_type`, with cursor paging, the incident and check IDs, and the body that was sent. A grouped event is listed for every device in the group. Bodies hold no secret: signatures travel in headers, which are not stored. + - **Not covered.** The prediction does not know about maintenance windows or about suppression by an upstream failure at the time of the incident. +- **Device link.** Migration `00025` adds `webhook_delivery_devices (delivery_id, tenant_id, device_id)`, filled by a trigger on `webhook_deliveries` from the event envelope (`data.device.id` and `data.objects[].device.id`), and backfilled for the deliveries that exist. It is removed with the delivery by `delivery_retention`. Tenant events have no device and no rows. + ## Acceptance criteria - An n8n flow using a Standard Webhooks verification step accepts every event. diff --git a/docs/progress.md b/docs/progress.md index b56a328..246ff9d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -167,6 +167,10 @@ Suppression: a pool host that goes down suppresses its VMs' incidents and the ch Verified by `TestPool`, `TestPoolFollowsMaster`, `TestPoolFailures` (fake pool), `TestDiscoveredDevices` (engine, devices, incidents, metrics, limits, retention) and `TestDiscoveredHostSuppression`. +### v0.14.0 — notification catalog and device notifications + +`GET /v1/notification-catalog` and `GET /v1/devices/{device_id}/notifications` ([F06](features/F06-notifications.md#notification-catalog-and-device-view)): a customer sees which routes take a device's incidents, whether their webhooks work, whether the next incident would fire (`would_fire` with a `reason`), and the deliveries about the device with their payloads. Migration `00025` links deliveries to devices. Verified by `TestDeviceNotifications`. + ### v0.13.1 — box.agent every 5 s in every tenant `box.agent` beats every 5 s (was planned at 1 s): `DefaultInterval` and `MinInterval` are 5 s, `missed_count` defaults to 3 (silence after 15 s), and `Manifest.TenantFloorExempt` makes the 5 s floor hold in every tenant, so the tenant's 30 s or 60 s `min_check_interval_seconds` no longer blocks box provisioning. The ingest rate limit stays at 1/s. Verified by `TestBoxAgent`. diff --git a/internal/api/device_notifications_integration_test.go b/internal/api/device_notifications_integration_test.go new file mode 100644 index 0000000..c784c8f --- /dev/null +++ b/internal/api/device_notifications_integration_test.go @@ -0,0 +1,182 @@ +package api_test + +import ( + "context" + "log/slog" + "slices" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/plusclouds/monitoring.server/internal/engine" + "github.com/plusclouds/monitoring.server/internal/runner" + "github.com/plusclouds/monitoring.server/pkg/plugin" +) + +// F06: a device lists the routes that take its incidents, whether their +// webhooks work, and the deliveries about it. +func TestDeviceNotifications(t *testing.T) { + e := setup(t, true) + allowLoopback(t) + k := e.adminKey + rcv := newReceiver(t) + + dev := e.id(e.must(e.do("POST", "/v1/devices", k, map[string]any{"name": "web-1", "type": "server", + "tags": map[string]string{"rack": "a12"}}), 201)) + chk := e.id(e.must(e.do("POST", "/v1/devices/"+dev+"/checks", k, + map[string]any{"name": "c", "plugin": "http", "failure_count": 1}), 201)) + tenant := e.must(e.do("GET", "/v1/tenant", k, nil), 200).body["id"].(string) + eng, err := engine.New(engine.Options{System: db.System, Logger: slog.New(slog.DiscardHandler)}) + if err != nil { + t.Fatal(err) + } + feed := func(st plugin.Status) { + t.Helper() + if _, err := eng.Apply(context.Background(), runner.Result{ + TenantID: uuid.MustParse(tenant), DeviceID: uuid.MustParse(dev), CheckID: uuid.MustParse(chk), Plugin: "http", + Interval: time.Minute, Result: plugin.Result{Status: st, Output: "x", Time: time.Now()}, + }); err != nil { + t.Fatal(err) + } + } + get := func(query string) map[string]any { + t.Helper() + return e.must(e.do("GET", "/v1/devices/"+dev+"/notifications"+query, k, nil), 200).body + } + route := func(body map[string]any, name string) map[string]any { + t.Helper() + for _, r := range body["routes"].([]any) { + if m := r.(map[string]any); m["name"] == name { + return m + } + } + t.Fatalf("no route %q in %v", name, body["routes"]) + return nil + } + + // The catalog lists the notifications with example payloads. + cat := e.must(e.do("GET", "/v1/notification-catalog", k, nil), 200).body + var types []string + for _, ev := range cat["events"].([]any) { + m := ev.(map[string]any) + types = append(types, m["type"].(string)) + if m["example"].(map[string]any)["type"] != m["type"] { + t.Errorf("example of %v has type %v", m["type"], m["example"].(map[string]any)["type"]) + } + } + for _, want := range []string{"monitoring.incident.opened", "monitoring.incident.resolved", "monitoring.incident.escalated", + "monitoring.tenant.device_limit_reached", "monitoring.webhook.test"} { + if !slices.Contains(types, want) { + t.Errorf("catalog lacks %s: %v", want, types) + } + } + + // No routes yet. + if b := get(""); len(b["routes"].([]any)) != 0 || len(b["items"].([]any)) != 0 { + t.Fatalf("empty setup: %v", b) + } + e.must(e.do("GET", "/v1/devices/"+uuid.NewString()+"/notifications", k, nil), 404) + + hook := e.must(e.do("POST", "/v1/webhooks", k, map[string]any{"name": "h", "url": rcv.URL, + "external": map[string]any{"source": "plusclouds", "type": `PlusClouds\Monitoring\Channel`, "id": "ch-1"}}), 201).body["id"] + off := e.must(e.do("POST", "/v1/webhooks", k, map[string]any{"name": "off", "url": rcv.URL, "enabled": false}), 201).body["id"] + e.must(e.do("POST", "/v1/alert-routes", k, map[string]any{"name": "critical", "endpoint_id": hook, + "match": map[string]any{"severity": []string{"critical"}, "tags": map[string]string{"rack": "a12"}}, "continue": true, + "external": map[string]any{"source": "plusclouds", "type": `PlusClouds\Monitoring\ChannelRoute`, "id": "ch-1"}}), 201) + e.must(e.do("POST", "/v1/alert-routes", k, map[string]any{"name": "paused", "endpoint_id": hook, "enabled": false}), 201) + e.must(e.do("POST", "/v1/alert-routes", k, map[string]any{"name": "network", "endpoint_id": hook, + "match": map[string]any{"device_types": []string{"network"}}}), 201) + e.must(e.do("POST", "/v1/alert-routes", k, map[string]any{"name": "to-off", "endpoint_id": off}), 201) + + b := get("") + r := route(b, "critical") + if r["would_fire"] != true || r["reason"] != nil { + t.Errorf("critical route: %v", r) + } + because := r["matches_because"].([]any) + if !slices.Contains(because, any("severity:critical")) || !slices.Contains(because, any("tag:rack=a12")) { + t.Errorf("matches_because = %v", because) + } + if x, _ := r["route_external"].(map[string]any); x["type"] != `PlusClouds\Monitoring\ChannelRoute` || x["id"] != "ch-1" { + t.Errorf("route_external = %v", r["route_external"]) + } + if x, _ := r["endpoint"].(map[string]any)["external"].(map[string]any); x["type"] != `PlusClouds\Monitoring\Channel` { + t.Errorf("endpoint external = %v", r["endpoint"]) + } + if m := route(b, "network"); m["route_external"] != nil || m["endpoint"].(map[string]any)["external"] == nil { + t.Errorf("route without a reference: %v", m) + } + mc := r["matched_checks"].([]any) + if len(mc) != 1 || mc[0].(map[string]any)["check_id"] != chk || + len(mc[0].(map[string]any)["severities"].([]any)) != 1 { + t.Errorf("matched_checks = %v", mc) + } + for name, reason := range map[string]string{"paused": "route_disabled", "network": "no_match", "to-off": "endpoint_disabled"} { + if m := route(b, name); m["would_fire"] != false || m["reason"] != reason { + t.Errorf("route %s: would_fire=%v reason=%v, want %s", name, m["would_fire"], m["reason"], reason) + } + } + + // An incident is delivered and shows up under the device. + feed(plugin.Critical) + n := newNotifier(t) + n.Tick(context.Background()) + b = get("") + items := b["items"].([]any) + if len(items) != 1 { + t.Fatalf("items: %v", items) + } + d := items[0].(map[string]any) + if d["event_type"] != "monitoring.incident.opened" || d["status"] != "delivered" || d["check_id"] != chk || + d["incident_id"] == nil || d["route_name"] != "critical" { + t.Errorf("delivery: %v", d) + } + if x, _ := d["route_external"].(map[string]any); x["id"] != "ch-1" { + t.Errorf("delivery route_external = %v", d["route_external"]) + } + if x, _ := d["endpoint_external"].(map[string]any); x["type"] != `PlusClouds\Monitoring\Channel` { + t.Errorf("delivery endpoint_external = %v", d["endpoint_external"]) + } + data := d["payload"].(map[string]any)["data"].(map[string]any) + if data["device"].(map[string]any)["name"] != "web-1" || data["route"].(map[string]any)["name"] != "critical" { + t.Errorf("payload: %v", d["payload"]) + } + if h := route(b, "critical")["health"].(map[string]any); h["last_status"] != "delivered" || h["failing"] != false { + t.Errorf("health: %v", h) + } + + // Filters and paging. + feed(plugin.OK) + n.Tick(context.Background()) + if got := get("?event_type=monitoring.incident.resolved")["items"].([]any); len(got) != 1 { + t.Errorf("event_type filter: %v", got) + } + if got := get("?status=failed")["items"].([]any); len(got) != 0 { + t.Errorf("status filter: %v", got) + } + p1 := get("?limit=1") + if len(p1["items"].([]any)) != 1 || p1["next_cursor"] == nil { + t.Fatalf("page 1: %v", p1) + } + p2 := get("?limit=1&cursor=" + p1["next_cursor"].(string)) + if len(p2["items"].([]any)) != 1 || len(p2["routes"].([]any)) != 0 || + p2["items"].([]any)[0].(map[string]any)["id"] == p1["items"].([]any)[0].(map[string]any)["id"] { + t.Errorf("page 2: %v", p2) + } + if p2["next_cursor"] != nil { + t.Errorf("page 2 has a next cursor: %v", p2["next_cursor"]) + } + + // A failing receiver turns the route off. + rcv.code.Store(500) + feed(plugin.Critical) + n.Tick(context.Background()) + r = route(get(""), "critical") + if h := r["health"].(map[string]any); h["failing"] != true || h["last_error"] == nil { + t.Errorf("health after failure: %v", h) + } + if r["would_fire"] != false || r["reason"] != "endpoint_failing" { + t.Errorf("route with a failing webhook: %v", r) + } +} diff --git a/internal/api/gen/api.gen.go b/internal/api/gen/api.gen.go index cd46d06..c804973 100644 --- a/internal/api/gen/api.gen.go +++ b/internal/api/gen/api.gen.go @@ -274,6 +274,78 @@ func (e CheckStateStatus) Valid() bool { } } +// Defines values for DeviceDeliveryStatus. +const ( + DeviceDeliveryStatusCancelled DeviceDeliveryStatus = "cancelled" + DeviceDeliveryStatusDelivered DeviceDeliveryStatus = "delivered" + DeviceDeliveryStatusFailed DeviceDeliveryStatus = "failed" + DeviceDeliveryStatusPending DeviceDeliveryStatus = "pending" +) + +// Valid indicates whether the value is a known member of the DeviceDeliveryStatus enum. +func (e DeviceDeliveryStatus) Valid() bool { + switch e { + case DeviceDeliveryStatusCancelled: + return true + case DeviceDeliveryStatusDelivered: + return true + case DeviceDeliveryStatusFailed: + return true + case DeviceDeliveryStatusPending: + return true + default: + return false + } +} + +// Defines values for DeviceNotificationRouteMatchedChecksSeverities. +const ( + DeviceNotificationRouteMatchedChecksSeveritiesCritical DeviceNotificationRouteMatchedChecksSeverities = "critical" + DeviceNotificationRouteMatchedChecksSeveritiesWarning DeviceNotificationRouteMatchedChecksSeverities = "warning" +) + +// Valid indicates whether the value is a known member of the DeviceNotificationRouteMatchedChecksSeverities enum. +func (e DeviceNotificationRouteMatchedChecksSeverities) Valid() bool { + switch e { + case DeviceNotificationRouteMatchedChecksSeveritiesCritical: + return true + case DeviceNotificationRouteMatchedChecksSeveritiesWarning: + return true + default: + return false + } +} + +// Defines values for DeviceNotificationRouteReason. +const ( + DeviceNotificationRouteReasonEndpointDisabled DeviceNotificationRouteReason = "endpoint_disabled" + DeviceNotificationRouteReasonEndpointFailing DeviceNotificationRouteReason = "endpoint_failing" + DeviceNotificationRouteReasonLessThannil DeviceNotificationRouteReason = "" + DeviceNotificationRouteReasonNoMatch DeviceNotificationRouteReason = "no_match" + DeviceNotificationRouteReasonRouteDisabled DeviceNotificationRouteReason = "route_disabled" + DeviceNotificationRouteReasonShadowed DeviceNotificationRouteReason = "shadowed" +) + +// Valid indicates whether the value is a known member of the DeviceNotificationRouteReason enum. +func (e DeviceNotificationRouteReason) Valid() bool { + switch e { + case DeviceNotificationRouteReasonEndpointDisabled: + return true + case DeviceNotificationRouteReasonEndpointFailing: + return true + case DeviceNotificationRouteReasonLessThannil: + return true + case DeviceNotificationRouteReasonNoMatch: + return true + case DeviceNotificationRouteReasonRouteDisabled: + return true + case DeviceNotificationRouteReasonShadowed: + return true + default: + return false + } +} + // Defines values for DeviceStatusHealth. const ( DeviceStatusHealthCritical DeviceStatusHealth = "critical" @@ -352,6 +424,30 @@ func (e DeviceType) Valid() bool { } } +// Defines values for EndpointHealthLastStatus. +const ( + EndpointHealthLastStatusDelivered EndpointHealthLastStatus = "delivered" + EndpointHealthLastStatusFailed EndpointHealthLastStatus = "failed" + EndpointHealthLastStatusLessThannil EndpointHealthLastStatus = "" + EndpointHealthLastStatusPending EndpointHealthLastStatus = "pending" +) + +// Valid indicates whether the value is a known member of the EndpointHealthLastStatus enum. +func (e EndpointHealthLastStatus) Valid() bool { + switch e { + case EndpointHealthLastStatusDelivered: + return true + case EndpointHealthLastStatusFailed: + return true + case EndpointHealthLastStatusLessThannil: + return true + case EndpointHealthLastStatusPending: + return true + default: + return false + } +} + // Defines values for GroupField. const ( GroupFieldCheckId GroupField = "check_id" @@ -628,6 +724,27 @@ func (e MqttCredentialCreateProfile) Valid() bool { } } +// Defines values for NotificationCatalogEventsScope. +const ( + NotificationCatalogEventsScopeIncident NotificationCatalogEventsScope = "incident" + NotificationCatalogEventsScopeTenant NotificationCatalogEventsScope = "tenant" + NotificationCatalogEventsScopeTest NotificationCatalogEventsScope = "test" +) + +// Valid indicates whether the value is a known member of the NotificationCatalogEventsScope enum. +func (e NotificationCatalogEventsScope) Valid() bool { + switch e { + case NotificationCatalogEventsScopeIncident: + return true + case NotificationCatalogEventsScopeTenant: + return true + case NotificationCatalogEventsScopeTest: + return true + default: + return false + } +} + // Defines values for PluginBillingClass. const ( Advanced PluginBillingClass = "advanced" @@ -1033,6 +1150,30 @@ func (e BindDeviceMqttJSONBodyProfile) Valid() bool { } } +// Defines values for ListDeviceNotificationsParamsStatus. +const ( + ListDeviceNotificationsParamsStatusCancelled ListDeviceNotificationsParamsStatus = "cancelled" + ListDeviceNotificationsParamsStatusDelivered ListDeviceNotificationsParamsStatus = "delivered" + ListDeviceNotificationsParamsStatusFailed ListDeviceNotificationsParamsStatus = "failed" + ListDeviceNotificationsParamsStatusPending ListDeviceNotificationsParamsStatus = "pending" +) + +// Valid indicates whether the value is a known member of the ListDeviceNotificationsParamsStatus enum. +func (e ListDeviceNotificationsParamsStatus) Valid() bool { + switch e { + case ListDeviceNotificationsParamsStatusCancelled: + return true + case ListDeviceNotificationsParamsStatusDelivered: + return true + case ListDeviceNotificationsParamsStatusFailed: + return true + case ListDeviceNotificationsParamsStatusPending: + return true + default: + return false + } +} + // Defines values for ListIncidentsParamsStatus. const ( ListIncidentsParamsStatusAcknowledged ListIncidentsParamsStatus = "acknowledged" @@ -1643,6 +1784,33 @@ type Device struct { UpdatedAt time.Time `json:"updated_at"` } +// DeviceDelivery defines model for DeviceDelivery. +type DeviceDelivery struct { + Attempts int `json:"attempts"` + CheckId *openapi_types.UUID `json:"check_id"` + CreatedAt time.Time `json:"created_at"` + DeliveredAt *time.Time `json:"delivered_at"` + EndpointExternal *ExternalRef `json:"endpoint_external"` + EndpointId openapi_types.UUID `json:"endpoint_id"` + EndpointName string `json:"endpoint_name"` + EventType string `json:"event_type"` + Id openapi_types.UUID `json:"id"` + IncidentId *openapi_types.UUID `json:"incident_id"` + LastAttemptAt *time.Time `json:"last_attempt_at"` + LastError *string `json:"last_error"` + LastStatusCode *int `json:"last_status_code"` + + // Payload The body that was sent + Payload map[string]interface{} `json:"payload"` + RouteExternal *ExternalRef `json:"route_external"` + RouteId *openapi_types.UUID `json:"route_id"` + RouteName *string `json:"route_name"` + Status DeviceDeliveryStatus `json:"status"` +} + +// DeviceDeliveryStatus defines model for DeviceDelivery.Status. +type DeviceDeliveryStatus string + // DeviceMqtt defines model for DeviceMqtt. type DeviceMqtt struct { ConnectionCheckId *openapi_types.UUID `json:"connection_check_id"` @@ -1661,6 +1829,60 @@ type DeviceMqtt struct { } `json:"session"` } +// DeviceNotificationRoute defines model for DeviceNotificationRoute. +type DeviceNotificationRoute struct { + Enabled bool `json:"enabled"` + Endpoint struct { + DisabledReason *string `json:"disabled_reason"` + Enabled bool `json:"enabled"` + External *ExternalRef `json:"external"` + Id openapi_types.UUID `json:"id"` + Name string `json:"name"` + } `json:"endpoint"` + + // Health Delivery health of the route's webhook, over all devices + Health EndpointHealth `json:"health"` + + // Match Every set field must match; an empty match takes everything. + Match AlertRouteMatch `json:"match"` + + // MatchedChecks The device's enabled checks whose incidents the route takes + MatchedChecks []struct { + CheckId openapi_types.UUID `json:"check_id"` + Name string `json:"name"` + Plugin string `json:"plugin"` + Severities []DeviceNotificationRouteMatchedChecksSeverities `json:"severities"` + } `json:"matched_checks"` + + // MatchesBecause The route's filters that this device satisfies, as codes: + // `all_incidents`, `event_type:`, `severity:`, `device_type:`, `site:`, `device_id:`, + // `check_id:`, `tag:=`. + MatchesBecause []string `json:"matches_because"` + Name string `json:"name"` + Position int `json:"position"` + + // Reason Why `would_fire` is false; null when true + Reason *DeviceNotificationRouteReason `json:"reason"` + RouteExternal *ExternalRef `json:"route_external"` + RouteId openapi_types.UUID `json:"route_id"` + + // WouldFire The next incident of this device would be delivered through this route + WouldFire bool `json:"would_fire"` +} + +// DeviceNotificationRouteMatchedChecksSeverities defines model for DeviceNotificationRoute.MatchedChecks.Severities. +type DeviceNotificationRouteMatchedChecksSeverities string + +// DeviceNotificationRouteReason Why `would_fire` is false; null when true +type DeviceNotificationRouteReason string + +// DeviceNotifications defines model for DeviceNotifications. +type DeviceNotifications struct { + Items []DeviceDelivery `json:"items"` + NextCursor *string `json:"next_cursor"` + Routes []DeviceNotificationRoute `json:"routes"` +} + // DevicePage defines model for DevicePage. type DevicePage struct { Items []Device `json:"items"` @@ -1703,6 +1925,22 @@ type DeviceWrite struct { Type DeviceType `json:"type"` } +// EndpointHealth Delivery health of the route's webhook, over all devices +type EndpointHealth struct { + // Failed24h Deliveries that failed for good in the last 24 hours + Failed24h int `json:"failed_24h"` + + // Failing The latest delivery failed or is being retried + Failing bool `json:"failing"` + LastAttemptAt *time.Time `json:"last_attempt_at"` + LastError *string `json:"last_error"` + LastStatus *EndpointHealthLastStatus `json:"last_status"` + LastStatusCode *int `json:"last_status_code"` +} + +// EndpointHealthLastStatus defines model for EndpointHealth.LastStatus. +type EndpointHealthLastStatus string + // ExternalRef defines model for ExternalRef. type ExternalRef struct { Id string `json:"id"` @@ -1967,6 +2205,30 @@ type MqttCredentialPatch struct { Name *string `json:"name,omitempty"` } +// NotificationCatalog defines model for NotificationCatalog. +type NotificationCatalog struct { + Events []struct { + // Default Delivered by routes that do not name event types + Default bool `json:"default"` + Description string `json:"description"` + + // Example The CloudEvents body as the receiver gets it, with made-up values + Example map[string]interface{} `json:"example"` + Scope NotificationCatalogEventsScope `json:"scope"` + + // Trigger What has to be set up for the event to be sent; null when nothing + Trigger *string `json:"trigger"` + Type string `json:"type"` + } `json:"events"` + GroupByFields []string `json:"group_by_fields"` + + // MatchFields The filters of an alert route's `match`, with what each tests + MatchFields map[string]string `json:"match_fields"` +} + +// NotificationCatalogEventsScope defines model for NotificationCatalog.Events.Scope. +type NotificationCatalogEventsScope string + // Plugin defines model for Plugin. type Plugin struct { BillingClass PluginBillingClass `json:"billing_class"` @@ -2629,6 +2891,19 @@ type BindDeviceMqttJSONBody struct { // BindDeviceMqttJSONBodyProfile defines parameters for BindDeviceMqtt. type BindDeviceMqttJSONBodyProfile string +// ListDeviceNotificationsParams defines parameters for ListDeviceNotifications. +type ListDeviceNotificationsParams struct { + Status *ListDeviceNotificationsParamsStatus `form:"status,omitempty" json:"status,omitempty"` + EventType *string `form:"event_type,omitempty" json:"event_type,omitempty"` + + // Cursor Opaque cursor from `next_cursor` of the previous page. + Cursor *Cursor `form:"cursor,omitempty" json:"cursor,omitempty"` + Limit *int `form:"limit,omitempty" json:"limit,omitempty"` +} + +// ListDeviceNotificationsParamsStatus defines parameters for ListDeviceNotifications. +type ListDeviceNotificationsParamsStatus string + // ListIncidentsParams defines parameters for ListIncidents. type ListIncidentsParams struct { // Status `active` is open or acknowledged. @@ -3167,6 +3442,9 @@ type ServerInterface interface { // BindDeviceMqtt Make a device an MQTT device (pre-registration) // (POST /devices/{device_id}/mqtt) BindDeviceMqtt(w http.ResponseWriter, r *http.Request, deviceId DeviceID) + // ListDeviceNotifications The notifications set up for a device, whether they work, and what was sent + // (GET /devices/{device_id}/notifications) + ListDeviceNotifications(w http.ResponseWriter, r *http.Request, deviceId DeviceID, params ListDeviceNotificationsParams) // TestDevice Run every enabled check of the device once // (POST /devices/{device_id}/test) TestDevice(w http.ResponseWriter, r *http.Request, deviceId DeviceID) @@ -3227,6 +3505,9 @@ type ServerInterface interface { // SummarizeMetrics Statistics of the selected series over the whole window // (GET /metrics/summary) SummarizeMetrics(w http.ResponseWriter, r *http.Request, params SummarizeMetricsParams) + // GetNotificationCatalog Every notification a webhook can receive, with example payloads + // (GET /notification-catalog) + GetNotificationCatalog(w http.ResponseWriter, r *http.Request) // ListPlugins List the plugins this engine was built with // (GET /plugins) ListPlugins(w http.ResponseWriter, r *http.Request) @@ -5189,6 +5470,87 @@ func (siw *ServerInterfaceWrapper) BindDeviceMqtt(w http.ResponseWriter, r *http handler.ServeHTTP(w, r) } +// ListDeviceNotifications operation middleware +func (siw *ServerInterfaceWrapper) ListDeviceNotifications(w http.ResponseWriter, r *http.Request) { + + var err error + _ = err + + // ------------- Path parameter "device_id" ------------- + var deviceId DeviceID + + err = runtime.BindStyledParameterWithOptions("simple", "device_id", r.PathValue("device_id"), &deviceId, runtime.BindStyledParameterOptions{ParamLocation: runtime.ParamLocationPath, Explode: false, Required: true, Type: "string", Format: "uuid", ValueIsUnescaped: true}) + if err != nil { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "device_id", Err: err}) + return + } + + // Parameter object where we will unmarshal all parameters from the context + var params ListDeviceNotificationsParams + + // ------------- Optional query parameter "status" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "status", r.URL.Query(), ¶ms.Status, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "status"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "status", Err: err}) + } + return + } + + // ------------- Optional query parameter "event_type" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "event_type", r.URL.Query(), ¶ms.EventType, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "event_type"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "event_type", Err: err}) + } + return + } + + // ------------- Optional query parameter "cursor" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "cursor", r.URL.Query(), ¶ms.Cursor, runtime.BindQueryParameterOptions{Type: "string", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "cursor"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "cursor", Err: err}) + } + return + } + + // ------------- Optional query parameter "limit" ------------- + + err = runtime.BindQueryParameterWithOptions("form", true, false, "limit", r.URL.Query(), ¶ms.Limit, runtime.BindQueryParameterOptions{Type: "integer", Format: ""}) + if err != nil { + var requiredError *runtime.RequiredParameterError + if errors.As(err, &requiredError) { + siw.ErrorHandlerFunc(w, r, &RequiredParamError{ParamName: "limit"}) + } else { + siw.ErrorHandlerFunc(w, r, &InvalidParamFormatError{ParamName: "limit", Err: err}) + } + return + } + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.ListDeviceNotifications(w, r, deviceId, params) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // TestDevice operation middleware func (siw *ServerInterfaceWrapper) TestDevice(w http.ResponseWriter, r *http.Request) { @@ -6003,6 +6365,20 @@ func (siw *ServerInterfaceWrapper) SummarizeMetrics(w http.ResponseWriter, r *ht handler.ServeHTTP(w, r) } +// GetNotificationCatalog operation middleware +func (siw *ServerInterfaceWrapper) GetNotificationCatalog(w http.ResponseWriter, r *http.Request) { + + handler := http.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + siw.Handler.GetNotificationCatalog(w, r) + })) + + for _, middleware := range siw.HandlerMiddlewares { + handler = middleware(handler) + } + + handler.ServeHTTP(w, r) +} + // ListPlugins operation middleware func (siw *ServerInterfaceWrapper) ListPlugins(w http.ResponseWriter, r *http.Request) { @@ -7477,6 +7853,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/devices/{device_id}/dependents", wrapper.ListDeviceDependents) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/devices/{device_id}/impact", wrapper.GetDeviceImpact) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/devices/{device_id}/test", wrapper.TestDevice) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/devices/{device_id}/notifications", wrapper.ListDeviceNotifications) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/devices/{device_id}/checks", wrapper.ListDeviceChecks) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/devices/{device_id}/checks", wrapper.CreateCheck) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/checks", wrapper.ListChecks) @@ -7509,6 +7886,7 @@ func HandlerWithOptions(si ServerInterface, options StdHTTPServerOptions) http.H m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/webhooks/{webhook_id}/deliveries", wrapper.ListWebhookDeliveries) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{webhook_id}/deliveries/{delivery_id}/replay", wrapper.ReplayWebhookDelivery) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/webhooks/{webhook_id}/deliveries/replay", wrapper.ReplayWebhookDeliveries) + m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/notification-catalog", wrapper.GetNotificationCatalog) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/alert-routes/test", wrapper.TestAlertRoutes) m.HandleFunc(http.MethodGet+" "+options.BaseURL+"/alert-routes", wrapper.ListAlertRoutes) m.HandleFunc(http.MethodPost+" "+options.BaseURL+"/alert-routes", wrapper.CreateAlertRoute) @@ -9603,6 +9981,46 @@ func (response BindDeviceMqttdefaultApplicationProblemPlusJSONResponse) VisitBin return err } +type ListDeviceNotificationsRequestObject struct { + DeviceId DeviceID `json:"device_id"` + Params ListDeviceNotificationsParams +} + +type ListDeviceNotificationsResponseObject interface { + VisitListDeviceNotificationsResponse(w http.ResponseWriter) error +} + +type ListDeviceNotifications200JSONResponse DeviceNotifications + +func (response ListDeviceNotifications200JSONResponse) VisitListDeviceNotificationsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type ListDeviceNotificationsdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response ListDeviceNotificationsdefaultApplicationProblemPlusJSONResponse) VisitListDeviceNotificationsResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type TestDeviceRequestObject struct { DeviceId DeviceID `json:"device_id"` } @@ -10463,6 +10881,44 @@ func (response SummarizeMetricsdefaultApplicationProblemPlusJSONResponse) VisitS return err } +type GetNotificationCatalogRequestObject struct { +} + +type GetNotificationCatalogResponseObject interface { + VisitGetNotificationCatalogResponse(w http.ResponseWriter) error +} + +type GetNotificationCatalog200JSONResponse NotificationCatalog + +func (response GetNotificationCatalog200JSONResponse) VisitGetNotificationCatalogResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response); err != nil { + return err + } + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(200) + _, err := buf.WriteTo(w) + return err +} + +type GetNotificationCatalogdefaultApplicationProblemPlusJSONResponse struct { + Body Problem + StatusCode int +} + +func (response GetNotificationCatalogdefaultApplicationProblemPlusJSONResponse) VisitGetNotificationCatalogResponse(w http.ResponseWriter) error { + + var buf bytes.Buffer + if err := json.NewEncoder(&buf).Encode(response.Body); err != nil { + return err + } + w.Header().Set("Content-Type", "application/problem+json") + w.WriteHeader(response.StatusCode) + _, err := buf.WriteTo(w) + return err +} + type ListPluginsRequestObject struct { } @@ -12074,6 +12530,9 @@ type StrictServerInterface interface { // BindDeviceMqtt Make a device an MQTT device (pre-registration) // (POST /devices/{device_id}/mqtt) BindDeviceMqtt(ctx context.Context, request BindDeviceMqttRequestObject) (BindDeviceMqttResponseObject, error) + // ListDeviceNotifications The notifications set up for a device, whether they work, and what was sent + // (GET /devices/{device_id}/notifications) + ListDeviceNotifications(ctx context.Context, request ListDeviceNotificationsRequestObject) (ListDeviceNotificationsResponseObject, error) // TestDevice Run every enabled check of the device once // (POST /devices/{device_id}/test) TestDevice(ctx context.Context, request TestDeviceRequestObject) (TestDeviceResponseObject, error) @@ -12134,6 +12593,9 @@ type StrictServerInterface interface { // SummarizeMetrics Statistics of the selected series over the whole window // (GET /metrics/summary) SummarizeMetrics(ctx context.Context, request SummarizeMetricsRequestObject) (SummarizeMetricsResponseObject, error) + // GetNotificationCatalog Every notification a webhook can receive, with example payloads + // (GET /notification-catalog) + GetNotificationCatalog(ctx context.Context, request GetNotificationCatalogRequestObject) (GetNotificationCatalogResponseObject, error) // ListPlugins List the plugins this engine was built with // (GET /plugins) ListPlugins(ctx context.Context, request ListPluginsRequestObject) (ListPluginsResponseObject, error) @@ -13774,6 +14236,33 @@ func (sh *strictHandler) BindDeviceMqtt(w http.ResponseWriter, r *http.Request, } } +// ListDeviceNotifications operation middleware +func (sh *strictHandler) ListDeviceNotifications(w http.ResponseWriter, r *http.Request, deviceId DeviceID, params ListDeviceNotificationsParams) { + var request ListDeviceNotificationsRequestObject + + request.DeviceId = deviceId + request.Params = params + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.ListDeviceNotifications(ctx, request.(ListDeviceNotificationsRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "ListDeviceNotifications") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(ListDeviceNotificationsResponseObject); ok { + if err := validResponse.VisitListDeviceNotificationsResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // TestDevice operation middleware func (sh *strictHandler) TestDevice(w http.ResponseWriter, r *http.Request, deviceId DeviceID) { var request TestDeviceRequestObject @@ -14320,6 +14809,30 @@ func (sh *strictHandler) SummarizeMetrics(w http.ResponseWriter, r *http.Request } } +// GetNotificationCatalog operation middleware +func (sh *strictHandler) GetNotificationCatalog(w http.ResponseWriter, r *http.Request) { + var request GetNotificationCatalogRequestObject + + handler := func(ctx context.Context, w http.ResponseWriter, r *http.Request, request interface{}) (interface{}, error) { + return sh.ssi.GetNotificationCatalog(ctx, request.(GetNotificationCatalogRequestObject)) + } + for _, middleware := range sh.middlewares { + handler = middleware(handler, "GetNotificationCatalog") + } + + response, err := handler(r.Context(), w, r, request) + + if err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } else if validResponse, ok := response.(GetNotificationCatalogResponseObject); ok { + if err := validResponse.VisitGetNotificationCatalogResponse(w); err != nil { + sh.options.ResponseErrorHandlerFunc(w, r, err) + } + } else if response != nil { + sh.options.ResponseErrorHandlerFunc(w, r, fmt.Errorf("unexpected response type: %T", response)) + } +} + // ListPlugins operation middleware func (sh *strictHandler) ListPlugins(w http.ResponseWriter, r *http.Request) { var request ListPluginsRequestObject @@ -15346,356 +15859,382 @@ func (sh *strictHandler) TestWebhook(w http.ResponseWriter, r *http.Request, web // const string: with thousands of chunks the chained `+` fold is several // times slower for the Go compiler than parsing a slice literal. var swaggerSpec = []string{ - "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", } // decodeSpec returns the embedded OpenAPI spec as raw JSON bytes, diff --git a/internal/api/handlers_notifications.go b/internal/api/handlers_notifications.go new file mode 100644 index 0000000..6d19a50 --- /dev/null +++ b/internal/api/handlers_notifications.go @@ -0,0 +1,122 @@ +package api + +import ( + "context" + "encoding/json" + + "github.com/jackc/pgx/v5" + + "github.com/plusclouds/monitoring.server/internal/api/gen" + "github.com/plusclouds/monitoring.server/internal/webhook" +) + +// GetNotificationCatalog lists the notifications a webhook can receive, each +// with an example payload (F06). +func (s *Server) GetNotificationCatalog(_ context.Context, _ gen.GetNotificationCatalogRequestObject) (gen.GetNotificationCatalogResponseObject, error) { + events := webhook.Catalog() + out := make([]map[string]any, len(events)) + for i, e := range events { + var trigger *string + if e.Trigger != "" { + trigger = &e.Trigger + } + out[i] = map[string]any{"type": e.Type, "description": e.Description, "scope": e.Scope, "default": e.Default, + "trigger": trigger, "example": e.Example} + } + res, err := via[gen.NotificationCatalog](map[string]any{ + "events": out, "match_fields": webhook.RouteMatchFields, "group_by_fields": webhook.GroupFields, + }) + return gen.GetNotificationCatalog200JSONResponse(res), err +} + +// ListDeviceNotifications shows the notification setup of a device: the +// routes that take its incidents, the health of their webhooks and the +// deliveries about the device (F06). +func (s *Server) ListDeviceNotifications(ctx context.Context, req gen.ListDeviceNotificationsRequestObject) (gen.ListDeviceNotificationsResponseObject, error) { + _, t, err := tenantScope(ctx, roleReadOnly, false) + if err != nil { + return nil, err + } + q := req.Params + n := 20 + if q.Limit != nil { + n = min(max(*q.Limit, 1), 100) + } + f := webhook.DeviceDeliveryFilter{Limit: n + 1, EventType: q.EventType, IncidentURLTemplate: s.incidentURL} + if q.Status != nil { + v := string(*q.Status) + f.Status = &v + } + first := q.Cursor == nil + if !first { + id, err := decodeCursorID(*q.Cursor) + if err != nil { + return nil, err + } + f.Before = &id + } + var routes []webhook.DeviceRoute + var items []webhook.DeviceDelivery + err = s.tx(ctx, t, func(tx pgx.Tx) error { + // Evaluating also proves the device exists (404 otherwise). + if routes, err = webhook.DeviceRoutes(ctx, tx, t.ID, req.DeviceId); err != nil { + return err + } + items, err = webhook.DeviceDeliveries(ctx, tx, t.ID, req.DeviceId, f) + return err + }) + if err != nil { + return nil, err + } + next := (*string)(nil) + if len(items) > n { + items = items[:n] + c := encodeCursorID(items[n-1].ID) + next = &c + } + out := map[string]any{"routes": []any{}, "items": make([]any, len(items)), "next_cursor": next} + if first { + rs := make([]any, len(routes)) + for i, r := range routes { + rs[i] = deviceRouteView(r) + } + out["routes"] = rs + } + for i, d := range items { + out["items"].([]any)[i] = map[string]any{ + "id": d.ID, "event_type": d.EventType, "incident_id": d.IncidentID, "check_id": d.CheckID, + "route_id": d.RouteID, "route_name": d.RouteName, "route_external": d.RouteExternal, + "endpoint_id": d.EndpointID, "endpoint_name": d.EndpointName, "endpoint_external": d.EndpointExt, "status": d.Status, "attempts": d.Attempts, + "last_status_code": d.LastStatusCode, "last_error": d.LastError, "last_attempt_at": d.LastAttemptAt, + "delivered_at": d.DeliveredAt, "created_at": d.CreatedAt, "payload": json.RawMessage(d.Payload), + } + } + res, err := via[gen.DeviceNotifications](out) + return gen.ListDeviceNotifications200JSONResponse(res), err +} + +func deviceRouteView(r webhook.DeviceRoute) map[string]any { + checks := make([]map[string]any, len(r.Matches)) + for i, m := range r.Matches { + checks[i] = map[string]any{"check_id": m.CheckID, "name": m.Name, "plugin": m.Plugin, "severities": m.Severities} + } + var reason *string + if r.Reason != "" { + reason = &r.Reason + } + h := r.Health + status := h.LastStatus + if status != nil && *status == "cancelled" { + status = nil + } + return map[string]any{ + "route_id": r.Route.ID, "route_external": r.Route.External, "name": r.Route.Name, "position": r.Route.Position, "enabled": r.Route.Enabled, + "match": r.Route.Match, + "endpoint": map[string]any{"id": r.Route.EndpointID, "name": r.Endpoint.Name, "enabled": r.Endpoint.Enabled, + "disabled_reason": r.Endpoint.DisabledReason, "external": r.Endpoint.External}, + "matches_because": nonNilSlice(r.Because), "matched_checks": checks, + "health": map[string]any{"last_attempt_at": h.LastAttemptAt, "last_status": status, + "last_status_code": h.LastStatusCode, "last_error": h.LastError, "failed_24h": h.Failed24h, "failing": h.Failing}, + "would_fire": r.WouldFire, "reason": reason, + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 0f0a791..177fd06 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -54,6 +54,7 @@ type Server struct { hooks *webhook.Store sender *webhook.Sender rotationOverlap time.Duration + incidentURL string // notifier.incident_url_template, for data.links in delivery payloads identitySource string platformRate int maxPoints int // per metric series in a query (F07) @@ -86,6 +87,7 @@ func New(o Options) (*Server, error) { hooks: &webhook.Store{Keys: o.Keys}, sender: webhook.NewSender(o.Config), rotationOverlap: o.Config.Notifier.SecretRotationOverlap.D(), + incidentURL: o.Config.Notifier.IncidentURLTemplate, cfg: o.Config.API, tls: o.Config.TLS, db: o.DB, diff --git a/internal/webhook/catalog.go b/internal/webhook/catalog.go new file mode 100644 index 0000000..6d7da8a --- /dev/null +++ b/internal/webhook/catalog.go @@ -0,0 +1,156 @@ +package webhook + +import ( + "github.com/plusclouds/monitoring.server/internal/incident" +) + +// EventInfo describes one notification a customer can receive (F06). +type EventInfo struct { + Type string + Description string + // Scope is "incident", "tenant" or "test". + Scope string + // Default: routes deliver it without naming it in match.event_types. + Default bool + // Trigger says what has to be set up for the event to be sent, when it + // is not sent on its own. + Trigger string + Example map[string]any +} + +// Catalog lists the notifications a webhook can receive, each with the +// payload as the receiver gets it. The examples use made-up values; the +// shape is the one the notifier sends. +func Catalog() []EventInfo { + incidentEvent := func(typ, desc, trigger string, def bool, tweak func(obj, data map[string]any)) EventInfo { + data, obj := exampleIncident() + if tweak != nil { + tweak(obj, data) + } + return EventInfo{Type: typ, Description: desc, Scope: "incident", Default: def, Trigger: trigger, + Example: exampleEnvelope(typ, "0192f7c4-4a00-7000-8000-00000000a001", data)} + } + ack := func(obj, data map[string]any) { + obj["status"] = "acknowledged" + obj["acknowledged_at"] = "2026-10-12T09:20:00Z" + obj["acknowledged_by"] = "0192f7a0-0000-7000-8000-0000000000a1" + obj["acknowledged_by_external_id"] = "5d2f…" + data["actor"] = map[string]any{"kind": "user", "user_id": "0192f7a0-0000-7000-8000-0000000000a1", "external_id": "5d2f…"} + } + return []EventInfo{ + incidentEvent(incident.EventOpened, + "A check crossed a threshold and an incident opened. Also sent when a suppressed incident stops being explained by an upstream failure.", + "", true, nil), + incidentEvent(incident.EventUpdated, + "The incident's severity changed, or it started or stopped flapping.", "", true, + func(obj, _ map[string]any) { obj["severity"] = "warning"; obj["flapping"] = true }), + incidentEvent(incident.EventAcknowledged, "A user acknowledged the incident.", "", true, ack), + incidentEvent(incident.EventResolved, + "The incident ended: the check recovered, or it was resolved by a user, a deleted or disabled check, or a vanished object.", + "", true, func(obj, _ map[string]any) { + obj["status"] = "resolved" + obj["resolved_at"] = "2026-10-12T09:40:00Z" + obj["resolved_by"] = "recovery" + }), + incidentEvent(incident.EventRenotify, + "A reminder: the incident is still open and unacknowledged after the route's repeat interval.", + "Set repeat_interval_seconds on the alert route.", false, nil), + incidentEvent(incident.EventEscalated, + "A later step of the route's escalation is due, with that step's labels.", + "Give the alert route more than one step.", false, func(_, data map[string]any) { + data["route"] = map[string]any{"id": exampleRouteID, "name": "datacenter critical", "step": 1, + "labels": map[string]any{"recipient": "on-call"}} + }), + incidentEvent(incident.EventCommented, + "Someone commented on the incident. data.object.comment holds the comment.", + "List this type in match.event_types of the alert route.", false, func(obj, data map[string]any) { + obj["comment"] = map[string]any{"id": "0192f7c4-4a00-7000-8000-00000000b001", "author_kind": "user", + "author_user_id": "0192f7a0-0000-7000-8000-0000000000a1", "author_external_id": "5d2f…", + "body": "Looking into it.", "created_at": "2026-10-12T09:18:00Z"} + data["actor"] = map[string]any{"kind": "user", "user_id": "0192f7a0-0000-7000-8000-0000000000a1", "external_id": "5d2f…"} + }), + tenantExample(incident.EventDeviceLimitReached, "devices", + "The account reached its device limit; the next device create is refused."), + tenantExample(incident.EventCheckLimitReached, "checks", + "The account reached its check limit; the next check create is refused."), + {Type: "monitoring.webhook.test", Scope: "test", + Description: "A test sent on request with POST /v1/webhooks/{id}/test. data.object is the webhook.", + Trigger: "POST /v1/webhooks/{id}/test.", + Example: exampleEnvelope("monitoring.webhook.test", exampleWebhookID, map[string]any{ + "account_id": exampleAccount, "object_type": `Monitoring\Webhooks`, + "object": map[string]any{"id": exampleWebhookID, "name": "ops channel", "url": "https://hooks.example.com/monitoring", + "enabled": true}, + "actor": map[string]any{"kind": "user", "user_id": "0192f7a0-0000-7000-8000-0000000000a1", "external_id": "5d2f…"}})}, + } +} + +// RouteMatchFields are the filters of an alert route's match, with what +// each one tests. +var RouteMatchFields = map[string]string{ + "event_types": "Event types to deliver. Empty means the defaults of the catalog.", + "severity": "warning or critical.", + "device_types": "The device's type.", + "site_ids": "The device's site.", + "device_ids": "Specific devices.", + "check_ids": "Specific checks.", + "tags": "Tags the device carries; every key and value must be present.", +} + +const ( + exampleRouteID = "0192f7a0-0000-7000-8000-00000000f001" + exampleWebhookID = "0192f7a0-0000-7000-8000-00000000f101" + exampleAccount = "9b1c2d3e-0000-4000-8000-000000000001" +) + +func tenantExample(typ, resource, desc string) EventInfo { + return EventInfo{Type: typ, Description: desc, Scope: "tenant", Default: true, + Trigger: "Only routes without device, check or severity filters deliver it.", + Example: exampleEnvelope(typ, "tenant:0192f7b0-0000-7000-8000-000000000001", map[string]any{ + "account_id": exampleAccount, "object_type": `Monitoring\Tenants`, + "object": map[string]any{"tenant_id": "0192f7b0-0000-7000-8000-000000000001", "resource": resource, + "limit": 50, "count": 50}, + "device": nil, "check": nil, "route": routeExample(0), "actor": nil})} +} + +func routeExample(step int) map[string]any { + return map[string]any{"id": exampleRouteID, "name": "datacenter critical", "step": step, + "labels": map[string]any{"recipient": "noc"}} +} + +func exampleEnvelope(typ, subject string, data map[string]any) map[string]any { + return map[string]any{ + "specversion": "1.0", "id": "0192f7c4-5b1e-7a3c-9d2e-3f4a5b6c7d8e", + "source": "/monitoring/0192f7a0-0000-7000-8000-000000000001/0192f7b0-0000-7000-8000-000000000001", + "type": typ, "time": "2026-10-12T09:15:00Z", "datacontenttype": "application/json", + "subject": subject, "data": data, + } +} + +// exampleIncident returns data of an opened incident, and its object. +func exampleIncident() (data, obj map[string]any) { + obj = map[string]any{ + "id": "0192f7c4-4a00-7000-8000-00000000a001", "check_id": "0192f7a0-0000-7000-8000-00000000c001", + "device_id": "0192f7a0-0000-7000-8000-00000000d001", "object_key": nil, "object_name": nil, + "severity": "critical", "status": "open", "summary": "CPU load 97 % (critical above 90 %)", + "last_output": "load=97", "rule_id": "cpu-critical", "rule_name": "CPU critical", "flapping": false, + "suppressed": false, "root_incident_id": nil, "root_device_id": "0192f7a0-0000-7000-8000-00000000d001", + "opened_at": "2026-10-12T09:15:00Z", "acknowledged_at": nil, "acknowledged_by": nil, + "acknowledged_by_external_id": nil, "resolved_at": nil, "resolved_by": nil, "updated_at": "2026-10-12T09:15:00Z", + } + data = map[string]any{ + "account_id": exampleAccount, "object_type": incident.ObjectType, "object": obj, + "device": map[string]any{ + "id": "0192f7a0-0000-7000-8000-00000000d001", "name": "web-01", "address": "10.0.0.11", "type": "server", + "site_id": "0192f7a0-0000-7000-8000-00000000e001", "tags": map[string]any{"rack": "a12"}, + "external": map[string]any{"source": "plusclouds", "type": `NextDeveloper\IAAS\Database\Models\VirtualMachines`, "id": "9b1c…"}, + "path": []any{map[string]any{"id": "0192f7a0-0000-7000-8000-00000000b001", "name": "hv-07", "external": nil}}, + }, + "check": map[string]any{ + "id": "0192f7a0-0000-7000-8000-00000000c001", "name": "CPU load", "plugin": "cpu", "last_output": "load=97", + "rule_id": "cpu-critical", "rule_name": "CPU critical", "runbook_url": nil, "thresholds": []any{}, + }, + "route": routeExample(0), "actor": nil, + "links": map[string]any{"incident": "https://panel.example.com/monitoring/incidents/0192f7c4-4a00-7000-8000-00000000a001"}, + } + return data, obj +} diff --git a/internal/webhook/device_notifications.go b/internal/webhook/device_notifications.go new file mode 100644 index 0000000..f9a7cdd --- /dev/null +++ b/internal/webhook/device_notifications.go @@ -0,0 +1,371 @@ +package webhook + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/plusclouds/monitoring.server/internal/errs" + "github.com/plusclouds/monitoring.server/internal/extref" + "github.com/plusclouds/monitoring.server/internal/incident" +) + +// Reasons a route would not notify for a device (F06). +const ( + ReasonRouteDisabled = "route_disabled" + ReasonNoMatch = "no_match" + ReasonEndpointDisabled = "endpoint_disabled" + ReasonShadowed = "shadowed" + ReasonEndpointFailing = "endpoint_failing" +) + +// maxEvaluatedChecks bounds the checks of one device that are evaluated. +const maxEvaluatedChecks = 50 + +// Health is how deliveries to an endpoint have been going. It covers the +// whole endpoint, not one device. +type Health struct { + LastAttemptAt *time.Time + LastStatus *string + LastStatusCode *int + LastError *string + Failed24h int + // Failing: the latest delivery failed for good, or is being retried. + Failing bool +} + +// CheckMatch is a check of the device that a route takes, and for which +// severities. +type CheckMatch struct { + CheckID uuid.UUID + Name string + Plugin string + Severities []string +} + +// DeviceRoute is one alert route seen from one device. +type DeviceRoute struct { + Route Route + Endpoint Endpoint + Health Health + Matches []CheckMatch + // Because lists the route's filters that the device satisfies. + Because []string + WouldFire bool + // Reason is set when WouldFire is false. + Reason string +} + +// DeviceRoutes evaluates every route of the tenant against the next +// incident of the device: for each enabled check and each severity it walks +// the routes the way the router does (Evaluate) and records which routes +// take the incident and would deliver it. A device without checks is +// evaluated once, without a check. +func DeviceRoutes(ctx context.Context, tx pgx.Tx, tenant, device uuid.UUID) ([]DeviceRoute, error) { + var exists bool + if err := tx.QueryRow(ctx, `SELECT EXISTS (SELECT 1 FROM devices WHERE id = $1)`, device).Scan(&exists); err != nil { + return nil, err + } + if !exists { + return nil, errs.ErrNotFound + } + routes, err := ListRoutes(ctx, tx, tenant) + if err != nil || len(routes) == 0 { + return nil, err + } + endpoints, err := ListEndpoints(ctx, tx) + if err != nil { + return nil, err + } + byID := map[uuid.UUID]Endpoint{} + ids := make([]uuid.UUID, 0, len(endpoints)) + for _, e := range endpoints { + byID[e.ID] = e + ids = append(ids, e.ID) + } + health, err := EndpointHealth(ctx, tx, ids) + if err != nil { + return nil, err + } + rows, err := tx.Query(ctx, `SELECT id, name, plugin FROM checks WHERE device_id = $1 AND enabled + ORDER BY name, id LIMIT $2`, device, maxEvaluatedChecks) + if err != nil { + return nil, err + } + checks, err := pgx.CollectRows(rows, func(r pgx.CollectableRow) (CheckMatch, error) { + var c CheckMatch + err := r.Scan(&c.CheckID, &c.Name, &c.Plugin) + return c, err + }) + if err != nil { + return nil, err + } + + out := make([]DeviceRoute, len(routes)) + reached := make([]bool, len(routes)) // delivers, endpoint on + taken := make([]bool, len(routes)) // matched and routed, whatever the endpoint + shadowed := make([]bool, len(routes)) + for i, r := range routes { + out[i] = DeviceRoute{Route: r, Endpoint: byID[r.EndpointID], Health: health[r.EndpointID]} + } + + scenarios := checks + if len(scenarios) == 0 { + scenarios = []CheckMatch{{}} + } + for _, c := range scenarios { + var check *uuid.UUID + if c.CheckID != uuid.Nil { + check = &c.CheckID + } + env, err := incident.Preview(ctx, tx, tenant, incident.EventOpened, "critical", &device, check) + if err != nil { + return nil, err + } + for _, severity := range []string{"warning", "critical"} { + stopped := false + for i, r := range routes { + if !r.Matches(&env, severity) { + continue + } + if len(out[i].Because) == 0 { + out[i].Because = because(r.Match, &env, severity) + } + if check != nil { + addMatch(&out[i], c, severity) + } + if !r.Enabled { + continue + } + if stopped { + shadowed[i] = true + continue + } + taken[i] = true + if byID[r.EndpointID].Enabled { + reached[i] = true + } + stopped = !r.Continue + } + } + } + + for i := range out { + switch { + case !routes[i].Enabled: + out[i].Reason = ReasonRouteDisabled + case !taken[i] && shadowed[i]: + out[i].Reason = ReasonShadowed + case !taken[i]: + out[i].Reason = ReasonNoMatch + case !reached[i]: + out[i].Reason = ReasonEndpointDisabled + case out[i].Health.Failing: + out[i].Reason = ReasonEndpointFailing + default: + out[i].WouldFire = true + } + } + return out, nil +} + +func addMatch(r *DeviceRoute, c CheckMatch, severity string) { + for i := range r.Matches { + if r.Matches[i].CheckID == c.CheckID { + if !slices.Contains(r.Matches[i].Severities, severity) { + r.Matches[i].Severities = append(r.Matches[i].Severities, severity) + } + return + } + } + c.Severities = []string{severity} + r.Matches = append(r.Matches, c) +} + +// because lists, as short codes, the filters of m that the event satisfies. +// A route without filters takes every incident. +func because(m Match, e *incident.Envelope, severity string) []string { + var out []string + if len(m.EventTypes) > 0 { + out = append(out, "event_type:"+e.Type) + } + if len(m.Severity) > 0 { + out = append(out, "severity:"+severity) + } + if d := e.Data.Device; d != nil { + if len(m.DeviceTypes) > 0 { + out = append(out, "device_type:"+d.Type) + } + if len(m.SiteIDs) > 0 && d.SiteID != nil { + out = append(out, "site:"+d.SiteID.String()) + } + if len(m.DeviceIDs) > 0 { + out = append(out, "device_id:"+d.ID.String()) + } + for _, k := range sortedKeys(m.Tags) { + out = append(out, "tag:"+k+"="+m.Tags[k]) + } + } + if c := e.Data.Check; c != nil && len(m.CheckIDs) > 0 { + out = append(out, "check_id:"+c.ID.String()) + } + if len(out) == 0 { + out = []string{"all_incidents"} + } + return out +} + +func sortedKeys(m map[string]string) []string { + keys := make([]string, 0, len(m)) + for k := range m { + keys = append(keys, k) + } + slices.Sort(keys) + return keys +} + +// EndpointHealth summarises the recent deliveries of each endpoint. +func EndpointHealth(ctx context.Context, tx pgx.Tx, endpoints []uuid.UUID) (map[uuid.UUID]Health, error) { + out := map[uuid.UUID]Health{} + if len(endpoints) == 0 { + return out, nil + } + // The latest delivery that was not cancelled gives the status. + rows, err := tx.Query(ctx, ` + SELECT DISTINCT ON (endpoint_id) endpoint_id, status, attempts, last_status_code, last_error, last_attempt_at + FROM webhook_deliveries + WHERE endpoint_id = ANY($1) AND status <> 'cancelled' + ORDER BY endpoint_id, id DESC`, endpoints) + if err != nil { + return nil, err + } + for rows.Next() { + var id uuid.UUID + var h Health + var status string + var attempts int + if err := rows.Scan(&id, &status, &attempts, &h.LastStatusCode, &h.LastError, &h.LastAttemptAt); err != nil { + rows.Close() + return nil, err + } + h.LastStatus = &status + h.Failing = status == "failed" || (status == "pending" && attempts > 0 && h.LastError != nil) + out[id] = h + } + rows.Close() + if err := rows.Err(); err != nil { + return nil, err + } + rows, err = tx.Query(ctx, ` + SELECT endpoint_id, count(*) FILTER (WHERE status = 'failed' + AND coalesce(last_attempt_at, created_at) > now() - interval '24 hours'), + max(last_attempt_at) + FROM webhook_deliveries WHERE endpoint_id = ANY($1) GROUP BY endpoint_id`, endpoints) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var id uuid.UUID + var failed int + var last *time.Time + if err := rows.Scan(&id, &failed, &last); err != nil { + return nil, err + } + h := out[id] + h.Failed24h, h.LastAttemptAt = failed, last + out[id] = h + } + return out, rows.Err() +} + +// DeviceDelivery is a delivery about a device, with the body that was sent. +type DeviceDelivery struct { + ID uuid.UUID + EventType string + IncidentID *uuid.UUID + CheckID *uuid.UUID + RouteID *uuid.UUID + RouteName *string + RouteExternal *extref.Ref + EndpointID uuid.UUID + EndpointName string + EndpointExt *extref.Ref + Status string + Attempts int + LastStatusCode *int + LastError *string + LastAttemptAt *time.Time + DeliveredAt *time.Time + CreatedAt time.Time + Payload json.RawMessage +} + +// DeviceDeliveryFilter selects a page of a device's deliveries, newest +// first. Before is the ID of the last delivery of the previous page. +type DeviceDeliveryFilter struct { + Status, EventType *string + Before *uuid.UUID + Limit int + // IncidentURLTemplate is notifier.incident_url_template, so the payload + // carries the same data.links as the delivered body. + IncidentURLTemplate string +} + +// DeviceDeliveries lists the deliveries that are about a device. A grouped +// event is listed for every device in the group. Payloads are rebuilt the +// way the sender builds them. A body holds no secret: the signature is in +// headers, which are not stored. +func DeviceDeliveries(ctx context.Context, tx pgx.Tx, tenant, device uuid.UUID, f DeviceDeliveryFilter) ([]DeviceDelivery, error) { + rows, err := tx.Query(ctx, ` + SELECT d.id, d.event_type, d.route_id, r.name, r.external_source, r.external_type, r.external_id, d.endpoint_id, w.name, + w.external_source, w.external_type, w.external_id, d.status, d.attempts, d.last_status_code, + d.last_error, d.last_attempt_at, d.delivered_at, d.created_at, e.envelope, d.route + FROM webhook_delivery_devices x + JOIN webhook_deliveries d ON d.id = x.delivery_id + JOIN events e ON e.id = d.event_id + JOIN webhook_endpoints w ON w.id = d.endpoint_id + LEFT JOIN alert_routes r ON r.id = d.route_id + WHERE x.tenant_id = $1 AND x.device_id = $2 + AND ($3::text IS NULL OR d.status = $3) AND ($4::text IS NULL OR d.event_type = $4) + AND ($5::uuid IS NULL OR d.id < $5) + ORDER BY d.id DESC LIMIT $6`, tenant, device, f.Status, f.EventType, f.Before, f.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var out []DeviceDelivery + for rows.Next() { + var d DeviceDelivery + var envelope, route []byte + var rsrc, rtyp, rext, wsrc, wtyp, wext *string + if err := rows.Scan(&d.ID, &d.EventType, &d.RouteID, &d.RouteName, &rsrc, &rtyp, &rext, &d.EndpointID, + &d.EndpointName, &wsrc, &wtyp, &wext, &d.Status, &d.Attempts, &d.LastStatusCode, &d.LastError, &d.LastAttemptAt, &d.DeliveredAt, &d.CreatedAt, &envelope, &route); err != nil { + return nil, err + } + d.RouteExternal, d.EndpointExt = extref.FromColumns(rsrc, rtyp, rext), extref.FromColumns(wsrc, wtyp, wext) + body, err := render(envelope, route, f.IncidentURLTemplate, tenant) + if err != nil { + return nil, fmt.Errorf("render delivery %s: %w", d.ID, err) + } + d.Payload = body + var env incident.Envelope + if json.Unmarshal(envelope, &env) == nil { + if id, err := uuid.Parse(env.Subject); err == nil { + d.IncidentID = &id + } + if env.Data.Check != nil { + id := env.Data.Check.ID + d.CheckID = &id + } + } + d.CreatedAt = d.CreatedAt.UTC() + out = append(out, d) + } + return out, rows.Err() +} diff --git a/migrations/00025_delivery_devices.sql b/migrations/00025_delivery_devices.sql new file mode 100644 index 0000000..0d1ca52 --- /dev/null +++ b/migrations/00025_delivery_devices.sql @@ -0,0 +1,69 @@ +-- Link webhook deliveries to the devices they are about, so a device can list +-- its own notifications without scanning event envelopes (F06). +-- +-- One row per delivery and device: a plain incident event has one device, a +-- grouped event has one per grouped incident. Tenant events (limits) have none. +-- A trigger fills the table for every new delivery, so no code path that +-- creates deliveries has to remember it. + +-- +goose Up +CREATE TABLE webhook_delivery_devices ( + delivery_id uuid NOT NULL REFERENCES webhook_deliveries (id) ON DELETE CASCADE, + tenant_id uuid NOT NULL REFERENCES tenants (id) ON DELETE CASCADE, + device_id uuid NOT NULL, + PRIMARY KEY (delivery_id, device_id) +); +-- Newest first: delivery IDs are UUIDv7. +CREATE INDEX webhook_delivery_devices_device ON webhook_delivery_devices (tenant_id, device_id, delivery_id DESC); + +ALTER TABLE webhook_delivery_devices ENABLE ROW LEVEL SECURITY; +CREATE POLICY tenant_isolation ON webhook_delivery_devices + USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid); +GRANT SELECT, INSERT, UPDATE, DELETE ON webhook_delivery_devices TO monitor_system; +GRANT SELECT ON webhook_delivery_devices TO monitor_app; + +-- +goose StatementBegin +CREATE FUNCTION link_delivery_devices() RETURNS trigger +LANGUAGE plpgsql SET search_path = pg_catalog, public +AS $$ +BEGIN + INSERT INTO webhook_delivery_devices (delivery_id, tenant_id, device_id) + SELECT NEW.id, NEW.tenant_id, x.device::uuid + FROM ( + SELECT e.envelope #>> '{data,device,id}' AS device + FROM events e WHERE e.id = NEW.event_id + UNION + SELECT o #>> '{device,id}' + FROM events e, + jsonb_array_elements(CASE WHEN jsonb_typeof(e.envelope #> '{data,objects}') = 'array' + THEN e.envelope #> '{data,objects}' ELSE '[]'::jsonb END) o + WHERE e.id = NEW.event_id + ) x + WHERE x.device IS NOT NULL + ON CONFLICT DO NOTHING; + RETURN NULL; +END +$$; +-- +goose StatementEnd +CREATE TRIGGER link_delivery_devices AFTER INSERT ON webhook_deliveries + FOR EACH ROW EXECUTE FUNCTION link_delivery_devices(); + +-- Deliveries that already exist (kept by delivery_retention, 30 days by default). +INSERT INTO webhook_delivery_devices (delivery_id, tenant_id, device_id) +SELECT d.id, d.tenant_id, x.device::uuid + FROM webhook_deliveries d + JOIN events e ON e.id = d.event_id + CROSS JOIN LATERAL ( + SELECT e.envelope #>> '{data,device,id}' AS device + UNION + SELECT o #>> '{device,id}' + FROM jsonb_array_elements(CASE WHEN jsonb_typeof(e.envelope #> '{data,objects}') = 'array' + THEN e.envelope #> '{data,objects}' ELSE '[]'::jsonb END) o + ) x + WHERE x.device IS NOT NULL +ON CONFLICT DO NOTHING; + +-- +goose Down +DROP TRIGGER link_delivery_devices ON webhook_deliveries; +DROP FUNCTION link_delivery_devices(); +DROP TABLE webhook_delivery_devices;