Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
36b6e5ac41 |
@@ -145,6 +145,11 @@ TTY detection, and security headers are always applied.
|
|||||||
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
| `TRUSTED_PROXIES` | CIDRs whose forwarded headers are trusted. A set value replaces the default. If any client can reach webhooker, or the proxy in front of it, from an RFC 1918 source address, set it to the proxy's address alone. See [Trusted proxies](#trusted-proxies) | `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16` (RFC 1918) |
|
||||||
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
| `ALLOWED_EGRESS_CIDRS` | CIDRs that delivery targets may reach despite the SSRF blocklist. Read [Allowing egress to your own network](#allowing-egress-to-your-own-network) before setting it | `""` (none) |
|
||||||
|
|
||||||
|
The Settings page of the web UI (`/settings`, behind the login) lists
|
||||||
|
every one of these with the value the running server loaded. It is
|
||||||
|
read-only, and it shows `METRICS_PASSWORD` and `SENTRY_DSN` only as
|
||||||
|
set or not set, never their values.
|
||||||
|
|
||||||
#### Allowing egress to your own network
|
#### Allowing egress to your own network
|
||||||
|
|
||||||
By default every delivery target must resolve to a public address. The
|
By default every delivery target must resolve to a public address. The
|
||||||
@@ -1075,7 +1080,7 @@ unconditionally against whatever files it finds:
|
|||||||
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
- the main database on connect — `Setting`, `User`, `APIKey`, `Webhook`,
|
||||||
`Entrypoint`, `Target`
|
`Entrypoint`, `Target`
|
||||||
- each event database when it is lazily opened — `Event`, `Delivery`,
|
- each event database when it is lazily opened — `Event`, `Delivery`,
|
||||||
`DeliveryResult`, `EventTotals`, `TargetTotals`
|
`DeliveryResult`
|
||||||
- each archive database on every open and reopen
|
- each archive database on every open and reopen
|
||||||
|
|
||||||
There is no schema version table, no migration ledger, and no down
|
There is no schema version table, no migration ledger, and no down
|
||||||
@@ -1392,7 +1397,7 @@ The codebase uses consistent naming throughout (rename completed in
|
|||||||
|
|
||||||
### Data Model
|
### Data Model
|
||||||
|
|
||||||
webhooker's data model has eleven entities organized into two tiers: the
|
webhooker's data model has nine entities organized into two tiers: the
|
||||||
**application tier** (user and webhook configuration) and the **event
|
**application tier** (user and webhook configuration) and the **event
|
||||||
tier** (event ingestion, delivery, and logging).
|
tier** (event ingestion, delivery, and logging).
|
||||||
|
|
||||||
@@ -1421,13 +1426,6 @@ tier** (event ingestion, delivery, and logging).
|
|||||||
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
│ ┌──────────┐ ┌──────────┐ ┌─────────────────┐ │
|
||||||
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
│ │ Event │──1:N──│ Delivery │──1:N──│ DeliveryResult │ │
|
||||||
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
│ └──────────┘ └──────────┘ └─────────────────┘ │
|
||||||
│ │
|
|
||||||
│ ┌──────────────┐ (one row: running counts of events) │
|
|
||||||
│ │ EventTotals │ │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
│ ┌──────────────┐ (one row per target: running counts │
|
|
||||||
│ │ TargetTotals │ of its deliveries) │
|
|
||||||
│ └──────────────┘ │
|
|
||||||
└─────────────────────────────────────────────────────────────┘
|
└─────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -1681,7 +1679,6 @@ status across potentially multiple attempts.
|
|||||||
| `event_id` | UUID | Foreign key → Event |
|
| `event_id` | UUID | Foreign key → Event |
|
||||||
| `target_id`| UUID | Foreign key → Target |
|
| `target_id`| UUID | Foreign key → Target |
|
||||||
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
| `status` | DeliveryStatus | One of: `pending`, `delivered`, `failed`, `retrying` |
|
||||||
| `finished_at` | timestamp | When the delivery became `delivered` or `failed` (nullable; empty while `pending` or `retrying`) |
|
|
||||||
|
|
||||||
**Relations:** Belongs to Event. Belongs to Target. Has many
|
**Relations:** Belongs to Event. Belongs to Target. Has many
|
||||||
DeliveryResults.
|
DeliveryResults.
|
||||||
@@ -1749,70 +1746,33 @@ retries) is individually logged for full observability.
|
|||||||
|
|
||||||
**Relations:** Belongs to Delivery.
|
**Relations:** Belongs to Delivery.
|
||||||
|
|
||||||
#### EventTotals and TargetTotals
|
|
||||||
|
|
||||||
Running counts in each event database, read by the statistics pane at the
|
|
||||||
top of the webhook page. `EventTotals` is one row:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| ---------------- | --------- | ----------- |
|
|
||||||
| `events` | integer | Events ever stored, resubmitted copies included |
|
|
||||||
| `events_removed` | integer | Events retention has deleted |
|
|
||||||
| `last_event_at` | timestamp | When the newest event arrived (nullable; empty before the first); retention leaves it as it is |
|
|
||||||
|
|
||||||
`TargetTotals` is one row per target, created by the first delivery to it:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
|
||||||
| -------------------- | ------- | ----------- |
|
|
||||||
| `target_id` | UUID | The target (primary key) |
|
|
||||||
| `deliveries` | integer | Deliveries to it ever created, replays included |
|
|
||||||
| `delivered` | integer | Of those, how many became `delivered` |
|
|
||||||
| `failed` | integer | Of those, how many became `failed` |
|
|
||||||
| `deliveries_removed` | integer | Its deliveries retention has deleted |
|
|
||||||
| `failed_removed` | integer | Its failed deliveries retention has deleted |
|
|
||||||
|
|
||||||
Each count changes in the transaction that writes or deletes the rows it
|
|
||||||
counts. The pane's lifetime events are `events`, and its lifetime
|
|
||||||
deliveries and failures are `deliveries` and `failed` summed over the
|
|
||||||
targets; each figure within retention is the same less what retention
|
|
||||||
removed, so neither needs the rows themselves. Its last event is
|
|
||||||
`last_event_at`, written in the transaction that stores the event, so it
|
|
||||||
still shows once retention has removed every event. Its last-10-minutes and
|
|
||||||
last-24-hours figures are counted from the `events` and `deliveries`
|
|
||||||
indexes over just that window, the deliveries in one query grouped by
|
|
||||||
target. Its failure percentage for a window is the deliveries that became
|
|
||||||
`failed` in it out of all that became `delivered` or `failed` in it, and
|
|
||||||
a dash when none did.
|
|
||||||
|
|
||||||
#### Event-tier indexes
|
#### Event-tier indexes
|
||||||
|
|
||||||
These indexes on the per-webhook event databases are declared in the model
|
These indexes on the per-webhook event databases are declared in the model
|
||||||
tags, so `AutoMigrate` creates them on a fresh database:
|
tags, so `AutoMigrate` creates them on a fresh and on an existing database:
|
||||||
|
|
||||||
| Table | Columns | Serves |
|
| Table | Columns | Serves |
|
||||||
| ------------------ | --------------------------- | ------ |
|
| ------------------ | --------------------------- | ------ |
|
||||||
| `deliveries` | `status`, `deleted_at`, `finished_at`, `target_id` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status, and the webhook page's statistics, which count each target's deliveries by status and when they finished |
|
| `deliveries` | `status`, `deleted_at` | Startup recovery, the retry and pending sweeps every 60 seconds and the queue-depth sampler every 30 seconds, which select deliveries by status |
|
||||||
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which counts and deletes the deliveries of expired events |
|
| `deliveries` | `event_id`, `deleted_at` | The event log, which loads each event's deliveries, and retention, which selects and deletes the deliveries of expired events |
|
||||||
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
| `delivery_results` | `delivery_id`, `deleted_at` | The event log, which loads the attempts of a page's deliveries, and retention, which deletes the attempts of expired events |
|
||||||
| `events` | `deleted_at`, `created_at` | The webhook page's statistics, which count recent events |
|
| `events` | `deleted_at`, `created_at` | Retention, which selects expired events by age |
|
||||||
| `events` | `created_at` | Retention, which selects expired events by age |
|
| `events` | `created_at` | Retention's delete of the expired events themselves |
|
||||||
|
|
||||||
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
|
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention's
|
||||||
leaves it out. SQLite keeps no statistics on these tables, and without them it
|
deletes leave it out, but their lookups of expired rows keep it. SQLite keeps
|
||||||
rates the `deleted_at` index, which every live row matches, above an index on
|
no statistics on these tables, and without them it rates the `deleted_at`
|
||||||
a column matched against several values or compared with a range. So every
|
index, which every live row matches, above an index on a column matched
|
||||||
index but the last also covers `deleted_at`. It comes second, so that
|
against several values or compared with `<`. So every index but the last also
|
||||||
retention can use the index without it, except in `events`, where the
|
covers `deleted_at`. It comes second, so that retention's deletes can use the
|
||||||
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
|
index without it, except in `events`, where `created_at` is compared with `<`
|
||||||
range only on the last column it uses.
|
and SQLite narrows by a `<` only on the last column it uses.
|
||||||
|
|
||||||
#### Common Fields
|
#### Common Fields
|
||||||
|
|
||||||
Every entity except `Setting`, `EventTotals` and `TargetTotals` includes
|
Every entity except `Setting` includes these fields from `BaseModel`.
|
||||||
these fields from `BaseModel`. `Setting` is a bare key-value row with no
|
`Setting` is a bare key-value row with no `id`, no timestamps and no
|
||||||
`id`, no timestamps and no soft delete, and the two totals tables hold
|
soft delete:
|
||||||
counts, plus `last_event_at` in `event_totals`, keyed by a numeric `id`
|
|
||||||
and by `target_id`:
|
|
||||||
|
|
||||||
| Field | Type | Description |
|
| Field | Type | Description |
|
||||||
| ------------ | --------- | ----------- |
|
| ------------ | --------- | ----------- |
|
||||||
@@ -1854,8 +1814,6 @@ encryption key is generated and stored, and an `admin` user is created.
|
|||||||
- **Events** — captured incoming webhook payloads
|
- **Events** — captured incoming webhook payloads
|
||||||
- **Deliveries** — event-to-target pairings and their status
|
- **Deliveries** — event-to-target pairings and their status
|
||||||
- **DeliveryResults** — individual delivery attempt logs
|
- **DeliveryResults** — individual delivery attempt logs
|
||||||
- **EventTotals** and **TargetTotals** — running counts of the above,
|
|
||||||
the deliveries per target, kept through retention
|
|
||||||
|
|
||||||
Per-webhook databases are created automatically when a webhook is
|
Per-webhook databases are created automatically when a webhook is
|
||||||
created (and lazily on first access for webhooks that predate this
|
created (and lazily on first access for webhooks that predate this
|
||||||
@@ -2760,6 +2718,7 @@ abuse limit later; they are tracked as future work.
|
|||||||
| ------ | ------------------------ | ----------- |
|
| ------ | ------------------------ | ----------- |
|
||||||
| `GET` | `/user/{username}` | User profile page |
|
| `GET` | `/user/{username}` | User profile page |
|
||||||
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
| `POST` | `/user/{username}/password` | Change the user's password (5 per minute per bucket, then `429`; `503` if no verification slot frees up within 5s, or immediately if 16 requests are already queued for one) |
|
||||||
|
| `GET` | `/settings` | Read-only list of the configuration the server is running with; `METRICS_PASSWORD` and `SENTRY_DSN` show only as set or not set |
|
||||||
| `GET` | `/sources` | List user's webhooks |
|
| `GET` | `/sources` | List user's webhooks |
|
||||||
| `GET` | `/sources/new` | Create webhook form |
|
| `GET` | `/sources/new` | Create webhook form |
|
||||||
| `POST` | `/sources/new` | Create webhook submission |
|
| `POST` | `/sources/new` | Create webhook submission |
|
||||||
@@ -2835,7 +2794,6 @@ webhooker/
|
|||||||
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
│ │ ├── model_event.go # Event entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
│ │ ├── model_delivery.go # Delivery entity (per-webhook DB)
|
||||||
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
│ │ ├── model_delivery_result.go # DeliveryResult entity (per-webhook DB)
|
||||||
│ │ ├── model_totals.go # EventTotals and TargetTotals (per-webhook DB)
|
|
||||||
│ │ ├── model_apikey.go # APIKey entity
|
│ │ ├── model_apikey.go # APIKey entity
|
||||||
│ │ ├── password.go # Argon2id hashing and verification
|
│ │ ├── password.go # Argon2id hashing and verification
|
||||||
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
│ │ ├── retention.go # Retention reaper (per-webhook event expiry)
|
||||||
@@ -2873,6 +2831,7 @@ webhooker/
|
|||||||
│ │ ├── healthcheck.go # Health check handler
|
│ │ ├── healthcheck.go # Health check handler
|
||||||
│ │ ├── index.go # Index page handler
|
│ │ ├── index.go # Index page handler
|
||||||
│ │ ├── profile.go # User profile handler
|
│ │ ├── profile.go # User profile handler
|
||||||
|
│ │ ├── settings.go # Read-only Settings page handler
|
||||||
│ │ ├── source_management.go # Webhook CRUD handlers
|
│ │ ├── source_management.go # Webhook CRUD handlers
|
||||||
│ │ └── webhook.go # Webhook receiver handler
|
│ │ └── webhook.go # Webhook receiver handler
|
||||||
│ ├── healthcheck/
|
│ ├── healthcheck/
|
||||||
@@ -3100,8 +3059,7 @@ check, see [The login endpoint](#the-login-endpoint).
|
|||||||
before the app starts; the image's health check; and `docker exec`,
|
before the app starts; the image's health check; and `docker exec`,
|
||||||
unless given `--user`
|
unless given `--user`
|
||||||
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
- GORM soft deletes on every entity that carries `BaseModel`, which is
|
||||||
all of them but `Setting`, `EventTotals` and `TargetTotals` (data
|
all of them but `Setting` (data preserved for audit)
|
||||||
preserved for audit)
|
|
||||||
|
|
||||||
### Shutdown
|
### Shutdown
|
||||||
|
|
||||||
|
|||||||
@@ -93,11 +93,11 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
deliveries []database.Delivery
|
deliveries []database.Delivery
|
||||||
results []database.DeliveryResult
|
results []database.DeliveryResult
|
||||||
depths []struct{ Depth int }
|
depths []struct{ Depth int }
|
||||||
removed []database.TargetTotals
|
|
||||||
)
|
)
|
||||||
|
|
||||||
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
|
||||||
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
|
||||||
|
byAge := "idx_events_deleted_at_created_at (deleted_at=? AND created_at<?)"
|
||||||
|
|
||||||
// The delivery engine: recovery and the retry sweep, the sweep for
|
// The delivery engine: recovery and the retry sweep, the sweep for
|
||||||
// stranded pending deliveries, and the queue depth count.
|
// stranded pending deliveries, and the queue depth count.
|
||||||
@@ -123,80 +123,25 @@ func TestEventTierQueriesUseTheirIndexes(t *testing.T) {
|
|||||||
Order("attempt_num ASC").Find(&results),
|
Order("attempt_num ASC").Find(&results),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
|
||||||
|
|
||||||
// Retention (reapExpired, deleteEvents): one batch of expired
|
// Retention's three deletes (reapExpired), whose subqueries are built
|
||||||
// events, then their attempts, deliveries and the events.
|
// afresh for each statement as it builds them.
|
||||||
var expired []string
|
expiredEventIDs := func() *gorm.DB {
|
||||||
|
return dry.Model(&database.Event{}).Select("id").
|
||||||
|
Where("created_at < ?", cutoff)
|
||||||
|
}
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(database.ExportReapBatchSize).Pluck("id", &expired),
|
|
||||||
"idx_events_created_at (created_at<?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where(
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
|
"delivery_id IN (?)", dry.Model(&database.Delivery{}).
|
||||||
Select("id").Where("event_id IN ?", ids),
|
Select("id").Where("event_id IN (?)", expiredEventIDs()),
|
||||||
).Delete(&database.DeliveryResult{}),
|
).Delete(&database.DeliveryResult{}),
|
||||||
"idx_delivery_results_delivery_id (delivery_id=?)",
|
"idx_delivery_results_delivery_id (delivery_id=?)", byEvent, byAge)
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
|
"event_id IN (?)", expiredEventIDs(),
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
).Delete(&database.Delivery{}),
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
"idx_deliveries_event_id (event_id=?)", byAge)
|
||||||
database.DeliveryStatusFailed).
|
assertPlanUses(t, db, dry.Unscoped().Where(
|
||||||
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
|
"created_at < ?", cutoff,
|
||||||
"idx_deliveries_event_id (event_id=?)")
|
).Delete(&database.Event{}), "idx_events_created_at (created_at<?)")
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("event_id IN ?", ids).
|
|
||||||
Delete(&database.Delivery{}), "idx_deliveries_event_id (event_id=?)")
|
|
||||||
assertPlanUses(t, db, dry.Unscoped().Where("id IN ?", ids).
|
|
||||||
Delete(&database.Event{}), "sqlite_autoindex_events_1 (id=?)")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestStatisticsQueriesUseTheirIndexes does the same for the webhook
|
|
||||||
// page's statistics (readEventStats in the handlers): deliveries in
|
|
||||||
// progress, each target's deliveries finished since a time, which must
|
|
||||||
// come from the index alone, and events received since a time.
|
|
||||||
func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(uuid.New().String())
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
dry := db.Session(&gorm.Session{DryRun: true})
|
|
||||||
since := time.Now()
|
|
||||||
|
|
||||||
var (
|
|
||||||
count int64
|
|
||||||
byTarget []struct{ TargetID string }
|
|
||||||
)
|
|
||||||
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
|
||||||
Where("status IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
}).Count(&count),
|
|
||||||
"idx_deliveries_status (status=? AND deleted_at=?)")
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").Find(&byTarget),
|
|
||||||
"COVERING INDEX idx_deliveries_status "+
|
|
||||||
"(status=? AND deleted_at=? AND finished_at>?)")
|
|
||||||
assertPlanUses(t, db, dry.Model(&database.Event{}).
|
|
||||||
Where("created_at >= ?", since).Count(&count),
|
|
||||||
"idx_events_deleted_at_created_at "+
|
|
||||||
"(deleted_at=? AND created_at>?)")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
// assertPlanUses asserts that SQLite's plan for a statement GORM built
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"testing"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
@@ -29,10 +28,6 @@ func NewTestRetentionReaper(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportReapBatchSize exposes how many expired events one retention
|
|
||||||
// transaction deletes.
|
|
||||||
const ExportReapBatchSize = reapBatchSize
|
|
||||||
|
|
||||||
// ExportSweep runs a single retention sweep synchronously for tests.
|
// ExportSweep runs a single retention sweep synchronously for tests.
|
||||||
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
|
||||||
r.sweep(ctx)
|
r.sweep(ctx)
|
||||||
@@ -84,14 +79,3 @@ func (d *Database) ExportSetBannerOut(w io.Writer) {
|
|||||||
func DummyPasswordHashForTest() string {
|
func DummyPasswordHashForTest() string {
|
||||||
return dummyPasswordHash()
|
return dummyPasswordHash()
|
||||||
}
|
}
|
||||||
|
|
||||||
// HashAtShippedCostForTest makes HashPassword hash at the shipped
|
|
||||||
// memory cost until t ends. t must not run in parallel with other
|
|
||||||
// tests, which would hash at that cost alongside it.
|
|
||||||
func HashAtShippedCostForTest(t *testing.T) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
hashAtShippedCostInTest = true
|
|
||||||
|
|
||||||
t.Cleanup(func() { hashAtShippedCostInTest = false })
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import "gorm.io/gorm"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// DeliveryStatus represents the status of a delivery
|
// DeliveryStatus represents the status of a delivery
|
||||||
type DeliveryStatus string
|
type DeliveryStatus string
|
||||||
@@ -41,7 +37,7 @@ type Delivery struct {
|
|||||||
BaseModel
|
BaseModel
|
||||||
|
|
||||||
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"`
|
||||||
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"`
|
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
|
||||||
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
|
||||||
|
|
||||||
// DeletedAt repeats the BaseModel field only to be the second column
|
// DeletedAt repeats the BaseModel field only to be the second column
|
||||||
@@ -49,13 +45,6 @@ type Delivery struct {
|
|||||||
// gives.
|
// gives.
|
||||||
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
|
||||||
|
|
||||||
// FinishedAt is when the delivery became delivered or failed, and
|
|
||||||
// nil while it is pending or retrying. It and then TargetID end the
|
|
||||||
// status index, so the webhook page counts each target's deliveries
|
|
||||||
// that finished in a recent window by reading just that window from
|
|
||||||
// the index.
|
|
||||||
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
|
|
||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
Event Event `json:"event,omitzero"`
|
Event Event `json:"event,omitzero"`
|
||||||
Target Target `json:"target,omitzero"`
|
Target Target `json:"target,omitzero"`
|
||||||
|
|||||||
@@ -1,99 +0,0 @@
|
|||||||
package database
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The running totals in a webhook's event database keep the webhook
|
|
||||||
// page's lifetime figures right after retention has removed the rows
|
|
||||||
// they count, and let the page show them without counting every row.
|
|
||||||
// Each total changes in the transaction that writes or deletes the
|
|
||||||
// rows it counts.
|
|
||||||
|
|
||||||
// EventTotals is the single row counting a webhook's events: every
|
|
||||||
// event ever stored, how many of them retention has deleted, and when
|
|
||||||
// the newest arrived, which retention leaves as it is.
|
|
||||||
type EventTotals struct {
|
|
||||||
ID int64 `gorm:"primaryKey"`
|
|
||||||
|
|
||||||
Events int64 `gorm:"not null"`
|
|
||||||
EventsRemoved int64 `gorm:"not null"`
|
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil before the
|
|
||||||
// first.
|
|
||||||
LastEventAt *time.Time
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddEventTotals updates.
|
|
||||||
func (EventTotals) TableName() string {
|
|
||||||
return "event_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetTotals is one row per target counting its deliveries: every
|
|
||||||
// delivery ever created, how many became delivered and how many
|
|
||||||
// failed, and how many deliveries and failed deliveries retention has
|
|
||||||
// deleted. The webhook's delivery figures are these rows summed.
|
|
||||||
type TargetTotals struct {
|
|
||||||
TargetID string `gorm:"type:uuid;primaryKey"`
|
|
||||||
|
|
||||||
Deliveries int64 `gorm:"not null"`
|
|
||||||
Delivered int64 `gorm:"not null"`
|
|
||||||
Failed int64 `gorm:"not null"`
|
|
||||||
|
|
||||||
DeliveriesRemoved int64 `gorm:"not null"`
|
|
||||||
FailedRemoved int64 `gorm:"not null"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// TableName names the table AddTargetTotals updates.
|
|
||||||
func (TargetTotals) TableName() string {
|
|
||||||
return "target_totals"
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddEventTotals adds each count in add to the webhook's event totals,
|
|
||||||
// and records add.LastEventAt as when the newest event arrived if it is
|
|
||||||
// set. Call it on the transaction that writes or deletes the events it
|
|
||||||
// counts.
|
|
||||||
func AddEventTotals(tx *gorm.DB, add EventTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`UPDATE event_totals SET
|
|
||||||
events = events + ?,
|
|
||||||
events_removed = events_removed + ?,
|
|
||||||
last_event_at = coalesce(?, last_event_at)`,
|
|
||||||
add.Events, add.EventsRemoved, add.LastEventAt,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("adding to event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTargetTotals adds each count in add to the totals of the target
|
|
||||||
// add.TargetID names, creating its row the first time. Call it on the
|
|
||||||
// transaction that writes or deletes the deliveries it counts.
|
|
||||||
func AddTargetTotals(tx *gorm.DB, add TargetTotals) error {
|
|
||||||
err := tx.Exec(
|
|
||||||
`INSERT INTO target_totals (target_id, deliveries, delivered,
|
|
||||||
failed, deliveries_removed, failed_removed)
|
|
||||||
VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
ON CONFLICT (target_id) DO UPDATE SET
|
|
||||||
deliveries = deliveries + excluded.deliveries,
|
|
||||||
delivered = delivered + excluded.delivered,
|
|
||||||
failed = failed + excluded.failed,
|
|
||||||
deliveries_removed =
|
|
||||||
deliveries_removed + excluded.deliveries_removed,
|
|
||||||
failed_removed = failed_removed + excluded.failed_removed`,
|
|
||||||
add.TargetID, add.Deliveries, add.Delivered,
|
|
||||||
add.Failed, add.DeliveriesRemoved, add.FailedRemoved,
|
|
||||||
).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf(
|
|
||||||
"adding to totals of target %s: %w", add.TargetID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
@@ -2,8 +2,7 @@ package database
|
|||||||
|
|
||||||
// Migrate runs database migrations for the main application database.
|
// Migrate runs database migrations for the main application database.
|
||||||
// Only configuration-tier models are stored in the main database.
|
// Only configuration-tier models are stored in the main database.
|
||||||
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals,
|
// Event-tier models (Event, Delivery, DeliveryResult) live in
|
||||||
// TargetTotals) live in
|
|
||||||
// per-webhook dedicated databases managed by WebhookDBManager.
|
// per-webhook dedicated databases managed by WebhookDBManager.
|
||||||
func (d *Database) Migrate() error {
|
func (d *Database) Migrate() error {
|
||||||
return d.db.AutoMigrate(
|
return d.db.AutoMigrate(
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"testing"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/argon2"
|
"golang.org/x/crypto/argon2"
|
||||||
)
|
)
|
||||||
@@ -64,30 +63,10 @@ func DefaultPasswordConfig() *PasswordConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
|
// HashPassword generates an Argon2id hash of the password
|
||||||
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
|
|
||||||
// that starts a database hashes the bootstrap admin password, dozens
|
|
||||||
// of them run in parallel, and under the race detector each 64 MB hash
|
|
||||||
// holds about 150 MB. VerifyPassword reads the cost from the hash it
|
|
||||||
// checks, so verification follows.
|
|
||||||
const testArgon2Memory = 1024
|
|
||||||
|
|
||||||
// hashAtShippedCostInTest makes a test binary hash at the shipped
|
|
||||||
// memory cost. Only TestHashPassword_ShippedParameters sets it.
|
|
||||||
//
|
|
||||||
//nolint:gochecknoglobals // set by one test, see above
|
|
||||||
var hashAtShippedCostInTest bool
|
|
||||||
|
|
||||||
// HashPassword generates an Argon2id hash of the password. A binary
|
|
||||||
// built by go test hashes at testArgon2Memory; one built by go build
|
|
||||||
// always hashes at the defaults.
|
|
||||||
func HashPassword(password string) (string, error) {
|
func HashPassword(password string) (string, error) {
|
||||||
config := DefaultPasswordConfig()
|
config := DefaultPasswordConfig()
|
||||||
|
|
||||||
if testing.Testing() && !hashAtShippedCostInTest {
|
|
||||||
config.Memory = testArgon2Memory
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate a salt
|
// Generate a salt
|
||||||
salt := make([]byte, config.SaltLen)
|
salt := make([]byte, config.SaltLen)
|
||||||
|
|
||||||
|
|||||||
@@ -192,39 +192,6 @@ func TestHashPasswordUniqueness(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestHashPassword_ShippedParameters hashes and verifies through
|
|
||||||
// HashPassword at the shipped Argon2id parameters. Every other test
|
|
||||||
// hashes at the lower memory cost a test binary uses, so this is the
|
|
||||||
// one that keeps production hashing covered. One hash and one
|
|
||||||
// verification: each costs 64 MB.
|
|
||||||
//
|
|
||||||
//nolint:paralleltest // changes the hashing cost for the whole binary
|
|
||||||
func TestHashPassword_ShippedParameters(t *testing.T) {
|
|
||||||
database.HashAtShippedCostForTest(t)
|
|
||||||
|
|
||||||
password := "correct horse battery staple"
|
|
||||||
|
|
||||||
hash, err := database.HashPassword(password)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("hashing with the shipped parameters: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
const shipped = "$argon2id$v=19$m=65536,t=1,p=4$"
|
|
||||||
|
|
||||||
if !strings.HasPrefix(hash, shipped) {
|
|
||||||
t.Errorf("hash = %q, want prefix %q", hash, shipped)
|
|
||||||
}
|
|
||||||
|
|
||||||
valid, err := database.VerifyPassword(password, hash)
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("VerifyPassword() error = %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !valid {
|
|
||||||
t.Error("VerifyPassword() returned false for correct password")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
// TestVerifyDummyPassword_DoesRealWork covers the anti-enumeration
|
||||||
// path. Login charges an unknown username a verification against a
|
// path. Login charges an unknown username a verification against a
|
||||||
// dummy hash so that a nonexistent account is not answered in
|
// dummy hash so that a nonexistent account is not answered in
|
||||||
|
|||||||
+46
-111
@@ -18,19 +18,6 @@ import (
|
|||||||
// computation.
|
// computation.
|
||||||
const hoursPerDay = 24
|
const hoursPerDay = 24
|
||||||
|
|
||||||
// reapBatchSize is how many expired events one retention transaction
|
|
||||||
// deletes. A transaction holds the event database's write lock, which
|
|
||||||
// the receiver and the delivery workers wait for, so a large prune is
|
|
||||||
// split into transactions each short enough to finish well inside the
|
|
||||||
// busy timeout.
|
|
||||||
const reapBatchSize = 1000
|
|
||||||
|
|
||||||
// reapBatchPause is how long retention waits after one batch before
|
|
||||||
// starting the next. A writer waiting for the write lock checks for it
|
|
||||||
// again after at most 100 ms, so a longer pause lets it in between two
|
|
||||||
// batches instead of only after the whole prune.
|
|
||||||
const reapBatchPause = 200 * time.Millisecond
|
|
||||||
|
|
||||||
// RetentionReaperParams holds the fx dependencies for the
|
// RetentionReaperParams holds the fx dependencies for the
|
||||||
// RetentionReaper.
|
// RetentionReaper.
|
||||||
type RetentionReaperParams struct {
|
type RetentionReaperParams struct {
|
||||||
@@ -200,15 +187,13 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
|
r.reapWebhook(wh.ID, wh.RetentionDays)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapWebhook removes every expired event (and its dependents) from a
|
// reapWebhook removes every expired event (and its dependents) from a
|
||||||
// single webhook's database, or as many as it reaches before ctx is
|
// single webhook's database.
|
||||||
// cancelled.
|
|
||||||
func (r *RetentionReaper) reapWebhook(
|
func (r *RetentionReaper) reapWebhook(
|
||||||
ctx context.Context,
|
|
||||||
webhookID string,
|
webhookID string,
|
||||||
retentionDays int,
|
retentionDays int,
|
||||||
) {
|
) {
|
||||||
@@ -228,7 +213,7 @@ func (r *RetentionReaper) reapWebhook(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
deleted, err := reapExpired(ctx, db, cutoff)
|
deleted, err := reapExpired(db, cutoff)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
r.log.Error(
|
r.log.Error(
|
||||||
"retention sweep: failed to reap expired events",
|
"retention sweep: failed to reap expired events",
|
||||||
@@ -280,107 +265,57 @@ func retentionCutoff(
|
|||||||
), true
|
), true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reapExpired hard-deletes the events older than cutoff, with their
|
// reapExpired hard-deletes, in foreign-key-safe order, the delivery
|
||||||
// deliveries and delivery results, reapBatchSize events per
|
// results, deliveries, and events associated with events older than
|
||||||
// transaction with reapBatchPause between transactions, until none is
|
// cutoff. Deletes are unscoped so rows are physically removed rather
|
||||||
// left. Once ctx is cancelled it returns after the batch in hand,
|
// than soft-deleted, reclaiming disk. It returns the number of events
|
||||||
// leaving the rest to the next sweep, so stopping the app does not
|
// deleted.
|
||||||
// wait for a long prune. It returns the number of events deleted.
|
func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
|
||||||
func reapExpired(
|
// Fresh subqueries are built per statement to avoid reusing a
|
||||||
ctx context.Context, db *gorm.DB, cutoff time.Time,
|
// mutated builder across executions.
|
||||||
) (int64, error) {
|
expiredEventIDs := func() *gorm.DB {
|
||||||
var total int64
|
return db.Model(&Event{}).
|
||||||
|
|
||||||
for {
|
|
||||||
var eventIDs []string
|
|
||||||
|
|
||||||
err := db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Unscoped().Model(&Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Limit(reapBatchSize).
|
|
||||||
Pluck("id", &eventIDs).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("selecting expired events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(eventIDs) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return deleteEvents(tx, eventIDs)
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return total, err
|
|
||||||
}
|
|
||||||
|
|
||||||
total += int64(len(eventIDs))
|
|
||||||
|
|
||||||
if len(eventIDs) < reapBatchSize {
|
|
||||||
return total, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return total, nil
|
|
||||||
case <-time.After(reapBatchPause):
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// deleteEvents hard-deletes the given events and, in foreign-key-safe
|
|
||||||
// order before them, their delivery results and deliveries, then adds
|
|
||||||
// what it deleted to the running totals. It runs on reapExpired's
|
|
||||||
// transaction, so the totals change exactly when the rows do. Deletes
|
|
||||||
// are unscoped so rows are physically removed rather than
|
|
||||||
// soft-deleted, reclaiming disk.
|
|
||||||
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
|
|
||||||
// 1. The delivery results of the events' deliveries.
|
|
||||||
err := tx.Unscoped().
|
|
||||||
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
|
|
||||||
Select("id").
|
Select("id").
|
||||||
Where("event_id IN ?", eventIDs)).
|
Where("created_at < ?", cutoff)
|
||||||
Delete(&DeliveryResult{}).Error
|
}
|
||||||
if err != nil {
|
expiredDeliveryIDs := func() *gorm.DB {
|
||||||
return fmt.Errorf("deleting expired delivery results: %w", err)
|
return db.Model(&Delivery{}).
|
||||||
|
Select("id").
|
||||||
|
Where("event_id IN (?)", expiredEventIDs())
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. The events' deliveries, after counting them, and the failed
|
// 1. Delivery results whose delivery belongs to an expired event.
|
||||||
// ones among them, per target. The status is tested in the select
|
res := db.Unscoped().
|
||||||
// list rather than the WHERE clause: there, SQLite would read every
|
Where("delivery_id IN (?)", expiredDeliveryIDs()).
|
||||||
// failed delivery the webhook has through the status index,
|
Delete(&DeliveryResult{})
|
||||||
// instead of only these through the event_id index.
|
if res.Error != nil {
|
||||||
var removed []TargetTotals
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired delivery results: %w",
|
||||||
err = tx.Unscoped().Model(&Delivery{}).
|
res.Error,
|
||||||
Select("target_id, count(*) AS deliveries_removed, "+
|
)
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
|
|
||||||
DeliveryStatusFailed).
|
|
||||||
Where("event_id IN ?", eventIDs).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&removed).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting expired deliveries: %w", err)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
err = tx.Unscoped().
|
// 2. Deliveries belonging to an expired event.
|
||||||
Where("event_id IN ?", eventIDs).
|
del := db.Unscoped().
|
||||||
Delete(&Delivery{}).Error
|
Where("event_id IN (?)", expiredEventIDs()).
|
||||||
if err != nil {
|
Delete(&Delivery{})
|
||||||
return fmt.Errorf("deleting expired deliveries: %w", err)
|
if del.Error != nil {
|
||||||
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired deliveries: %w",
|
||||||
|
del.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. The events themselves.
|
// 3. The expired events themselves.
|
||||||
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
|
ev := db.Unscoped().
|
||||||
|
Where("created_at < ?", cutoff).
|
||||||
|
Delete(&Event{})
|
||||||
if ev.Error != nil {
|
if ev.Error != nil {
|
||||||
return fmt.Errorf("deleting expired events: %w", ev.Error)
|
return 0, fmt.Errorf(
|
||||||
|
"deleting expired events: %w",
|
||||||
|
ev.Error,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
for i := range removed {
|
return ev.RowsAffected, nil
|
||||||
err = AddTargetTotals(tx, removed[i])
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,410 +0,0 @@
|
|||||||
package database_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"context"
|
|
||||||
"net/http"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// readEventTotals reads a webhook database's row of event totals,
|
|
||||||
// asserting that it has exactly one.
|
|
||||||
func readEventTotals(t *testing.T, db *gorm.DB) database.EventTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.EventTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
require.Len(t, rows, 1)
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func readTargetTotals(
|
|
||||||
t *testing.T, db *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookDBManager_TotalsSurviveReopen verifies that a new event
|
|
||||||
// database starts with one row of zero event totals and no target
|
|
||||||
// totals, that adding to a target twice adds to the one row, and that
|
|
||||||
// opening the database again keeps everything added.
|
|
||||||
func TestWebhookDBManager_TotalsSurviveReopen(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
mgr, lc := setupTestWebhookDBManager(t)
|
|
||||||
ctx := context.Background()
|
|
||||||
require.NoError(t, lc.Start(ctx))
|
|
||||||
|
|
||||||
defer func() { require.NoError(t, lc.Stop(ctx)) }()
|
|
||||||
|
|
||||||
webhookID := uuid.New().String()
|
|
||||||
|
|
||||||
db, err := mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
fresh := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{ID: fresh.ID}, fresh)
|
|
||||||
assert.Empty(t, readTargetTotals(t, db))
|
|
||||||
|
|
||||||
first, second := uuid.New().String(), uuid.New().String()
|
|
||||||
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: 2,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: first, Failed: 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: second, Deliveries: 1,
|
|
||||||
}))
|
|
||||||
|
|
||||||
// Drop the cached connection so the next open reopens the file,
|
|
||||||
// as a restart would.
|
|
||||||
require.NoError(t, mgr.CloseAll())
|
|
||||||
|
|
||||||
db, err = mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
assert.Equal(t, database.EventTotals{ID: fresh.ID, Events: 2},
|
|
||||||
readEventTotals(t, db))
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {
|
|
||||||
TargetID: first, Deliveries: 2, Delivered: 1, Failed: 1,
|
|
||||||
},
|
|
||||||
second: {TargetID: second, Deliveries: 1},
|
|
||||||
}, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedExpiredEvents stores count events created at the given time,
|
|
||||||
// each with a delivered delivery to one target and a failed delivery
|
|
||||||
// to the other, and one attempt for each delivery.
|
|
||||||
func seedExpiredEvents(
|
|
||||||
t *testing.T,
|
|
||||||
db *gorm.DB,
|
|
||||||
webhookID string,
|
|
||||||
count int,
|
|
||||||
createdAt time.Time,
|
|
||||||
delivered, failed string,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := make([]database.Event, count)
|
|
||||||
deliveries := make([]database.Delivery, 0, 2*count)
|
|
||||||
|
|
||||||
for i := range events {
|
|
||||||
events[i] = database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
events[i].ID = uuid.New().String()
|
|
||||||
events[i].CreatedAt = createdAt
|
|
||||||
|
|
||||||
deliveries = append(deliveries,
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: delivered,
|
|
||||||
Status: database.DeliveryStatusDelivered,
|
|
||||||
},
|
|
||||||
database.Delivery{
|
|
||||||
EventID: events[i].ID,
|
|
||||||
TargetID: failed,
|
|
||||||
Status: database.DeliveryStatusFailed,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
|
||||||
require.NoError(t, db.CreateInBatches(deliveries, 500).Error)
|
|
||||||
|
|
||||||
results := make([]database.DeliveryResult, len(deliveries))
|
|
||||||
for i := range deliveries {
|
|
||||||
results[i] = database.DeliveryResult{
|
|
||||||
DeliveryID: deliveries[i].ID, AttemptNum: 1,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(results, 500).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedBareEvents stores count events created at the given time, with
|
|
||||||
// no deliveries.
|
|
||||||
func seedBareEvents(
|
|
||||||
t *testing.T,
|
|
||||||
db *gorm.DB,
|
|
||||||
webhookID string,
|
|
||||||
count int,
|
|
||||||
createdAt time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
events := make([]database.Event, count)
|
|
||||||
for i := range events {
|
|
||||||
events[i] = database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
events[i].CreatedAt = createdAt
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.CreateInBatches(events, 500).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_PrunesMoreThanOneBatch verifies that a prune
|
|
||||||
// larger than one transaction's batch removes every expired event with
|
|
||||||
// its deliveries and delivery results, keeps the recent event, and
|
|
||||||
// adds what it removed to the event and target totals, so the totals
|
|
||||||
// within retention match the rows still stored.
|
|
||||||
func TestRetentionReaper_PrunesMoreThanOneBatch(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
|
||||||
|
|
||||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
|
||||||
|
|
||||||
db, err := env.mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
expired := database.ExportReapBatchSize + 1
|
|
||||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
|
||||||
seedExpiredEvents(t, db, webhookID, expired,
|
|
||||||
time.Now().Add(-40*24*time.Hour), delivered, failed)
|
|
||||||
|
|
||||||
// One recent event, delivered to the first target.
|
|
||||||
recent := seedEventChain(t, db, webhookID, time.Now())
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", recent.deliveryID).
|
|
||||||
Update("target_id", delivered).Error)
|
|
||||||
|
|
||||||
// The totals storing those rows would have left.
|
|
||||||
n := int64(expired)
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
}))
|
|
||||||
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
// Only the recent event's rows are left.
|
|
||||||
for _, model := range []any{
|
|
||||||
&database.Event{}, &database.Delivery{}, &database.DeliveryResult{},
|
|
||||||
} {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(model).Count(&count).Error)
|
|
||||||
assert.Equal(t, int64(1), count, "%T rows left", model)
|
|
||||||
}
|
|
||||||
|
|
||||||
assertChainPresent(t, db, recent)
|
|
||||||
|
|
||||||
eventTotals := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, database.EventTotals{
|
|
||||||
ID: eventTotals.ID, Events: n + 1, EventsRemoved: n,
|
|
||||||
}, eventTotals)
|
|
||||||
|
|
||||||
targetTotals := readTargetTotals(t, db)
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
delivered: {
|
|
||||||
TargetID: delivered, Deliveries: n + 1, Delivered: n + 1,
|
|
||||||
DeliveriesRemoved: n,
|
|
||||||
},
|
|
||||||
failed: {
|
|
||||||
TargetID: failed, Deliveries: n, Failed: n,
|
|
||||||
DeliveriesRemoved: n, FailedRemoved: n,
|
|
||||||
},
|
|
||||||
}, targetTotals)
|
|
||||||
|
|
||||||
// A sweep with nothing left to remove changes nothing.
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
|
|
||||||
assert.Equal(t, eventTotals, readEventTotals(t, db))
|
|
||||||
assert.Equal(t, targetTotals, readTargetTotals(t, db))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_WriteDuringPruneSucceeds verifies that a prune
|
|
||||||
// of several batches lets other writers in between its batches: an
|
|
||||||
// event stored once the first batch is deleted is stored while expired
|
|
||||||
// events are still left, not only after the prune has finished.
|
|
||||||
func TestRetentionReaper_WriteDuringPruneSucceeds(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
|
||||||
|
|
||||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
|
||||||
|
|
||||||
db, err := env.mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Three batches of expired events, with nothing else stored: only
|
|
||||||
// the number of batches matters here.
|
|
||||||
expired := 3 * database.ExportReapBatchSize
|
|
||||||
seedBareEvents(t, db, webhookID, expired,
|
|
||||||
time.Now().Add(-40*24*time.Hour))
|
|
||||||
|
|
||||||
cutoff := time.Now().Add(-30 * 24 * time.Hour)
|
|
||||||
countExpired := func() int64 {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Event{}).
|
|
||||||
Where("created_at < ?", cutoff).
|
|
||||||
Count(&count).Error)
|
|
||||||
|
|
||||||
return count
|
|
||||||
}
|
|
||||||
|
|
||||||
pruned := make(chan struct{})
|
|
||||||
|
|
||||||
go func() {
|
|
||||||
defer close(pruned)
|
|
||||||
|
|
||||||
env.reaper.ExportSweep(context.Background())
|
|
||||||
}()
|
|
||||||
|
|
||||||
t.Cleanup(func() { <-pruned })
|
|
||||||
|
|
||||||
// Every stored event is expired until the write below.
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).Count(&count).Error
|
|
||||||
|
|
||||||
return err == nil && count < int64(expired)
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
event := &database.Event{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
EntrypointID: uuid.New().String(),
|
|
||||||
Method: http.MethodPost,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.Create(event).Error)
|
|
||||||
|
|
||||||
assert.Positive(t, countExpired(),
|
|
||||||
"the event was stored only after the whole prune")
|
|
||||||
|
|
||||||
<-pruned
|
|
||||||
|
|
||||||
assert.Zero(t, countExpired())
|
|
||||||
|
|
||||||
var stored database.Event
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", event.ID).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRetentionReaper_StopDuringPruneLeavesTheRest verifies that
|
|
||||||
// stopping the reaper during a prune of several batches returns
|
|
||||||
// between two batches, well inside the stop timeout, leaving the
|
|
||||||
// remaining expired events for the next sweep, and that the totals
|
|
||||||
// match the rows left.
|
|
||||||
func TestRetentionReaper_StopDuringPruneLeavesTheRest(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
env := setupRetentionTest(t)
|
|
||||||
|
|
||||||
webhookID := createWebhook(t, env.mainDB.DB(), 30)
|
|
||||||
|
|
||||||
db, err := env.mgr.GetDB(webhookID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
// Two batches and one more of expired events, a few of them with a
|
|
||||||
// delivered and a failed delivery for the target totals to count.
|
|
||||||
// Most carry nothing else, to keep the test quick.
|
|
||||||
const withDeliveries = 10
|
|
||||||
|
|
||||||
expiredAt := time.Now().Add(-40 * 24 * time.Hour)
|
|
||||||
delivered, failed := uuid.New().String(), uuid.New().String()
|
|
||||||
seedExpiredEvents(t, db, webhookID, withDeliveries, expiredAt,
|
|
||||||
delivered, failed)
|
|
||||||
seedBareEvents(t, db, webhookID,
|
|
||||||
2*database.ExportReapBatchSize+1-withDeliveries, expiredAt)
|
|
||||||
|
|
||||||
n := int64(2*database.ExportReapBatchSize + 1)
|
|
||||||
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
|
|
||||||
Events: n,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: delivered, Deliveries: withDeliveries,
|
|
||||||
Delivered: withDeliveries,
|
|
||||||
}))
|
|
||||||
require.NoError(t, database.AddTargetTotals(db, database.TargetTotals{
|
|
||||||
TargetID: failed, Deliveries: withDeliveries,
|
|
||||||
Failed: withDeliveries,
|
|
||||||
}))
|
|
||||||
|
|
||||||
env.reaper.ExportSetInterval(time.Millisecond)
|
|
||||||
env.reaper.ExportStart()
|
|
||||||
|
|
||||||
// Stop once the first batch is deleted. The stop lands in the pause
|
|
||||||
// after it, or at worst during the second batch, so at least the
|
|
||||||
// last event is left.
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var count int64
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).Count(&count).Error
|
|
||||||
|
|
||||||
return err == nil && count < n
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
// The app's stop timeout.
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
require.NoError(t, env.reaper.ExportStop(ctx))
|
|
||||||
|
|
||||||
var events int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Event{}).Count(&events).Error)
|
|
||||||
assert.Positive(t, events, "the stop waited for the whole prune")
|
|
||||||
|
|
||||||
eventTotals := readEventTotals(t, db)
|
|
||||||
assert.Equal(t, events, eventTotals.Events-eventTotals.EventsRemoved)
|
|
||||||
|
|
||||||
targetTotals := readTargetTotals(t, db)
|
|
||||||
require.Len(t, targetTotals, 2)
|
|
||||||
|
|
||||||
for target, totals := range targetTotals {
|
|
||||||
var deliveries, failures int64
|
|
||||||
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("target_id = ?", target).
|
|
||||||
Count(&deliveries).Error)
|
|
||||||
require.NoError(t, db.Model(&database.Delivery{}).
|
|
||||||
Where("target_id = ? AND status = ?",
|
|
||||||
target, database.DeliveryStatusFailed).
|
|
||||||
Count(&failures).Error)
|
|
||||||
|
|
||||||
assert.Equal(t, deliveries,
|
|
||||||
totals.Deliveries-totals.DeliveriesRemoved, target)
|
|
||||||
assert.Equal(t, failures, totals.Failed-totals.FailedRemoved,
|
|
||||||
target)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -35,8 +35,7 @@ var errInvalidCachedDBType = errors.New(
|
|||||||
|
|
||||||
// WebhookDBManager manages per-webhook SQLite database files
|
// WebhookDBManager manages per-webhook SQLite database files
|
||||||
// for event storage. Each webhook gets its own dedicated
|
// for event storage. Each webhook gets its own dedicated
|
||||||
// database containing Events, Deliveries, DeliveryResults and the
|
// database containing Events, Deliveries, and DeliveryResults.
|
||||||
// running totals of them (EventTotals, TargetTotals).
|
|
||||||
// Database connections are opened lazily and cached.
|
// Database connections are opened lazily and cached.
|
||||||
type WebhookDBManager struct {
|
type WebhookDBManager struct {
|
||||||
dataDir string
|
dataDir string
|
||||||
@@ -296,7 +295,6 @@ func (m *WebhookDBManager) openDB(
|
|||||||
// Run migrations for event-tier models only
|
// Run migrations for event-tier models only
|
||||||
err = db.AutoMigrate(
|
err = db.AutoMigrate(
|
||||||
&Event{}, &Delivery{}, &DeliveryResult{},
|
&Event{}, &Delivery{}, &DeliveryResult{},
|
||||||
&EventTotals{}, &TargetTotals{},
|
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
_ = sqlDB.Close()
|
_ = sqlDB.Close()
|
||||||
@@ -307,18 +305,6 @@ func (m *WebhookDBManager) openDB(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// A new database gets its row of event totals, all zero. Target
|
|
||||||
// totals rows are created by the first delivery to each target.
|
|
||||||
err = db.FirstOrCreate(&EventTotals{}).Error
|
|
||||||
if err != nil {
|
|
||||||
_ = sqlDB.Close()
|
|
||||||
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"creating event totals for webhook database %s: %w",
|
|
||||||
webhookID, err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
m.log.Info(
|
m.log.Info(
|
||||||
"opened per-webhook database",
|
"opened per-webhook database",
|
||||||
"webhook_id", webhookID,
|
"webhook_id", webhookID,
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
package delivery_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// targetTotals reads one target's totals from a webhook database, all
|
|
||||||
// zero when it has no row.
|
|
||||||
func targetTotals(
|
|
||||||
t *testing.T, db *gorm.DB, targetID string,
|
|
||||||
) database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, db.Where("target_id = ?", targetID).
|
|
||||||
Find(&rows).Error)
|
|
||||||
|
|
||||||
if len(rows) == 0 {
|
|
||||||
return database.TargetTotals{TargetID: targetID}
|
|
||||||
}
|
|
||||||
|
|
||||||
return rows[0]
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
|
|
||||||
// write records for the webhook page's statistics: the time a delivery
|
|
||||||
// finished, set only when it becomes delivered or failed, and one more
|
|
||||||
// on its target's delivered or failed total.
|
|
||||||
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
status database.DeliveryStatus
|
|
||||||
finished bool
|
|
||||||
delivered int64
|
|
||||||
failed int64
|
|
||||||
}{
|
|
||||||
{database.DeliveryStatusRetrying, false, 0, 0},
|
|
||||||
{database.DeliveryStatusDelivered, true, 1, 0},
|
|
||||||
{database.DeliveryStatusFailed, true, 0, 1},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
t.Run(string(tt.status), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
)
|
|
||||||
|
|
||||||
before := time.Now()
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, tt.status,
|
|
||||||
))
|
|
||||||
|
|
||||||
var stored database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
|
||||||
assert.Equal(t, tt.status, stored.Status)
|
|
||||||
|
|
||||||
if tt.finished {
|
|
||||||
require.NotNil(t, stored.FinishedAt)
|
|
||||||
assert.False(t, stored.FinishedAt.Before(before))
|
|
||||||
} else {
|
|
||||||
assert.Nil(t, stored.FinishedAt)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, database.TargetTotals{
|
|
||||||
TargetID: targetID,
|
|
||||||
Delivered: tt.delivered,
|
|
||||||
Failed: tt.failed,
|
|
||||||
}, targetTotals(t, db, targetID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
|
|
||||||
// delivery retention deleted while the engine still held it. Failing
|
|
||||||
// it afterwards writes no row, so it adds no failure either: retention
|
|
||||||
// has already counted what it removed.
|
|
||||||
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, db.Unscoped().
|
|
||||||
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, database.DeliveryStatusFailed,
|
|
||||||
))
|
|
||||||
|
|
||||||
assert.Equal(t, database.TargetTotals{TargetID: targetID},
|
|
||||||
targetTotals(t, db, targetID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
|
|
||||||
// delivery settled a second time, as recovery can do when a worker has
|
|
||||||
// settled it since recovery read it. Neither status writes over the
|
|
||||||
// first, and the totals do not move.
|
|
||||||
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
finished := []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, first := range finished {
|
|
||||||
t.Run(string(first), func(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
db := testWebhookDB(t)
|
|
||||||
e := testEngine(t, 1)
|
|
||||||
event := seedEvent(t, db, `{}`)
|
|
||||||
targetID := uuid.New().String()
|
|
||||||
d := seedDelivery(
|
|
||||||
t, db, event.ID, targetID,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
)
|
|
||||||
|
|
||||||
// The delivery as recovery read it, before the worker
|
|
||||||
// settled it.
|
|
||||||
readBefore := d
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &d, first,
|
|
||||||
))
|
|
||||||
|
|
||||||
var settled database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
|
|
||||||
require.NotNil(t, settled.FinishedAt)
|
|
||||||
|
|
||||||
totals := targetTotals(t, db, targetID)
|
|
||||||
|
|
||||||
for _, again := range finished {
|
|
||||||
stale := readBefore
|
|
||||||
|
|
||||||
require.NoError(t, e.ExportUpdateDeliveryStatus(
|
|
||||||
db, &stale, again,
|
|
||||||
))
|
|
||||||
}
|
|
||||||
|
|
||||||
var stored database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
|
|
||||||
assert.Equal(t, first, stored.Status)
|
|
||||||
require.NotNil(t, stored.FinishedAt)
|
|
||||||
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
|
|
||||||
assert.Equal(t, totals, targetTotals(t, db, targetID))
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -531,11 +531,6 @@ func (e *Engine) processRetryTask(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set before anything below can fail the delivery: the failure is
|
|
||||||
// added to this target's totals.
|
|
||||||
d.EventID = task.EventID
|
|
||||||
d.TargetID = task.TargetID
|
|
||||||
|
|
||||||
if d.Status != database.DeliveryStatusRetrying {
|
if d.Status != database.DeliveryStatusRetrying {
|
||||||
e.log.Debug(
|
e.log.Debug(
|
||||||
"skipping retry for delivery "+
|
"skipping retry for delivery "+
|
||||||
@@ -567,6 +562,8 @@ func (e *Engine) processRetryTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
target := buildTargetFromTask(task)
|
target := buildTargetFromTask(task)
|
||||||
|
d.EventID = task.EventID
|
||||||
|
d.TargetID = task.TargetID
|
||||||
d.Event = event
|
d.Event = event
|
||||||
d.Target = target
|
d.Target = target
|
||||||
|
|
||||||
@@ -1557,9 +1554,8 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
targetType database.TargetType,
|
targetType database.TargetType,
|
||||||
status database.DeliveryStatus,
|
status database.DeliveryStatus,
|
||||||
) error {
|
) error {
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Model(d).
|
||||||
return writeDeliveryStatus(tx, d, status)
|
Update("status", status).Error
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf(
|
return fmt.Errorf(
|
||||||
"updating delivery %s to status %s: %w",
|
"updating delivery %s to status %s: %w",
|
||||||
@@ -1578,43 +1574,6 @@ func (e *Engine) updateDeliveryStatus(
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// writeDeliveryStatus writes a delivery's new status. A delivery that
|
|
||||||
// becomes delivered or failed also gets the time it finished, and is
|
|
||||||
// added to its target's delivered or failed total. That write changes
|
|
||||||
// only a delivery not yet delivered or failed, and the total moves
|
|
||||||
// only when it changed a row: retention may have deleted the delivery
|
|
||||||
// while the engine was working on it, and a recovery path may settle
|
|
||||||
// a delivery that a worker has already settled.
|
|
||||||
func writeDeliveryStatus(
|
|
||||||
tx *gorm.DB,
|
|
||||||
d *database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) error {
|
|
||||||
if !status.Terminal() {
|
|
||||||
return tx.Model(d).Update("status", status).Error
|
|
||||||
}
|
|
||||||
|
|
||||||
res := tx.Model(d).
|
|
||||||
Where("status NOT IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}).
|
|
||||||
Updates(map[string]any{
|
|
||||||
"status": status,
|
|
||||||
"finished_at": time.Now(),
|
|
||||||
})
|
|
||||||
if res.Error != nil || res.RowsAffected == 0 {
|
|
||||||
return res.Error
|
|
||||||
}
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}
|
|
||||||
|
|
||||||
// settleStatus moves a delivery to its outcome status and reports a
|
// settleStatus moves a delivery to its outcome status and reports a
|
||||||
// failed write through bookkeepingFailed, which leaves the row
|
// failed write through bookkeepingFailed, which leaves the row
|
||||||
// recoverable. It exists so the target call sites read as one
|
// recoverable. It exists so the target call sites read as one
|
||||||
|
|||||||
@@ -57,10 +57,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
|
|||||||
&database.Event{},
|
&database.Event{},
|
||||||
&database.Delivery{},
|
&database.Delivery{},
|
||||||
&database.DeliveryResult{},
|
&database.DeliveryResult{},
|
||||||
&database.EventTotals{},
|
|
||||||
&database.TargetTotals{},
|
|
||||||
))
|
))
|
||||||
require.NoError(t, db.Create(&database.EventTotals{}).Error)
|
|
||||||
|
|
||||||
return db
|
return db
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -150,16 +150,6 @@ func (e *Engine) ExportDeliverSlack(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
|
|
||||||
// target type, so no metric moves.
|
|
||||||
func (e *Engine) ExportUpdateDeliveryStatus(
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
d *database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
) error {
|
|
||||||
return e.updateDeliveryStatus(webhookDB, d, "", status)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ExportProcessNewTask exposes processNewTask.
|
// ExportProcessNewTask exposes processNewTask.
|
||||||
func (e *Engine) ExportProcessNewTask(
|
func (e *Engine) ExportProcessNewTask(
|
||||||
ctx context.Context, task *Task,
|
ctx context.Context, task *Task,
|
||||||
|
|||||||
@@ -418,38 +418,6 @@ func TestProcessRetryTask_TargetDeleted_MakesNoAttempt(
|
|||||||
assert.Zero(t, s.Engine.ExportInflightHeld())
|
assert.Zero(t, s.Engine.ExportInflightHeld())
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget verifies
|
|
||||||
// that the failure of a retry abandoned because its target is gone is
|
|
||||||
// added to that target's own totals, not to a row with no target.
|
|
||||||
func TestProcessRetryTask_TargetDeleted_CountsFailureOnTarget(
|
|
||||||
t *testing.T,
|
|
||||||
) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
s := newISetup(t)
|
|
||||||
|
|
||||||
var hits atomic.Int64
|
|
||||||
|
|
||||||
task, targetID := tRetryChainSetup(
|
|
||||||
t, s, "gone-counted", &hits,
|
|
||||||
)
|
|
||||||
|
|
||||||
require.NoError(t, s.MainDB.Delete(
|
|
||||||
&database.Target{}, "id = ?", targetID,
|
|
||||||
).Error)
|
|
||||||
|
|
||||||
s.Engine.ExportProcessRetryTask(
|
|
||||||
context.Background(), &task,
|
|
||||||
)
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, s.WebhookDB.Find(&rows).Error)
|
|
||||||
assert.Equal(t, []database.TargetTotals{
|
|
||||||
{TargetID: targetID, Failed: 1},
|
|
||||||
}, rows)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
// TestProcessRetryTask_TargetPresent_StillDelivers is the guard's
|
||||||
// mutation check: a liveness check that refused every retry would pass
|
// mutation check: a liveness check that refused every retry would pass
|
||||||
// the test above and break every retry there is.
|
// the test above and break every retry there is.
|
||||||
|
|||||||
@@ -299,9 +299,8 @@ func countInFlightDeliveries(
|
|||||||
return count, err
|
return count, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// createReplayDelivery writes the new pending delivery row, adds it to
|
// createReplayDelivery writes the new pending delivery row and returns
|
||||||
// its target's totals in the same transaction, and returns the task
|
// the task that carries it to the delivery engine.
|
||||||
// that carries it to the delivery engine.
|
|
||||||
//
|
//
|
||||||
// The row is written with associations omitted, and neither Event nor
|
// The row is written with associations omitted, and neither Event nor
|
||||||
// Target is populated on it: GORM's SaveBeforeAssociations would
|
// Target is populated on it: GORM's SaveBeforeAssociations would
|
||||||
@@ -320,16 +319,7 @@ func createReplayDelivery(
|
|||||||
Status: database.DeliveryStatusPending,
|
Status: database.DeliveryStatusPending,
|
||||||
}
|
}
|
||||||
|
|
||||||
err := webhookDB.Transaction(func(tx *gorm.DB) error {
|
err := webhookDB.Omit(clause.Associations).Create(dlv).Error
|
||||||
err := tx.Omit(clause.Associations).Create(dlv).Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: dlv.TargetID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return delivery.Task{}, err
|
return delivery.Task{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import (
|
|||||||
"html/template"
|
"html/template"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -71,29 +69,6 @@ func (s *Handlers) LoadEventLogViewsForTest(
|
|||||||
return views
|
return views
|
||||||
}
|
}
|
||||||
|
|
||||||
// WebhookStatsForTest returns the figures the statistics pane on a
|
|
||||||
// webhook's page shows, from the webhook's entrypoints and targets
|
|
||||||
// loaded as that page loads them.
|
|
||||||
func (s *Handlers) WebhookStatsForTest(webhookID string) *WebhookStats {
|
|
||||||
var entrypoints []database.Entrypoint
|
|
||||||
|
|
||||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&entrypoints)
|
|
||||||
|
|
||||||
var targets []database.Target
|
|
||||||
|
|
||||||
s.db.DB().Where("webhook_id = ?", webhookID).Find(&targets)
|
|
||||||
|
|
||||||
return s.loadWebhookStats(webhookID, entrypoints, targets)
|
|
||||||
}
|
|
||||||
|
|
||||||
// FinishedByTargetForTest exposes finishedByTarget for use in the
|
|
||||||
// handlers_test package.
|
|
||||||
func FinishedByTargetForTest(
|
|
||||||
webhookDB *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
return finishedByTarget(webhookDB, since)
|
|
||||||
}
|
|
||||||
|
|
||||||
// AddTemplateForTest registers a template under a page name so that
|
// AddTemplateForTest registers a template under a page name so that
|
||||||
// the handlers_test package can drive the render path with a
|
// the handlers_test package can drive the render path with a
|
||||||
// template of its own.
|
// template of its own.
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
|
|
||||||
"go.uber.org/fx"
|
"go.uber.org/fx"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
"sneak.berlin/go/webhooker/internal/database"
|
||||||
"sneak.berlin/go/webhooker/internal/delivery"
|
"sneak.berlin/go/webhooker/internal/delivery"
|
||||||
"sneak.berlin/go/webhooker/internal/globals"
|
"sneak.berlin/go/webhooker/internal/globals"
|
||||||
@@ -53,6 +54,7 @@ type HandlersParams struct {
|
|||||||
|
|
||||||
Logger *logger.Logger
|
Logger *logger.Logger
|
||||||
Globals *globals.Globals
|
Globals *globals.Globals
|
||||||
|
Config *config.Config
|
||||||
Database *database.Database
|
Database *database.Database
|
||||||
WebhookDBMgr *database.WebhookDBManager
|
WebhookDBMgr *database.WebhookDBManager
|
||||||
Healthcheck *healthcheck.Healthcheck
|
Healthcheck *healthcheck.Healthcheck
|
||||||
@@ -91,22 +93,18 @@ type Handlers struct {
|
|||||||
|
|
||||||
// parsePageTemplate parses a page-specific template set from the
|
// parsePageTemplate parses a page-specific template set from the
|
||||||
// embedded FS. Each page template is combined with the shared
|
// embedded FS. Each page template is combined with the shared
|
||||||
// base, htmlheader, and navbar templates, and with any further files
|
// base, htmlheader, and navbar templates. The page file must be
|
||||||
// the page includes. The page file must be listed first so that its
|
// listed first so that its root action ({{template "base" .}})
|
||||||
// root action ({{template "base" .}}) becomes the template set's entry
|
// becomes the template set's entry point.
|
||||||
// point.
|
func parsePageTemplate(pageFile string) *template.Template {
|
||||||
func parsePageTemplate(
|
|
||||||
pageFile string, included ...string,
|
|
||||||
) *template.Template {
|
|
||||||
files := append([]string{
|
|
||||||
pageFile,
|
|
||||||
"base.html",
|
|
||||||
"htmlheader.html",
|
|
||||||
"navbar.html",
|
|
||||||
}, included...)
|
|
||||||
|
|
||||||
return template.Must(
|
return template.Must(
|
||||||
template.ParseFS(templates.Templates, files...),
|
template.ParseFS(
|
||||||
|
templates.Templates,
|
||||||
|
pageFile,
|
||||||
|
"base.html",
|
||||||
|
"htmlheader.html",
|
||||||
|
"navbar.html",
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -133,9 +131,10 @@ func New(
|
|||||||
s.templates = map[string]*template.Template{
|
s.templates = map[string]*template.Template{
|
||||||
"login.html": parsePageTemplate("login.html"),
|
"login.html": parsePageTemplate("login.html"),
|
||||||
"profile.html": parsePageTemplate("profile.html"),
|
"profile.html": parsePageTemplate("profile.html"),
|
||||||
|
"settings.html": parsePageTemplate("settings.html"),
|
||||||
"sources_list.html": parsePageTemplate("sources_list.html"),
|
"sources_list.html": parsePageTemplate("sources_list.html"),
|
||||||
"sources_new.html": parsePageTemplate("sources_new.html"),
|
"sources_new.html": parsePageTemplate("sources_new.html"),
|
||||||
"source_detail.html": parsePageTemplate("source_detail.html", "webhook_stats.html"),
|
"source_detail.html": parsePageTemplate("source_detail.html"),
|
||||||
"source_edit.html": parsePageTemplate("source_edit.html"),
|
"source_edit.html": parsePageTemplate("source_edit.html"),
|
||||||
"source_logs.html": parsePageTemplate("source_logs.html"),
|
"source_logs.html": parsePageTemplate("source_logs.html"),
|
||||||
"target_edit.html": parsePageTemplate("target_edit.html"),
|
"target_edit.html": parsePageTemplate("target_edit.html"),
|
||||||
|
|||||||
@@ -83,16 +83,25 @@ func newTestApp(
|
|||||||
) *fxtest.App {
|
) *fxtest.App {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
|
|
||||||
|
return newTestAppWithConfig(
|
||||||
|
t, &config.Config{DataDir: t.TempDir()}, targets...,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// newTestAppWithConfig is newTestApp over a caller-supplied Config.
|
||||||
|
func newTestAppWithConfig(
|
||||||
|
t *testing.T,
|
||||||
|
cfg *config.Config,
|
||||||
|
targets ...any,
|
||||||
|
) *fxtest.App {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
return fxtest.New(
|
return fxtest.New(
|
||||||
t,
|
t,
|
||||||
fx.Provide(
|
fx.Provide(
|
||||||
globals.New,
|
globals.New,
|
||||||
logger.New,
|
logger.New,
|
||||||
func() *config.Config {
|
func() *config.Config { return cfg },
|
||||||
return &config.Config{
|
|
||||||
DataDir: t.TempDir(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
database.New,
|
database.New,
|
||||||
database.NewWebhookDBManager,
|
database.NewWebhookDBManager,
|
||||||
healthcheck.New,
|
healthcheck.New,
|
||||||
|
|||||||
@@ -0,0 +1,130 @@
|
|||||||
|
package handlers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/netip"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
// notSet is what the Settings page shows for a value that is empty.
|
||||||
|
const notSet = "not set"
|
||||||
|
|
||||||
|
// settingRow is one line of the Settings page: an environment
|
||||||
|
// variable, what it controls, and the value the server loaded for it.
|
||||||
|
type settingRow struct {
|
||||||
|
Name string
|
||||||
|
Description string
|
||||||
|
Value string
|
||||||
|
}
|
||||||
|
|
||||||
|
// HandleSettings returns a handler for the read-only Settings page,
|
||||||
|
// which lists the configuration the server started with.
|
||||||
|
func (h *Handlers) HandleSettings() http.HandlerFunc {
|
||||||
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
h.renderTemplate(w, r, "settings.html", map[string]any{
|
||||||
|
"Settings": settingRows(h.params.Config),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingRows lists every field of cfg under the environment variable
|
||||||
|
// it is read from, in the order of the README's configuration table.
|
||||||
|
// METRICS_PASSWORD and SENTRY_DSN are credentials, so their values
|
||||||
|
// never reach the page: only whether they are set.
|
||||||
|
func settingRows(cfg *config.Config) []settingRow {
|
||||||
|
metricsUsername := cfg.MetricsUsername
|
||||||
|
if metricsUsername == "" {
|
||||||
|
metricsUsername = notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return []settingRow{
|
||||||
|
{"WEBHOOKER_ENVIRONMENT", "dev or prod", cfg.Environment},
|
||||||
|
{"PORT", "HTTP listen port", strconv.Itoa(cfg.Port)},
|
||||||
|
{
|
||||||
|
"BIND_ADDRESS",
|
||||||
|
"IP address the HTTP listener binds",
|
||||||
|
cfg.BindAddress,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"DATA_DIR",
|
||||||
|
"Directory for all SQLite databases",
|
||||||
|
cfg.DataDir,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"DEBUG",
|
||||||
|
"Enable debug logging",
|
||||||
|
strconv.FormatBool(cfg.Debug),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"MAINTENANCE_MODE",
|
||||||
|
"Report maintenanceMode: true in the healthcheck JSON. " +
|
||||||
|
"It does not change how any request is served",
|
||||||
|
strconv.FormatBool(cfg.MaintenanceMode),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"METRICS_USERNAME",
|
||||||
|
"Basic auth username for /metrics",
|
||||||
|
metricsUsername,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"METRICS_PASSWORD",
|
||||||
|
"Basic auth password for /metrics",
|
||||||
|
setOrNotSet(cfg.MetricsPassword),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SENTRY_DSN",
|
||||||
|
"Error reporting DSN. Unset leaves error reporting off",
|
||||||
|
setOrNotSet(cfg.SentryDSN),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RETENTION_SWEEP_INTERVAL",
|
||||||
|
"How often the retention reaper and archive sweeper run",
|
||||||
|
cfg.RetentionSweepInterval.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"SESSION_IDLE_TIMEOUT",
|
||||||
|
"Idle session timeout. Zero or negative disables idle " +
|
||||||
|
"expiry",
|
||||||
|
cfg.SessionIdleTimeout.String(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"RECEIVER_RATE_LIMIT",
|
||||||
|
"Receiver requests per minute per IP per entrypoint " +
|
||||||
|
"(10x that per IP across the route)",
|
||||||
|
strconv.Itoa(cfg.ReceiverRateLimit),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"TRUSTED_PROXIES",
|
||||||
|
"CIDRs whose forwarded headers are trusted",
|
||||||
|
cidrList(cfg.TrustedProxies),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ALLOWED_EGRESS_CIDRS",
|
||||||
|
"CIDRs that delivery targets may reach despite the " +
|
||||||
|
"SSRF blocklist",
|
||||||
|
cidrList(cfg.AllowedEgressCIDRs),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// setOrNotSet is how the Settings page shows a credential: whether it
|
||||||
|
// has a value, never the value itself.
|
||||||
|
func setOrNotSet(value string) string {
|
||||||
|
if value == "" {
|
||||||
|
return notSet
|
||||||
|
}
|
||||||
|
|
||||||
|
return "set"
|
||||||
|
}
|
||||||
|
|
||||||
|
// cidrList renders a CIDR list setting for the Settings page.
|
||||||
|
func cidrList(prefixes []netip.Prefix) string {
|
||||||
|
if len(prefixes) == 0 {
|
||||||
|
return "none"
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.Join(config.PrefixStrings(prefixes), ", ")
|
||||||
|
}
|
||||||
@@ -0,0 +1,139 @@
|
|||||||
|
package handlers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"html"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"net/netip"
|
||||||
|
"regexp"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"sneak.berlin/go/webhooker/internal/config"
|
||||||
|
"sneak.berlin/go/webhooker/internal/handlers"
|
||||||
|
"sneak.berlin/go/webhooker/internal/session"
|
||||||
|
)
|
||||||
|
|
||||||
|
// settingsShown renders the Settings page over cfg as a logged-in user
|
||||||
|
// and returns the value it shows for each variable name, plus the
|
||||||
|
// whole page.
|
||||||
|
func settingsShown(
|
||||||
|
t *testing.T, cfg *config.Config,
|
||||||
|
) (map[string]string, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var h *handlers.Handlers
|
||||||
|
|
||||||
|
var sess *session.Session
|
||||||
|
|
||||||
|
app := newTestAppWithConfig(t, cfg, &h, &sess)
|
||||||
|
app.RequireStart()
|
||||||
|
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
req := httptest.NewRequestWithContext(
|
||||||
|
context.Background(), http.MethodGet, "/settings", nil,
|
||||||
|
)
|
||||||
|
for _, c := range authenticatedCookies(t, sess, "id", "admin") {
|
||||||
|
req.AddCookie(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
w := httptest.NewRecorder()
|
||||||
|
h.HandleSettings().ServeHTTP(w, req)
|
||||||
|
require.Equal(t, http.StatusOK, w.Code)
|
||||||
|
|
||||||
|
body := w.Body.String()
|
||||||
|
|
||||||
|
row := regexp.MustCompile(
|
||||||
|
`<code[^>]*>([A-Z_]+)</code>\s*<code[^>]*>([^<]*)</code>`,
|
||||||
|
)
|
||||||
|
|
||||||
|
shown := map[string]string{}
|
||||||
|
for _, match := range row.FindAllStringSubmatch(body, -1) {
|
||||||
|
shown[match[1]] = html.UnescapeString(match[2])
|
||||||
|
}
|
||||||
|
|
||||||
|
return shown, body
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsLoadedConfiguration(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const metricsPassword = "metrics-password-1f9a"
|
||||||
|
|
||||||
|
// No two rows show the same value: DEBUG and MAINTENANCE_MODE, and
|
||||||
|
// METRICS_PASSWORD and SENTRY_DSN, get opposite values, so each row
|
||||||
|
// is checked against its own field.
|
||||||
|
cfg := &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
Debug: true,
|
||||||
|
MaintenanceMode: false,
|
||||||
|
Environment: config.EnvironmentDev,
|
||||||
|
MetricsUsername: "scraper",
|
||||||
|
MetricsPassword: metricsPassword,
|
||||||
|
Port: 9123,
|
||||||
|
SentryDSN: "",
|
||||||
|
BindAddress: "192.0.2.10",
|
||||||
|
RetentionSweepInterval: 17 * time.Minute,
|
||||||
|
SessionIdleTimeout: 3 * time.Hour,
|
||||||
|
ReceiverRateLimit: 77,
|
||||||
|
TrustedProxies: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("10.1.0.0/16"),
|
||||||
|
},
|
||||||
|
AllowedEgressCIDRs: []netip.Prefix{
|
||||||
|
netip.MustParsePrefix("192.168.5.0/24"),
|
||||||
|
netip.MustParsePrefix("fd00::/8"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
shown, body := settingsShown(t, cfg)
|
||||||
|
|
||||||
|
assert.Equal(t, map[string]string{
|
||||||
|
"WEBHOOKER_ENVIRONMENT": "dev",
|
||||||
|
"PORT": "9123",
|
||||||
|
"BIND_ADDRESS": "192.0.2.10",
|
||||||
|
"DATA_DIR": cfg.DataDir,
|
||||||
|
"DEBUG": "true",
|
||||||
|
"MAINTENANCE_MODE": "false",
|
||||||
|
"METRICS_USERNAME": "scraper",
|
||||||
|
"METRICS_PASSWORD": "set",
|
||||||
|
"SENTRY_DSN": "not set",
|
||||||
|
"RETENTION_SWEEP_INTERVAL": "17m0s",
|
||||||
|
"SESSION_IDLE_TIMEOUT": "3h0m0s",
|
||||||
|
"RECEIVER_RATE_LIMIT": "77",
|
||||||
|
"TRUSTED_PROXIES": "10.1.0.0/16",
|
||||||
|
"ALLOWED_EGRESS_CIDRS": "192.168.5.0/24, fd00::/8",
|
||||||
|
}, shown)
|
||||||
|
|
||||||
|
assert.NotContains(t, body, metricsPassword)
|
||||||
|
assert.Contains(
|
||||||
|
t, body, `href="/settings"`,
|
||||||
|
"the navigation bar links to the page",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSettingsPageShowsUnsetValues(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
const (
|
||||||
|
sentryKey = "dsnkey7c2e"
|
||||||
|
sentryDSN = "https://" + sentryKey + "@errors.example.com/42"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SENTRY_DSN is set here and empty in the test above, the opposite
|
||||||
|
// of METRICS_PASSWORD, so each secret is seen both set and not set.
|
||||||
|
shown, body := settingsShown(t, &config.Config{
|
||||||
|
DataDir: t.TempDir(),
|
||||||
|
SentryDSN: sentryDSN,
|
||||||
|
})
|
||||||
|
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_USERNAME"])
|
||||||
|
assert.Equal(t, "not set", shown["METRICS_PASSWORD"])
|
||||||
|
assert.Equal(t, "set", shown["SENTRY_DSN"])
|
||||||
|
assert.NotContains(t, body, sentryKey)
|
||||||
|
assert.Equal(t, "none", shown["TRUSTED_PROXIES"])
|
||||||
|
assert.Equal(t, "none", shown["ALLOWED_EGRESS_CIDRS"])
|
||||||
|
}
|
||||||
@@ -457,7 +457,6 @@ func (h *Handlers) renderSourceDetail(
|
|||||||
"Targets": delivery.NewTargetViews(targets),
|
"Targets": delivery.NewTargetViews(targets),
|
||||||
"Events": events,
|
"Events": events,
|
||||||
"BaseURL": baseURL,
|
"BaseURL": baseURL,
|
||||||
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
h.renderTemplate(w, r, "source_detail.html", data)
|
h.renderTemplate(w, r, "source_detail.html", data)
|
||||||
|
|||||||
@@ -253,12 +253,11 @@ func requestEventSource(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// createAndFanOut writes the event and one pending delivery per target,
|
// createAndFanOut writes the event and one pending delivery per target
|
||||||
// and adds them to the webhook's running totals, in a single
|
// in a single transaction, then hands the tasks to the delivery
|
||||||
// transaction, then hands the tasks to the delivery engine. It is the
|
// engine. It is the only path by which an event and its deliveries are
|
||||||
// only path by which an event and its deliveries are created, so a
|
// created, so a resubmitted event is retried, SSRF-guarded and
|
||||||
// resubmitted event is retried, SSRF-guarded and circuit-broken
|
// circuit-broken exactly as a received one is.
|
||||||
// exactly as a received one is.
|
|
||||||
//
|
//
|
||||||
// The tasks are returned as well as queued, so a caller can report how
|
// The tasks are returned as well as queued, so a caller can report how
|
||||||
// many targets the event went to.
|
// many targets the event went to.
|
||||||
@@ -298,15 +297,6 @@ func (h *Handlers) createAndFanOut(
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddEventTotals(tx, database.EventTotals{
|
|
||||||
Events: 1, LastEventAt: &event.CreatedAt,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
tx.Rollback()
|
|
||||||
|
|
||||||
return nil, nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = tx.Commit().Error
|
err = tx.Commit().Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf(
|
return nil, nil, fmt.Errorf(
|
||||||
@@ -365,9 +355,8 @@ func (h *Handlers) finishWebhookResponse(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// buildDeliveryTasks creates one pending delivery per target in the
|
// buildDeliveryTasks creates one pending delivery per target in the
|
||||||
// transaction, adds each to its target's totals, and returns the tasks
|
// transaction and returns the tasks for the delivery engine. The
|
||||||
// for the delivery engine. The caller owns the transaction and rolls
|
// caller owns the transaction and rolls it back on error.
|
||||||
// it back on error.
|
|
||||||
func buildDeliveryTasks(
|
func buildDeliveryTasks(
|
||||||
tx *gorm.DB,
|
tx *gorm.DB,
|
||||||
event *database.Event,
|
event *database.Event,
|
||||||
@@ -391,13 +380,6 @@ func buildDeliveryTasks(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
err = database.AddTargetTotals(tx, database.TargetTotals{
|
|
||||||
TargetID: targets[i].ID, Deliveries: 1,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks = append(tasks, delivery.Task{
|
tasks = append(tasks, delivery.Task{
|
||||||
DeliveryID: dlv.ID,
|
DeliveryID: dlv.ID,
|
||||||
EventID: event.ID,
|
EventID: event.ID,
|
||||||
|
|||||||
@@ -1,259 +0,0 @@
|
|||||||
package handlers
|
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
)
|
|
||||||
|
|
||||||
// The spans of the two recent windows the statistics pane reports on:
|
|
||||||
// the last 10 minutes and the last 24 hours.
|
|
||||||
const (
|
|
||||||
shortWindow = 10 * time.Minute
|
|
||||||
longWindow = 24 * time.Hour
|
|
||||||
)
|
|
||||||
|
|
||||||
// percent turns a fraction into a percentage.
|
|
||||||
const percent = 100
|
|
||||||
|
|
||||||
// WebhookStats holds the figures in the statistics pane at the top of
|
|
||||||
// the webhook page.
|
|
||||||
type WebhookStats struct {
|
|
||||||
Entrypoints int
|
|
||||||
ActiveEntrypoints int
|
|
||||||
Targets int
|
|
||||||
ActiveTargets int
|
|
||||||
|
|
||||||
// Lifetime counts every event, delivery and failure the webhook
|
|
||||||
// has had, and WithinRetention those still stored.
|
|
||||||
Lifetime Counts
|
|
||||||
WithinRetention Counts
|
|
||||||
|
|
||||||
// InProgress counts the deliveries still pending or retrying.
|
|
||||||
InProgress int64
|
|
||||||
|
|
||||||
// LastEventAt is when the newest event arrived, or nil when none
|
|
||||||
// has. Retention does not change it.
|
|
||||||
LastEventAt *time.Time
|
|
||||||
|
|
||||||
Last10Minutes RecentWindow
|
|
||||||
Last24Hours RecentWindow
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counts holds a number of events, of deliveries and of failed
|
|
||||||
// deliveries.
|
|
||||||
type Counts struct {
|
|
||||||
Events int64
|
|
||||||
Deliveries int64
|
|
||||||
Failures int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// RecentWindow holds what happened in one recent window: the events
|
|
||||||
// received in it, and the deliveries that became delivered or failed in
|
|
||||||
// it.
|
|
||||||
type RecentWindow struct {
|
|
||||||
Events int64
|
|
||||||
Delivered int64
|
|
||||||
Failed int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// TargetFinished is how many of one target's deliveries became
|
|
||||||
// delivered, and how many failed, in a recent window.
|
|
||||||
type TargetFinished struct {
|
|
||||||
TargetID string
|
|
||||||
Delivered int64
|
|
||||||
Failed int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// FailurePercent is the share of the deliveries finished in the window
|
|
||||||
// that failed, or a dash when none finished. Deliveries still pending
|
|
||||||
// or retrying are not counted either way.
|
|
||||||
func (w RecentWindow) FailurePercent() string {
|
|
||||||
finished := w.Delivered + w.Failed
|
|
||||||
if finished == 0 {
|
|
||||||
return "—"
|
|
||||||
}
|
|
||||||
|
|
||||||
return fmt.Sprintf(
|
|
||||||
"%.1f%%", percent*float64(w.Failed)/float64(finished),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// loadWebhookStats gathers the figures for the statistics pane from the
|
|
||||||
// webhook's entrypoints and targets, as the page has already loaded
|
|
||||||
// them, and from its event database. It returns nil, and logs why, when
|
|
||||||
// the event database cannot be read.
|
|
||||||
func (h *Handlers) loadWebhookStats(
|
|
||||||
webhookID string,
|
|
||||||
entrypoints []database.Entrypoint,
|
|
||||||
targets []database.Target,
|
|
||||||
) *WebhookStats {
|
|
||||||
stats := &WebhookStats{
|
|
||||||
Entrypoints: len(entrypoints),
|
|
||||||
Targets: len(targets),
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range entrypoints {
|
|
||||||
if entrypoints[i].Active {
|
|
||||||
stats.ActiveEntrypoints++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for i := range targets {
|
|
||||||
if targets[i].Active {
|
|
||||||
stats.ActiveTargets++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Opening an event database that does not exist would create it,
|
|
||||||
// and it would hold nothing to count.
|
|
||||||
if !h.dbMgr.DBExists(webhookID) {
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := h.dbMgr.GetDB(webhookID)
|
|
||||||
if err == nil {
|
|
||||||
err = readEventStats(webhookDB, time.Now(), stats)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
h.log.Error(
|
|
||||||
"failed to read webhook statistics",
|
|
||||||
"webhook_id", webhookID,
|
|
||||||
"error", err,
|
|
||||||
)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return stats
|
|
||||||
}
|
|
||||||
|
|
||||||
// readEventStats fills in the figures that come from the webhook's
|
|
||||||
// event database. None of them reads every stored row: the totals are
|
|
||||||
// one row for the events and one per target for the deliveries, and
|
|
||||||
// every other figure is read from an index, over only the rows it
|
|
||||||
// counts.
|
|
||||||
func readEventStats(
|
|
||||||
db *gorm.DB, now time.Time, stats *WebhookStats,
|
|
||||||
) error {
|
|
||||||
err := readTotals(db, stats)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
err = db.Model(&database.Delivery{}).
|
|
||||||
Where("status IN ?", []database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusPending,
|
|
||||||
database.DeliveryStatusRetrying,
|
|
||||||
}).
|
|
||||||
Count(&stats.InProgress).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("counting deliveries in progress: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last10Minutes, err = readRecentWindow(
|
|
||||||
db, now.Add(-shortWindow),
|
|
||||||
)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Last24Hours, err = readRecentWindow(
|
|
||||||
db, now.Add(-longWindow),
|
|
||||||
)
|
|
||||||
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// readTotals fills in the lifetime and within-retention figures, and
|
|
||||||
// when the last event arrived, from the running totals: the events'
|
|
||||||
// row, and the targets' rows summed.
|
|
||||||
func readTotals(db *gorm.DB, stats *WebhookStats) error {
|
|
||||||
var events database.EventTotals
|
|
||||||
|
|
||||||
err := db.Take(&events).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading event totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
var targets []database.TargetTotals
|
|
||||||
|
|
||||||
err = db.Find(&targets).Error
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("reading target totals: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
stats.Lifetime.Events = events.Events
|
|
||||||
stats.WithinRetention.Events = events.Events - events.EventsRemoved
|
|
||||||
stats.LastEventAt = events.LastEventAt
|
|
||||||
|
|
||||||
for _, t := range targets {
|
|
||||||
stats.Lifetime.Deliveries += t.Deliveries
|
|
||||||
stats.Lifetime.Failures += t.Failed
|
|
||||||
stats.WithinRetention.Deliveries += t.Deliveries - t.DeliveriesRemoved
|
|
||||||
stats.WithinRetention.Failures += t.Failed - t.FailedRemoved
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// readRecentWindow counts the events received, and the deliveries that
|
|
||||||
// became delivered or failed, since the given time.
|
|
||||||
func readRecentWindow(
|
|
||||||
db *gorm.DB, since time.Time,
|
|
||||||
) (RecentWindow, error) {
|
|
||||||
var w RecentWindow
|
|
||||||
|
|
||||||
err := db.Model(&database.Event{}).
|
|
||||||
Where("created_at >= ?", since).
|
|
||||||
Count(&w.Events).Error
|
|
||||||
if err != nil {
|
|
||||||
return w, fmt.Errorf("counting recent events: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
byTarget, err := finishedByTarget(db, since)
|
|
||||||
if err != nil {
|
|
||||||
return w, err
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range byTarget {
|
|
||||||
w.Delivered += f.Delivered
|
|
||||||
w.Failed += f.Failed
|
|
||||||
}
|
|
||||||
|
|
||||||
return w, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// finishedByTarget counts, for each target, the deliveries that became
|
|
||||||
// delivered and those that failed since the given time, in one query
|
|
||||||
// over just that window of the deliveries' status index. A target with
|
|
||||||
// neither is left out.
|
|
||||||
func finishedByTarget(
|
|
||||||
db *gorm.DB, since time.Time,
|
|
||||||
) ([]TargetFinished, error) {
|
|
||||||
var byTarget []TargetFinished
|
|
||||||
|
|
||||||
err := db.Model(&database.Delivery{}).
|
|
||||||
Select("target_id, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS delivered, "+
|
|
||||||
"count(CASE WHEN status = ? THEN 1 END) AS failed",
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed).
|
|
||||||
Where("status IN ? AND finished_at >= ?",
|
|
||||||
[]database.DeliveryStatus{
|
|
||||||
database.DeliveryStatusDelivered,
|
|
||||||
database.DeliveryStatusFailed,
|
|
||||||
}, since).
|
|
||||||
Group("target_id").
|
|
||||||
Find(&byTarget).Error
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf(
|
|
||||||
"counting deliveries finished by target: %w", err,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget, nil
|
|
||||||
}
|
|
||||||
@@ -1,564 +0,0 @@
|
|||||||
package handlers_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
"net/http"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/google/uuid"
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/stretchr/testify/require"
|
|
||||||
"go.uber.org/fx/fxtest"
|
|
||||||
"gorm.io/gorm"
|
|
||||||
"gorm.io/gorm/clause"
|
|
||||||
"sneak.berlin/go/webhooker/internal/config"
|
|
||||||
"sneak.berlin/go/webhooker/internal/database"
|
|
||||||
"sneak.berlin/go/webhooker/internal/handlers"
|
|
||||||
"sneak.berlin/go/webhooker/internal/logger"
|
|
||||||
"sneak.berlin/go/webhooker/internal/session"
|
|
||||||
)
|
|
||||||
|
|
||||||
// statsEntrypoint adds an entrypoint to a webhook and returns its path.
|
|
||||||
func statsEntrypoint(
|
|
||||||
t *testing.T, db *database.Database, webhookID string, active bool,
|
|
||||||
) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
ep := &database.Entrypoint{
|
|
||||||
WebhookID: webhookID,
|
|
||||||
Path: uuid.New().String(),
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(ep).Error)
|
|
||||||
require.NoError(t, db.DB().Model(ep).Update("active", active).Error)
|
|
||||||
|
|
||||||
return ep.Path
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsDelivery returns an event's delivery to a target.
|
|
||||||
func statsDelivery(
|
|
||||||
t *testing.T, webhookDB *gorm.DB, eventID, targetID string,
|
|
||||||
) database.Delivery {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var d database.Delivery
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Where(
|
|
||||||
"event_id = ? AND target_id = ?", eventID, targetID,
|
|
||||||
).First(&d).Error)
|
|
||||||
|
|
||||||
return d
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsFinish settles a delivery as the delivery engine does: its
|
|
||||||
// final status and the time it finished, and one more on its target's
|
|
||||||
// delivered or failed total, in one transaction.
|
|
||||||
func statsFinish(
|
|
||||||
t *testing.T,
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
d database.Delivery,
|
|
||||||
status database.DeliveryStatus,
|
|
||||||
at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
add := database.TargetTotals{TargetID: d.TargetID, Delivered: 1}
|
|
||||||
if status == database.DeliveryStatusFailed {
|
|
||||||
add = database.TargetTotals{TargetID: d.TargetID, Failed: 1}
|
|
||||||
}
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Transaction(func(tx *gorm.DB) error {
|
|
||||||
err := tx.Model(&database.Delivery{}).
|
|
||||||
Where("id = ?", d.ID).
|
|
||||||
Updates(map[string]any{"status": status, "finished_at": at}).
|
|
||||||
Error
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return database.AddTargetTotals(tx, add)
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsAge moves an event's arrival back to the given time.
|
|
||||||
func statsAge(
|
|
||||||
t *testing.T, webhookDB *gorm.DB, eventID string, at time.Time,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Model(&database.Event{}).
|
|
||||||
Where("id = ?", eventID).
|
|
||||||
Update("created_at", at).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsTargetTotals reads a webhook database's target totals, keyed by
|
|
||||||
// target.
|
|
||||||
func statsTargetTotals(
|
|
||||||
t *testing.T, webhookDB *gorm.DB,
|
|
||||||
) map[string]database.TargetTotals {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
var rows []database.TargetTotals
|
|
||||||
|
|
||||||
require.NoError(t, webhookDB.Find(&rows).Error)
|
|
||||||
|
|
||||||
byTarget := make(map[string]database.TargetTotals, len(rows))
|
|
||||||
for _, row := range rows {
|
|
||||||
byTarget[row.TargetID] = row
|
|
||||||
}
|
|
||||||
|
|
||||||
return byTarget
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsHistory is the webhook seedStatsHistory builds: its event
|
|
||||||
// database, its newest event, and its two active targets.
|
|
||||||
type statsHistory struct {
|
|
||||||
webhook *database.Webhook
|
|
||||||
webhookDB *gorm.DB
|
|
||||||
newest database.Event
|
|
||||||
first, second string
|
|
||||||
}
|
|
||||||
|
|
||||||
// seedStatsHistory builds the webhook the statistics test checks: 14
|
|
||||||
// days of retention, eleven entrypoints (one inactive) and five targets
|
|
||||||
// (three inactive). Nine events arrive through the receiver, and so
|
|
||||||
// each has a delivery to the two active targets. The oldest event is
|
|
||||||
// past retention, the next 30 hours old, the next six hours old, the
|
|
||||||
// other six just in. Five deliveries are settled as the delivery engine
|
|
||||||
// would, the rest left pending, and a replay adds a pending delivery to
|
|
||||||
// the oldest event. Once retention has removed the oldest event, every
|
|
||||||
// figure in the pane differs from every other.
|
|
||||||
func seedStatsHistory(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
) statsHistory {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "stats", RetentionDays: 14,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
path := statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
for range 9 {
|
|
||||||
statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
}
|
|
||||||
|
|
||||||
statsEntrypoint(t, db, wh.ID, false)
|
|
||||||
|
|
||||||
first := seedConfiguredTarget(
|
|
||||||
t, db, wh.ID, database.TargetTypeHTTP,
|
|
||||||
`{"url":"`+replayTargetURL+`"}`,
|
|
||||||
)
|
|
||||||
second := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
|
|
||||||
for range 3 {
|
|
||||||
inactive := seedTarget(t, db, wh.ID, database.TargetTypeLog)
|
|
||||||
require.NoError(t, db.DB().Model(inactive).
|
|
||||||
Update("active", false).Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
router := receiverRouter(h)
|
|
||||||
|
|
||||||
for range 9 {
|
|
||||||
require.Equal(t, http.StatusOK, postReceiver(t, router, path))
|
|
||||||
}
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
events := listEvents(t, webhookDB)
|
|
||||||
require.Len(t, events, 9)
|
|
||||||
|
|
||||||
oldest, yesterday, middle, newest := events[0], events[1], events[2], events[8]
|
|
||||||
now := time.Now()
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, oldest.ID, now.Add(-15*24*time.Hour))
|
|
||||||
statsAge(t, webhookDB, yesterday.ID, now.Add(-30*time.Hour))
|
|
||||||
statsAge(t, webhookDB, middle.ID, now.Add(-6*time.Hour))
|
|
||||||
|
|
||||||
oldestFailure := statsDelivery(t, webhookDB, oldest.ID, first.ID)
|
|
||||||
statsFinish(t, webhookDB, oldestFailure,
|
|
||||||
database.DeliveryStatusFailed, now.Add(-14*24*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, yesterday.ID, first.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, yesterday.ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-29*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, middle.ID, second.ID),
|
|
||||||
database.DeliveryStatusFailed, now.Add(-5*time.Hour))
|
|
||||||
statsFinish(t, webhookDB,
|
|
||||||
statsDelivery(t, webhookDB, newest.ID, first.ID),
|
|
||||||
database.DeliveryStatusDelivered, now.Add(-2*time.Minute))
|
|
||||||
|
|
||||||
require.Equal(t, http.StatusSeeOther,
|
|
||||||
postReplay(t, h, sess, wh.ID, oldestFailure.ID).Code)
|
|
||||||
|
|
||||||
return statsHistory{
|
|
||||||
webhook: wh,
|
|
||||||
webhookDB: webhookDB,
|
|
||||||
newest: newest,
|
|
||||||
first: first.ID,
|
|
||||||
second: second.ID,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsPrune runs the real retention reaper until it has removed one
|
|
||||||
// event from the webhook's database, then stops it.
|
|
||||||
func statsPrune(
|
|
||||||
t *testing.T,
|
|
||||||
db *database.Database,
|
|
||||||
dbMgr *database.WebhookDBManager,
|
|
||||||
log *logger.Logger,
|
|
||||||
webhookDB *gorm.DB,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
lc := fxtest.NewLifecycle(t)
|
|
||||||
database.NewRetentionReaper(lc, database.RetentionReaperParams{
|
|
||||||
Config: &config.Config{
|
|
||||||
RetentionSweepInterval: 10 * time.Millisecond,
|
|
||||||
},
|
|
||||||
Database: db,
|
|
||||||
DBManager: dbMgr,
|
|
||||||
Logger: log,
|
|
||||||
})
|
|
||||||
|
|
||||||
lc.RequireStart()
|
|
||||||
|
|
||||||
require.Eventually(t, func() bool {
|
|
||||||
var totals database.EventTotals
|
|
||||||
|
|
||||||
err := webhookDB.Take(&totals).Error
|
|
||||||
|
|
||||||
return err == nil && totals.EventsRemoved == 1
|
|
||||||
}, 10*time.Second, 10*time.Millisecond)
|
|
||||||
|
|
||||||
lc.RequireStop()
|
|
||||||
}
|
|
||||||
|
|
||||||
// statsPane returns the text of the statistics pane in a rendered
|
|
||||||
// webhook page, everything from its heading to the next heading on the
|
|
||||||
// page, with the markup taken out and each run of space made one
|
|
||||||
// space. A table then reads header by header and row by row, each
|
|
||||||
// row's label followed by its figures in column order.
|
|
||||||
func statsPane(t *testing.T, page string) string {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
_, pane, found := strings.Cut(page, ">Statistics</h2>")
|
|
||||||
require.True(t, found, "the page has no statistics pane")
|
|
||||||
|
|
||||||
pane, _, _ = strings.Cut(pane, "<h2")
|
|
||||||
pane = regexp.MustCompile(`<[^>]*>`).ReplaceAllString(pane, " ")
|
|
||||||
|
|
||||||
return strings.Join(strings.Fields(pane), " ")
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertStatsTargets checks, for the history seedStatsHistory builds,
|
|
||||||
// each target's totals and its deliveries finished in the last 24
|
|
||||||
// hours. The first target has nine deliveries and the replay, the
|
|
||||||
// second nine; the inactive targets have none and so no row.
|
|
||||||
func assertStatsTargets(t *testing.T, hist statsHistory) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
first, second := hist.first, hist.second
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {TargetID: first, Deliveries: 10, Delivered: 1, Failed: 2},
|
|
||||||
second: {TargetID: second, Deliveries: 9, Failed: 2},
|
|
||||||
}, statsTargetTotals(t, hist.webhookDB))
|
|
||||||
|
|
||||||
lastDay, err := handlers.FinishedByTargetForTest(
|
|
||||||
hist.webhookDB, time.Now().Add(-24*time.Hour),
|
|
||||||
)
|
|
||||||
require.NoError(t, err)
|
|
||||||
assert.ElementsMatch(t, []handlers.TargetFinished{
|
|
||||||
{TargetID: first, Delivered: 1},
|
|
||||||
{TargetID: second, Failed: 1},
|
|
||||||
}, lastDay)
|
|
||||||
}
|
|
||||||
|
|
||||||
// assertStatsPaneAfterPrune checks the rendered statistics pane for the
|
|
||||||
// history seedStatsHistory builds, once retention has removed the
|
|
||||||
// oldest event: each figure after its label, in its column.
|
|
||||||
func assertStatsPaneAfterPrune(
|
|
||||||
t *testing.T,
|
|
||||||
h *handlers.Handlers,
|
|
||||||
sess *session.Session,
|
|
||||||
hist statsHistory,
|
|
||||||
) {
|
|
||||||
t.Helper()
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, hist.webhook.ID))
|
|
||||||
lastEvent := hist.newest.CreatedAt.UTC().Format("2006-01-02 15:04:05 UTC")
|
|
||||||
|
|
||||||
assert.Contains(t, pane, "Entrypoints 11 (10 active) "+
|
|
||||||
"Targets 5 (2 active) "+
|
|
||||||
"Deliveries in progress 12 "+
|
|
||||||
"Last event "+lastEvent+" "+
|
|
||||||
"Retention 14 days")
|
|
||||||
assert.Contains(t, pane, "Lifetime Within retention "+
|
|
||||||
"Events 9 8 "+
|
|
||||||
"Deliveries 19 16 "+
|
|
||||||
"Failures 4 3")
|
|
||||||
assert.Contains(t, pane, "Last 10 minutes Last 24 hours "+
|
|
||||||
"Events 6 7 "+
|
|
||||||
"Failures 0 1 "+
|
|
||||||
"Failure percentage 0.0% 50.0%")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_EveryFigureAcrossRetentionPrune checks every figure
|
|
||||||
// the statistics pane shows for the history seedStatsHistory builds,
|
|
||||||
// and each target's totals and recent figures, before and after the
|
|
||||||
// real retention reaper removes the oldest event.
|
|
||||||
func TestWebhookStats_EveryFigureAcrossRetentionPrune(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
hist := seedStatsHistory(t, h, sess, db, dbMgr)
|
|
||||||
first, second := hist.first, hist.second
|
|
||||||
|
|
||||||
stats := h.WebhookStatsForTest(hist.webhook.ID)
|
|
||||||
require.NotNil(t, stats)
|
|
||||||
|
|
||||||
assert.Equal(t, 11, stats.Entrypoints)
|
|
||||||
assert.Equal(t, 10, stats.ActiveEntrypoints)
|
|
||||||
assert.Equal(t, 5, stats.Targets)
|
|
||||||
assert.Equal(t, 2, stats.ActiveTargets)
|
|
||||||
assert.Equal(t, handlers.Counts{Events: 9, Deliveries: 19, Failures: 4},
|
|
||||||
stats.Lifetime)
|
|
||||||
assert.Equal(t, stats.Lifetime, stats.WithinRetention)
|
|
||||||
assert.Equal(t, int64(14), stats.InProgress)
|
|
||||||
require.NotNil(t, stats.LastEventAt)
|
|
||||||
assert.True(t, hist.newest.CreatedAt.Equal(*stats.LastEventAt))
|
|
||||||
assert.Equal(t, handlers.RecentWindow{Events: 6, Delivered: 1},
|
|
||||||
stats.Last10Minutes)
|
|
||||||
assert.Equal(t, handlers.RecentWindow{
|
|
||||||
Events: 7, Delivered: 1, Failed: 1,
|
|
||||||
}, stats.Last24Hours)
|
|
||||||
assert.Equal(t, "0.0%", stats.Last10Minutes.FailurePercent())
|
|
||||||
assert.Equal(t, "50.0%", stats.Last24Hours.FailurePercent())
|
|
||||||
|
|
||||||
assertStatsTargets(t, hist)
|
|
||||||
|
|
||||||
// Retention removes the oldest event with its three deliveries:
|
|
||||||
// the first target's failed one and the pending replay, and the
|
|
||||||
// second target's pending one.
|
|
||||||
statsPrune(t, db, dbMgr, log, hist.webhookDB)
|
|
||||||
|
|
||||||
after := h.WebhookStatsForTest(hist.webhook.ID)
|
|
||||||
require.NotNil(t, after)
|
|
||||||
|
|
||||||
assert.Equal(t, stats.Lifetime, after.Lifetime)
|
|
||||||
assert.Equal(t, handlers.Counts{Events: 8, Deliveries: 16, Failures: 3},
|
|
||||||
after.WithinRetention)
|
|
||||||
assert.Equal(t, int64(12), after.InProgress)
|
|
||||||
assert.Equal(t, stats.LastEventAt, after.LastEventAt)
|
|
||||||
assert.Equal(t, stats.Last10Minutes, after.Last10Minutes)
|
|
||||||
assert.Equal(t, stats.Last24Hours, after.Last24Hours)
|
|
||||||
|
|
||||||
assert.Equal(t, map[string]database.TargetTotals{
|
|
||||||
first: {
|
|
||||||
TargetID: first, Deliveries: 10, Delivered: 1, Failed: 2,
|
|
||||||
DeliveriesRemoved: 2, FailedRemoved: 1,
|
|
||||||
},
|
|
||||||
second: {
|
|
||||||
TargetID: second, Deliveries: 9, Failed: 2,
|
|
||||||
DeliveriesRemoved: 1,
|
|
||||||
},
|
|
||||||
}, statsTargetTotals(t, hist.webhookDB))
|
|
||||||
|
|
||||||
assertStatsPaneAfterPrune(t, h, sess, hist)
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_LastEventSurvivesPruningEveryEvent checks that once
|
|
||||||
// retention has removed every event, the pane still shows when the last
|
|
||||||
// one arrived rather than "none".
|
|
||||||
func TestWebhookStats_LastEventSurvivesPruningEveryEvent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
log *logger.Logger
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr, &log)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID, Name: "pruned", RetentionDays: 1,
|
|
||||||
}
|
|
||||||
require.NoError(t, db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
path := statsEntrypoint(t, db, wh.ID, true)
|
|
||||||
require.Equal(t, http.StatusOK,
|
|
||||||
postReceiver(t, receiverRouter(h), path))
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
events := listEvents(t, webhookDB)
|
|
||||||
require.Len(t, events, 1)
|
|
||||||
|
|
||||||
arrived := events[0].CreatedAt
|
|
||||||
|
|
||||||
statsAge(t, webhookDB, events[0].ID, time.Now().Add(-50*time.Hour))
|
|
||||||
statsPrune(t, db, dbMgr, log, webhookDB)
|
|
||||||
require.Empty(t, listEvents(t, webhookDB))
|
|
||||||
|
|
||||||
stats := h.WebhookStatsForTest(wh.ID)
|
|
||||||
require.NotNil(t, stats)
|
|
||||||
require.NotNil(t, stats.LastEventAt)
|
|
||||||
assert.True(t, arrived.Equal(*stats.LastEventAt))
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane,
|
|
||||||
"Last event "+arrived.UTC().Format("2006-01-02 15:04:05 UTC"))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_LastEventInUTC checks that the pane shows when the
|
|
||||||
// last event arrived in UTC, as the event list does, when the time was
|
|
||||||
// stored in another zone, as it is on a host whose local time is not
|
|
||||||
// UTC.
|
|
||||||
func TestWebhookStats_LastEventInUTC(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
webhookDB, err := dbMgr.GetDB(wh.ID)
|
|
||||||
require.NoError(t, err)
|
|
||||||
|
|
||||||
arrived := time.Date(2026, time.March, 4, 22, 30, 0, 0,
|
|
||||||
time.FixedZone("EST", -5*60*60))
|
|
||||||
require.NoError(t, database.AddEventTotals(webhookDB,
|
|
||||||
database.EventTotals{Events: 1, LastEventAt: &arrived}))
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Last event 2026-03-05 03:30:00 UTC")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_PaneShowsRetentionPeriod checks that the statistics
|
|
||||||
// pane itself, not only the line at the foot of the page, shows the
|
|
||||||
// webhook's retention period, for a finite one and for forever.
|
|
||||||
func TestWebhookStats_PaneShowsRetentionPeriod(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
retentionDays int
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{30, "30 days"},
|
|
||||||
{database.RetentionForeverDays, "forever"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
wh := &database.Webhook{
|
|
||||||
UserID: deleteTestUserID,
|
|
||||||
Name: "retention",
|
|
||||||
RetentionDays: tt.retentionDays,
|
|
||||||
}
|
|
||||||
require.NoError(t,
|
|
||||||
db.DB().Omit(clause.Associations).Create(wh).Error)
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Retention "+tt.want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestWebhookStats_WebhookWithNoEvents covers a webhook whose event
|
|
||||||
// database has never been opened: every count is zero, the
|
|
||||||
// percentages are a dash, and showing the page does not create the
|
|
||||||
// database.
|
|
||||||
func TestWebhookStats_WebhookWithNoEvents(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
var (
|
|
||||||
h *handlers.Handlers
|
|
||||||
sess *session.Session
|
|
||||||
db *database.Database
|
|
||||||
dbMgr *database.WebhookDBManager
|
|
||||||
)
|
|
||||||
|
|
||||||
app := newTestApp(t, &h, &sess, &db, &dbMgr)
|
|
||||||
app.RequireStart()
|
|
||||||
|
|
||||||
t.Cleanup(app.RequireStop)
|
|
||||||
|
|
||||||
wh := seedWebhook(t, db)
|
|
||||||
|
|
||||||
assert.Equal(t, &handlers.WebhookStats{}, h.WebhookStatsForTest(wh.ID))
|
|
||||||
assert.Equal(t, "—", handlers.RecentWindow{}.FailurePercent())
|
|
||||||
|
|
||||||
pane := statsPane(t, renderSourceDetailPage(t, h, sess, wh.ID))
|
|
||||||
assert.Contains(t, pane, "Last event none")
|
|
||||||
assert.Contains(t, pane, "Failure percentage — —")
|
|
||||||
assert.False(t, dbMgr.DBExists(wh.ID))
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestRecentWindow_FailurePercent pins the percentage: failed
|
|
||||||
// deliveries out of all that finished in the window.
|
|
||||||
func TestRecentWindow_FailurePercent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
tests := []struct {
|
|
||||||
window handlers.RecentWindow
|
|
||||||
want string
|
|
||||||
}{
|
|
||||||
{handlers.RecentWindow{}, "—"},
|
|
||||||
{handlers.RecentWindow{Events: 4}, "—"},
|
|
||||||
{handlers.RecentWindow{Delivered: 3, Failed: 1}, "25.0%"},
|
|
||||||
{handlers.RecentWindow{Failed: 2}, "100.0%"},
|
|
||||||
{handlers.RecentWindow{Delivered: 2}, "0.0%"},
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, tt := range tests {
|
|
||||||
assert.Equal(t, tt.want, tt.window.FailurePercent(), tt.window)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -140,7 +140,7 @@ func (n *noopEvictor) EvictWebhook(string) {}
|
|||||||
// and the database, exactly as internal/handlers builds them.
|
// and the database, exactly as internal/handlers builds them.
|
||||||
//
|
//
|
||||||
// One application per test function, not per case: every start that
|
// One application per test function, not per case: every start that
|
||||||
// finds no account seeds one with an Argon2id hash, and this package's
|
// finds no account seeds one at 64 MB of Argon2id, and this package's
|
||||||
// budget is not the place to spend that repeatedly.
|
// budget is not the place to spend that repeatedly.
|
||||||
func newServerApp(
|
func newServerApp(
|
||||||
t *testing.T, dir string,
|
t *testing.T, dir string,
|
||||||
|
|||||||
@@ -141,6 +141,7 @@ func (s *Server) setupRoutes() {
|
|||||||
|
|
||||||
s.setupPageRoutes()
|
s.setupPageRoutes()
|
||||||
s.setupUserRoutes()
|
s.setupUserRoutes()
|
||||||
|
s.setupSettingsRoutes()
|
||||||
s.setupSourceRoutes()
|
s.setupSourceRoutes()
|
||||||
s.setupWebhookRoutes()
|
s.setupWebhookRoutes()
|
||||||
}
|
}
|
||||||
@@ -182,6 +183,21 @@ func (s *Server) setupUserRoutes() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// setupSettingsRoutes serves the Settings page. It is GET only:
|
||||||
|
// configuration comes from the environment and nothing here changes
|
||||||
|
// it.
|
||||||
|
func (s *Server) setupSettingsRoutes() {
|
||||||
|
s.router.Route("/settings", func(r chi.Router) {
|
||||||
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
|
// see maxFormBodySize for why, and for what it costs.
|
||||||
|
r.Use(s.mw.MaxBodySize(maxFormBodySize))
|
||||||
|
r.Use(s.mw.CSRF())
|
||||||
|
r.Use(s.mw.NoCache())
|
||||||
|
r.Use(s.mw.RequireAuth())
|
||||||
|
r.Get("/", s.h.HandleSettings())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) setupSourceRoutes() {
|
func (s *Server) setupSourceRoutes() {
|
||||||
s.router.Route("/sources", func(r chi.Router) {
|
s.router.Route("/sources", func(r chi.Router) {
|
||||||
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
// MaxBodySize precedes CSRF and RequireAuth deliberately;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
package server_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestSettingsPageIsBehindLogin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
env := newTestEnv(t)
|
||||||
|
|
||||||
|
w := env.get("/settings", nil)
|
||||||
|
assert.Equal(t, http.StatusSeeOther, w.Code)
|
||||||
|
assert.Equal(t, "/pages/login", w.Header().Get("Location"))
|
||||||
|
|
||||||
|
w = env.get("/settings", env.authCookies(t, "id", "admin"))
|
||||||
|
assert.Equal(t, http.StatusOK, w.Code)
|
||||||
|
assert.Contains(t, w.Body.String(), "WEBHOOKER_ENVIRONMENT")
|
||||||
|
}
|
||||||
+1
-6
@@ -22,11 +22,6 @@
|
|||||||
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
# The one figure above 90s is GOMAXPROCS 1, a synthetic core floor rather than
|
||||||
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
# a condition CI runs under. If a CPU-limited runner ever puts a real run near
|
||||||
# 67s, that is the datum to revisit the org figure with.
|
# 67s, that is the datum to revisit the org figure with.
|
||||||
#
|
|
||||||
# -p 4 -parallel 8 keep the run under 2 GB of memory: at most four test
|
|
||||||
# binaries build or run at once, each with at most eight parallel tests. Under
|
|
||||||
# -race every test binary and every link costs a few hundred MB, so the
|
|
||||||
# defaults (one per core) add up to several GB on a many-core host.
|
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
||||||
@@ -34,7 +29,7 @@ ROOT="$(cd "$(dirname "$0")/.." && pwd -P)"
|
|||||||
main() {
|
main() {
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
"$ROOT/script/assets"
|
"$ROOT/script/assets"
|
||||||
go test -v -race -p 4 -parallel 8 -timeout 90s ./...
|
go test -v -race -timeout 90s ./...
|
||||||
}
|
}
|
||||||
|
|
||||||
main "$@"
|
main "$@"
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
<div class="hidden md:flex items-center gap-4">
|
<div class="hidden md:flex items-center gap-4">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text">Webhooks</a>
|
<a href="/sources" class="btn-text">Webhooks</a>
|
||||||
|
<a href="/settings" class="btn-text">Settings</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text">
|
<a href="/user/{{.User.Username}}" class="btn-text">
|
||||||
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
<svg class="w-5 h-5 mr-1" fill="currentColor" viewBox="0 0 16 16">
|
||||||
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
<path d="M11 6a3 3 0 1 1-6 0 3 3 0 0 1 6 0z"/>
|
||||||
@@ -39,6 +40,7 @@
|
|||||||
<div class="flex flex-col gap-2">
|
<div class="flex flex-col gap-2">
|
||||||
{{if .User}}
|
{{if .User}}
|
||||||
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
<a href="/sources" class="btn-text w-full text-left">Webhooks</a>
|
||||||
|
<a href="/settings" class="btn-text w-full text-left">Settings</a>
|
||||||
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
<a href="/user/{{.User.Username}}" class="btn-text w-full text-left">Profile</a>
|
||||||
<form method="POST" action="/pages/logout">
|
<form method="POST" action="/pages/logout">
|
||||||
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}">
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{{template "base" .}}
|
||||||
|
|
||||||
|
{{define "title"}}Settings - Webhooker{{end}}
|
||||||
|
|
||||||
|
{{define "content"}}
|
||||||
|
<div class="max-w-6xl mx-auto px-6 py-8">
|
||||||
|
<h1 class="text-2xl font-medium text-gray-900">Settings</h1>
|
||||||
|
<p class="text-sm text-gray-500 mt-1 mb-6">The configuration this server started with. It is set in the server's environment and cannot be changed here.</p>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="divide-y divide-gray-100">
|
||||||
|
{{range .Settings}}
|
||||||
|
<div class="p-4">
|
||||||
|
<div class="flex justify-between items-start gap-4">
|
||||||
|
<code class="text-sm font-medium text-gray-900">{{.Name}}</code>
|
||||||
|
<code class="text-sm text-gray-900 break-all">{{.Value}}</code>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-500 mt-1">{{.Description}}</p>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{end}}
|
||||||
@@ -24,8 +24,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{template "webhook_stats" .}}
|
|
||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
<!-- Entrypoints -->
|
<!-- Entrypoints -->
|
||||||
<div class="card">
|
<div class="card">
|
||||||
|
|||||||
@@ -1,95 +0,0 @@
|
|||||||
{{define "webhook_stats"}}
|
|
||||||
<!-- Statistics pane at the top of the webhook page. -->
|
|
||||||
<div class="card mb-6">
|
|
||||||
<div class="p-4 border-b border-gray-200">
|
|
||||||
<h2 class="text-lg font-medium text-gray-900">Statistics</h2>
|
|
||||||
</div>
|
|
||||||
{{with .Stats}}
|
|
||||||
<div class="p-4 flex flex-wrap gap-6 text-sm border-b border-gray-200">
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Entrypoints</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.Entrypoints}}</span>
|
|
||||||
<span class="text-gray-500">({{.ActiveEntrypoints}} active)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Targets</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.Targets}}</span>
|
|
||||||
<span class="text-gray-500">({{.ActiveTargets}} active)</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Deliveries in progress</span>
|
|
||||||
<span class="font-medium text-gray-900">{{.InProgress}}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Last event</span>
|
|
||||||
<span class="font-medium text-gray-900">{{with .LastEventAt}}{{.UTC.Format "2006-01-02 15:04:05 UTC"}}{{else}}none{{end}}</span>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<span class="text-gray-500">Retention</span>
|
|
||||||
<span class="font-medium text-gray-900">{{$.Webhook.RetentionLabel}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="p-4 grid grid-cols-1 lg:grid-cols-2 gap-6 text-sm">
|
|
||||||
<div>
|
|
||||||
<table class="w-full text-center text-gray-900">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
|
||||||
<th></th>
|
|
||||||
<th class="py-2 font-medium">Lifetime</th>
|
|
||||||
<th class="py-2 font-medium">Within retention</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Events</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Events}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Events}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Deliveries</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Deliveries}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Deliveries}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
|
||||||
<td class="py-2">{{.Lifetime.Failures}}</td>
|
|
||||||
<td class="py-2">{{.WithinRetention.Failures}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<table class="w-full text-center text-gray-900">
|
|
||||||
<thead>
|
|
||||||
<tr class="border-b border-gray-200 text-xs text-gray-500 uppercase tracking-wide">
|
|
||||||
<th></th>
|
|
||||||
<th class="py-2 font-medium">Last 10 minutes</th>
|
|
||||||
<th class="py-2 font-medium">Last 24 hours</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Events</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Events}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Events}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failures</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.Failed}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.Failed}}</td>
|
|
||||||
</tr>
|
|
||||||
<tr>
|
|
||||||
<td class="py-2 text-left text-gray-600">Failure percentage</td>
|
|
||||||
<td class="py-2">{{.Last10Minutes.FailurePercent}}</td>
|
|
||||||
<td class="py-2">{{.Last24Hours.FailurePercent}}</td>
|
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
<p class="mt-2 text-xs text-gray-500">Failure percentage is the failed deliveries out of all deliveries that finished in the window. Deliveries still pending or retrying are not counted.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="p-4 text-sm text-gray-500">The statistics could not be read.</div>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
{{end}}
|
|
||||||
Reference in New Issue
Block a user