Author SHA1 Message Date
clawbot 820d9391ff Index the event log's resubmit count with deleted_at (closes #325)
check / check (push) Waiting to run
The event log's resubmit count, run on every page load over up to 25 event ids, read every live event in the webhook's database: GORM adds deleted_at IS NULL, and SQLite, keeping no statistics there, chose the deleted_at index over the resubmitted_from_id one. deleted_at is now the second column of idx_events_resubmitted_from_id, so the count is answered from that index for a whole page of events. A test checks SQLite's plan for the statement as GORM builds it, with a full page of ids. The README's event-tier indexes table lists the index. Pre-1.0: the index changes in the schema in place, with nothing for older databases.

Model: opus-5-5
2026-10-02 21:23:33 +02:00
clawbot 2967c475a1 Show a database target's archive file, size and last write in the target list (closes #397)
check / check (push) Waiting to run
For a database target, the target list showed only its expiry, so the archive file the README's backup and move-away advice depend on could only be found from a shell on the host. Each database target now shows its archive file's name, its size on disk (the file and its -wal together) and when it was last written, relative with the full UTC time on hover, all from the files' metadata without opening the archive. Before the first write, and after the file has been moved away, it shows the name and "not created yet". Tests cover all three states.

Model: opus-5-5
2026-10-02 21:22:33 +02:00
clawbot 35d2f28c67 Name each embedded static file so a missing Alpine.js fails the build (closes #166)
check / check (push) Waiting to run
static/static.go embedded the css and js directories, so a build without the extracted Alpine.js file compiled and produced a binary whose admin pages silently had no Alpine. It now names the four files the pages load, so such a build fails naming js/alpine.min.js; make build extracts the file first, so the failure shows when that step is skipped. Both lint stages extract Alpine.js before linting, since the static package no longer compiles without it, and the README's lint stage says so. static/css/input.css, the Tailwind source no page loads, is no longer embedded or served.

Model: opus-5-5
2026-10-02 20:56:35 +02:00
24 changed files with 392 additions and 436 deletions
+1
View File
@@ -25,6 +25,7 @@ COPY . .
# would need a docker daemon inside the build. Keep these steps in step with
# Dockerfile.lint, including --network=none (see its header for why).
RUN make fmt-check
RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
+4
View File
@@ -31,6 +31,10 @@ FROM deps AS lint
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml
+38 -54
View File
@@ -79,8 +79,7 @@ directory, read once at startup before anything else looks at the
environment.
The file is optional and having none is the normal case for a
deployment. An empty file is the same as none: it has nothing in it to
apply. A file that is there but cannot be parsed aborts startup
deployment. A file that is there but cannot be parsed aborts startup
with a message naming it, because a single malformed line makes none
of the file apply: every variable in it silently reverts to its
default, which is exactly the failure [Invalid values abort
@@ -565,13 +564,11 @@ its Argon2id hash. There is no second account and no forgot-password
flow, so the banner and the reset command below are the only two ways
in.
A start that finds no `webhooker.db` in `DATA_DIR`, or a zero-length
one (which SQLite opens as an empty database), also logs
A start that finds no `webhooker.db` in `DATA_DIR` also logs
`created a new, empty database` at `WARN`, with the file's path,
shortly before the banner. On a deployment that has run before, that
line means `webhooker.db` was lost: either the file was missing, most
often because the volume holding `DATA_DIR` is not mounted, or it was
zero-length, as a truncated copy leaves it.
line means `DATA_DIR` was empty, most often because its volume is not
mounted.
#### Recovering a lost admin password
@@ -615,8 +612,7 @@ What it will not do:
the old password, so a reset underneath it would report a change the
service does not honour.
- **Create anything.** A `DATA_DIR` that does not exist, or that holds
no `webhooker.db` or a zero-length one, is an error naming the path
rather than a new empty deployment —
no `webhooker.db`, is an error rather than a new empty deployment —
a mistyped path must not be built out and then reported as a success.
- **Create an account.** A username that does not exist is an error.
`resetpw` changes an existing account's password and nothing else.
@@ -997,16 +993,6 @@ its sidecars; a killed or crashed instance leaves them, and they must be
carried with the `.db`. An archive the service has not opened since a
crash keeps that crash's sidecars, even across a later clean stop.
A missing sidecar is therefore normal, and SQLite makes new ones, so a
`-wal` lost from a copy cannot be reported: the transactions it held
are simply gone. SQLite reads a `-wal` up to its first damaged frame,
as after a crash, and rebuilds a damaged `-shm`. A sidecar with the
wrong mode is set back to `0600` when its database is opened. A
directory in place of either is refused then, with an error naming it:
for `webhooker.db` the server and `webhooker resetpw` stop, and an event
or archive database fails as a damaged one does (see
[Database Architecture](#database-architecture)).
Configuration is **not** in `DATA_DIR` — it comes from the environment
and from a `.env` file read out of the process working directory. Back
that up with your deployment config, separately.
@@ -1064,7 +1050,8 @@ Archive databases are the one exception the service is built for: the
archive writer closes and reopens its handle around writes (debounced
to at most one reopen per second), so an operator can move an
`archive-….db` away for offline retention while the service runs,
and it is recreated on the next write. See
and it is recreated on the next write. The webhook page names each
`database` target's archive file. See
[Database Architecture](#database-architecture). That is a
move-the-file-away workflow, not a substitute for the backup procedures
above.
@@ -1089,19 +1076,13 @@ with any `-wal`/`-shm` beside it, or wait until there are none.
1. Stop the service.
2. Restore the **whole set together**: `webhooker.db` *and* every
`events-*.db` *and* every `archive-*.db`. A restore that leaves out
`webhooker.db` or an `events-*.db` is reported, not refused; one
that leaves out an `archive-*.db` or a `-wal` (step 3) is not
reported at all. Every database is opened `mode=rwc`, so a
missing `events-{uuid}.db` is **created empty**: the webhook comes
back with its configuration intact and its entire event history
gone. The first start after the restore logs
`created a new, empty database` at `WARN` for each such file, with
its path, as it does for a missing `webhooker.db`. A missing
`archive-*.db` is recreated at its target's next delivery without a
warning, since moving one away is a supported workflow. Event
databases restored without `webhooker.db` are simply orphaned;
nothing references their UUIDs.
`events-*.db` *and* every `archive-*.db`. A partial restore fails
quietly rather than loudly. Every database is opened `mode=rwc`, so a
missing `events-{uuid}.db` is **created empty** on first access
instead of erroring — the webhook comes back with its configuration
intact and its entire event history silently gone. Event databases
restored without `webhooker.db` are simply orphaned; nothing
references their UUIDs.
3. Carry any `*.db-wal` and `*.db-shm` files that are in the backup.
They are part of the database, and dropping a `-wal` silently
@@ -1368,7 +1349,9 @@ apply. The directory is `3p/` rather than `vendor/` because Go treats a root
where `go:embed` picks it up. `script/test`, `make build` and `make dev` run
it first, and the Dockerfile builds through `make test` and `make build`, so
nothing downloads Alpine.js. The extracted file is not committed, and
`.dockerignore` keeps any host copy out of the build context.
`.dockerignore` keeps any host copy out of the build context. `static/static.go`
names every file it embeds, so a build that skips the extraction, such as a
bare `go build`, fails with an error naming `js/alpine.min.js`.
To move to a new version: download
`https://registry.npmjs.org/@alpinejs/csp/-/csp-<version>.tgz`, check it against
@@ -1896,16 +1879,19 @@ tags, so `AutoMigrate` creates them on a fresh database:
| `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 |
| `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` | `resubmitted_from_id`, `deleted_at` | The event log, which counts the events resubmitted from each event on a page |
| `events` | `created_at` | Retention, which selects expired events by age |
GORM's soft delete adds `deleted_at IS NULL` to these queries; retention
leaves it out. SQLite keeps no statistics on these tables, and without them it
rates the `deleted_at` index, which every live row matches, above an index on
a column matched against several values or compared with a range. So every
index but the last also covers `deleted_at`. It comes second, so that
retention can use the index without it, except in `events`, where the
statistics compare `created_at` with a range (`>=`) and SQLite narrows by a
range only on the last column it uses.
index but the last also covers `deleted_at`. It comes second in the `event_id`
and `delivery_id` indexes, so that retention can use them without it. The event
log's count, the one query on the `resubmitted_from_id` index, always carries
`deleted_at IS NULL` and uses both columns. In the statistics' `events` index
`deleted_at` comes first, because they compare `created_at` with a range (`>=`)
and SQLite narrows by a range only on the last column it uses.
#### Common Fields
@@ -1959,19 +1945,10 @@ encryption key is generated and stored, and an `admin` user is created.
the deliveries per target, kept through retention
Per-webhook databases are created automatically when a webhook is
created. They are managed by the `WebhookDBManager` component, which
created (and lazily on first access for webhooks that predate this
feature). They are managed by the `WebhookDBManager` component, which
handles connection pooling, lazy opening, migrations, and cleanup.
A per-webhook database that is missing or zero-length later means its
webhook's events and pending deliveries are gone. The next time it is
opened, an empty one is created in its place, so the webhook keeps
receiving, and `created a new, empty database` is logged at `WARN` with
the file's path. Every webhook's database is opened when the service
starts, so this appears at the latest at the first start after the
file was lost. A file there that SQLite cannot open fails that
webhook alone, with an `ERROR` naming the webhook on every access and a
500 to its senders, so one damaged file does not stop the others.
This separation provides:
- **Isolation** — a high-volume webhook won't cause lock contention or
@@ -2036,9 +2013,7 @@ After each write the archive handle is closed
and reopened, debounced to at most once per second, so an operator can
move the archive file away for offline archiving without stopping the
service; a moved or removed archive file is recreated automatically on
the next write. A zero-length archive file is written to as a new
archive: SQLite opens it as an empty database, so it holds nothing to
lose. An optional `expiry` in the target's config JSON (e.g.
the next write. An optional `expiry` in the target's config JSON (e.g.
`{"expiry":"720h"}`) is validated when the target is created — the
default (unset or the literal `never`) keeps rows forever — and rows
older than the expiry are pruned each time the archive is (re)opened. An
@@ -2062,6 +2037,14 @@ Because each `database` target has its own archive file, a target's
webhook with different expiries keep two archives, each pruned on its
own schedule.
The webhook page shows, for each `database` target, its archive file's
name, its size on disk and when it was last written. The size counts
the `.db` and its `-wal` together, and the last write is the later of
their two modification times, since a write lands in the `-wal` first.
Both are read from the files' metadata; the archive is never opened.
Before the first write, and after the file has been moved away, the page
shows `not created yet` beside the name.
Each `database` target on the webhook page has a **Download** button,
which returns its archive as one gzipped JSON file,
`archive-{webhook_name}-{target_name}-{YYYYMMDDTHHMMSSZ}.json.gz`, the
@@ -3410,8 +3393,9 @@ version is fixed independently of the compiler's:
1. **Lint stage** (`golangci/golangci-lint:v2.12.2`, Debian-based) —
installs `make`, downloads dependencies, copies the source, and runs
`make fmt-check`, then `golangci-lint config verify` and
`golangci-lint run`, both with `--network=none`.
`make fmt-check`, then `script/assets` to extract Alpine.js from
`3p/`, then `golangci-lint config verify` and `golangci-lint run`,
both with `--network=none`.
2. **Builder stage** (`golang:1.26.1-bookworm`) — depends on the lint
stage passing (it copies a file from it), runs `make test` and
`make build` (both extract Alpine.js from `3p/` first), and finally
@@ -4,7 +4,6 @@ import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
@@ -120,26 +119,3 @@ func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t, second, created, "an existing database is not new",
)
}
// TestZeroLengthDatabase_IsLoggedAsNew covers what
// https://git.eeqj.de/sneak/webhooker/issues/290 found: SQLite opens a
// zero-length file as an empty database, so a start on one is a first
// start, and it must say so exactly as a start with no file does.
func TestZeroLengthDatabase_IsLoggedAsNew(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(path, nil, database.SQLiteFilePerm))
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
assert.Contains(
t, out.String(),
`level=WARN msg="created a new, empty database" path=`+path,
)
}
+7 -12
View File
@@ -8,6 +8,7 @@ import (
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -202,7 +203,8 @@ func (d *Database) connectTo(dataDir string) error {
// Checked before opening, which creates the file. A DATA_DIR that
// is unexpectedly empty -- its volume not mounted, say -- looks
// exactly like a first start, so a new database is a warning.
created := missingOrEmpty(dbPath)
_, statErr := os.Stat(dbPath)
created := errors.Is(statErr, fs.ErrNotExist)
// Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool
@@ -211,15 +213,13 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil {
d.log.Error(
"failed to open database",
"path", dbPath,
"error", err,
)
return err
}
// Then use it with GORM. Its errors are SQLite's alone and name no
// file, so the path is added to them here.
// Then use it with GORM
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{
@@ -229,11 +229,10 @@ func (d *Database) connectTo(dataDir string) error {
if err != nil {
d.log.Error(
"failed to connect to database",
"path", dbPath,
"error", err,
)
return fmt.Errorf("connecting to %s: %w", dbPath, err)
return err
}
d.db = db
@@ -244,12 +243,8 @@ func (d *Database) connectTo(dataDir string) error {
d.log.Info("connected to database", "path", dbPath)
}
err = d.migrate()
if err != nil {
return fmt.Errorf("migrating %s: %w", dbPath, err)
}
return nil
// Run migrations
return d.migrate()
}
func (d *Database) migrate() error {
-25
View File
@@ -1,15 +1,9 @@
package database_test
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
@@ -106,22 +100,3 @@ func TestDatabaseConnection(t *testing.T) {
)
}
}
// TestOpen_UnreadableDatabaseIsNamed pins
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
// read webhooker.db, the error that stops the server and `webhooker
// resetpw` names the file, not only SQLite's own message.
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
))
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.Contains(t, err.Error(), path)
assert.Contains(t, err.Error(), "file is not a database")
}
@@ -199,6 +199,40 @@ func TestStatisticsQueriesUseTheirIndexes(t *testing.T) {
"(deleted_at=? AND created_at>?)")
}
// TestResubmitCountUsesItsIndex does the same for the event log's count
// of the events resubmitted from each of a page's events (resubmitCounts
// in the handlers). It passes a full page of 25 ids: with an index on
// resubmitted_from_id alone, SQLite uses it for three ids and turns to
// the deleted_at index from five.
func TestResubmitCountUsesItsIndex(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})
page := make([]string, 25)
for i := range page {
page[i] = uuid.New().String()
}
var counts []struct{ Total int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("resubmitted_from_id, count(*) AS total").
Where("resubmitted_from_id IN ?", page).
Group("resubmitted_from_id").Find(&counts),
"idx_events_resubmitted_from_id "+
"(resubmitted_from_id=? AND deleted_at=?)")
}
// assertPlanUses asserts that SQLite's plan for a statement GORM built
// in a dry run, run with the same SQL and arguments GORM would send,
// names each of the given indexes.
+5 -3
View File
@@ -19,8 +19,10 @@ type Event struct {
// narrows by a < only on the last column it uses. Its final delete
// has no deleted_at condition and uses the index on created_at
// alone. The other tables keep the unindexed BaseModel created_at.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1" json:"deletedAt,omitzero"`
// DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
@@ -42,7 +44,7 @@ type Event struct {
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index" json:"resubmittedFromId,omitempty"`
ResubmittedFromID *string `gorm:"type:uuid;index:idx_events_resubmitted_from_id,priority:1" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON.
+2 -2
View File
@@ -184,8 +184,8 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i]
// A missing database has nothing to reap. Restart recovery
// reports a lost one (see WebhookDBManager.GetDB).
// Nothing to reap if the per-webhook database has never
// been created.
if !r.dbManager.DBExists(wh.ID) {
continue
}
-23
View File
@@ -182,29 +182,6 @@ func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
requireDatabaseSetOwnerOnly(t, path)
}
// TestOpenSQLiteRefusesADirectorySidecar covers a directory in place
// of -wal or -shm. Beside a -shm directory SQLite opens the database
// read-only without a word, and every write then fails naming no file,
// so the open must stop instead, naming the directory.
func TestOpenSQLiteRefusesADirectorySidecar(t *testing.T) {
t.Parallel()
for _, suffix := range []string{"-wal", "-shm"} {
t.Run(suffix, func(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), database.MainDBFileName)
require.NoError(t, os.Mkdir(path+suffix, 0o700))
_, err := database.OpenSQLite(
path, database.SQLiteModeCreate,
)
require.Error(t, err)
assert.Contains(t, err.Error(), path+suffix)
})
}
}
// TestOpenSQLiteExistingModeDoesNotCreateTheFile guards the mechanism
// the fix uses: OpenSQLite now creates the database file itself, and
// must not do so for a caller that asked for an existing database. An
+2 -26
View File
@@ -7,7 +7,6 @@ import (
"io/fs"
"net/url"
"os"
"syscall"
"time"
_ "modernc.org/sqlite" // Pure Go SQLite driver
@@ -94,8 +93,7 @@ const (
const SQLiteFilePerm fs.FileMode = 0o600
// reserveSQLiteFile puts path at SQLiteFilePerm before the driver ever
// touches it, and tightens any sidecar already on disk. A directory in
// place of any of them is an error naming it.
// touches it, and tightens any sidecar already on disk.
//
// The mode has to be settled here rather than by a chmod after opening,
// because SQLite picks it: robust_open substitutes
@@ -145,15 +143,7 @@ func reserveSQLiteFile(path string, create bool) error {
for _, p := range append(
[]string{path}, sqliteSidecarPaths(path)...,
) {
// Chmod accepts a directory, and SQLite opens a database whose
// -shm is one read-only, without a word: every write then
// fails naming no file.
info, err := os.Stat(p)
if err == nil && info.IsDir() {
return fmt.Errorf("securing %s: %w", p, syscall.EISDIR)
}
err = os.Chmod(p, SQLiteFilePerm)
err := os.Chmod(p, SQLiteFilePerm)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("securing %s: %w", p, err)
}
@@ -162,20 +152,6 @@ func reserveSQLiteFile(path string, create bool) error {
return nil
}
// missingOrEmpty reports whether opening path in SQLiteModeCreate
// would start a new, empty database: the file is not there, or it is
// zero-length, which SQLite opens as an empty database. A file left at
// zero length by an interrupted first start or a truncated copy holds
// as little as a missing one, and must be reported the same way.
func missingOrEmpty(path string) bool {
info, err := os.Stat(path)
if errors.Is(err, fs.ErrNotExist) {
return true
}
return err == nil && info.Size() == 0
}
// sqliteSidecarPaths returns the files SQLite maintains beside a
// database under WAL. They carry the same rows as the database itself,
// so a fix that tightens only the main file has fixed nothing.
+28 -54
View File
@@ -98,18 +98,34 @@ func NewWebhookDBManager(
return m, nil
}
// GetDB returns the database connection for a webhook, opening it on
// first use.
//
// The file is made by CreateDB when the webhook is created. One that is
// missing or zero-length here means the webhook's events and pending
// deliveries are gone: an empty database is created in its place so
// the webhook keeps receiving, and that is logged as a warning naming
// the file, as a new main database is.
// GetDB returns the database connection for a webhook,
// creating the database file lazily if it doesn't exist.
func (m *WebhookDBManager) GetDB(
webhookID string,
) (*gorm.DB, error) {
return m.getDB(webhookID, false)
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// Slow path: open the database under the lock, looking in the
// cache again first. A caller that raced another one here then
// waits for its handle instead of opening a second one.
m.mu.Lock()
defer m.mu.Unlock()
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
m.dbs.Store(webhookID, db)
return db, nil
}
// asGormDB returns a value read from the cache as the database
@@ -127,12 +143,12 @@ func asGormDB(val any, webhookID string) (*gorm.DB, error) {
return db, nil
}
// CreateDB creates a new webhook's database file and runs
// migrations.
// CreateDB explicitly creates a new per-webhook database file
// and runs migrations.
func (m *WebhookDBManager) CreateDB(
webhookID string,
) error {
_, err := m.getDB(webhookID, true)
_, err := m.GetDB(webhookID)
return err
}
@@ -250,48 +266,6 @@ func (m *WebhookDBManager) DBPath(
return m.dbPath(webhookID)
}
// getDB is GetDB, and CreateDB when isNew is true: the webhook has just
// been created, so a missing file is expected rather than lost.
func (m *WebhookDBManager) getDB(
webhookID string, isNew bool,
) (*gorm.DB, error) {
// Fast path: already open
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// Slow path: open the database under the lock, looking in the
// cache again first. A caller that raced another one here then
// waits for its handle instead of opening a second one.
m.mu.Lock()
defer m.mu.Unlock()
if val, ok := m.dbs.Load(webhookID); ok {
return asGormDB(val, webhookID)
}
// Checked before opening, which creates the file. See GetDB.
path := m.dbPath(webhookID)
replaced := !isNew && missingOrEmpty(path)
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
if replaced {
m.log.Warn(
"created a new, empty database",
"webhook_id", webhookID,
"path", path,
)
}
m.dbs.Store(webhookID, db)
return db, nil
}
func (m *WebhookDBManager) dbPath(
webhookID string,
) string {
@@ -289,75 +289,6 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
assert.True(t, mgr.DBExists(webhookID))
}
// A webhook's database is made by CreateDB along with the webhook. One
// that GetDB finds missing or zero-length has lost the webhook's events
// and pending deliveries, so the empty database made in its place is
// logged as a warning naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
// reopening a database that is there, log no such warning.
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
t.Parallel()
const created = `level=WARN msg="created a new, empty database"`
open := func(
t *testing.T, prepare func(*database.WebhookDBManager, string),
) (string, string) {
t.Helper()
var logs bytes.Buffer
mgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)),
)
webhookID := uuid.New().String()
prepare(mgr, webhookID)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
return logs.String(),
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
}
t.Run("missing", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(*database.WebhookDBManager, string) {},
)
assert.Contains(t, logs, created+fields)
})
t.Run("zero-length", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, os.WriteFile(
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
))
},
)
assert.Contains(t, logs, created+fields)
})
t.Run("created with the webhook, then reopened", func(t *testing.T) {
t.Parallel()
logs, _ := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, mgr.CreateDB(webhookID))
require.NoError(t, mgr.CloseAll())
},
)
assert.NotContains(t, logs, created)
})
}
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
t.Parallel()
+1 -24
View File
@@ -699,33 +699,10 @@ func (e *Engine) recoverInFlight(ctx context.Context) {
default:
}
// The web interface is already serving, so the webhook may
// have been deleted since the list was read. Opening its
// database then would create the file again after the delete
// removed it.
var count int64
err := e.database.DB().
Model(&database.Webhook{}).
Where("id = ?", webhookID).
Count(&count).Error
if err != nil {
e.log.Error(
"failed to confirm webhook exists for recovery",
"webhook_id", webhookID,
"error", err,
)
if !e.dbManager.DBExists(webhookID) {
continue
}
if count == 0 {
continue
}
// Opened even when its file is missing, so that GetDB reports
// a lost database at start, not when the webhook next receives
// an event, which for a quiet webhook may be never.
e.recoverWebhookDeliveries(ctx, webhookID)
}
}
@@ -1,7 +1,6 @@
package delivery_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
@@ -1137,85 +1136,6 @@ func TestRecoverInFlight_WithPendingDeliveries(
}
}
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Restart
// recovery opens every webhook's database, so the empty one made in its
// place is reported at start, naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290).
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "lost-database")
var logs bytes.Buffer
dbMgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
assert.Contains(
t, logs.String(),
`level=WARN msg="created a new, empty database" webhook_id=`+
webhookID+" path="+dbMgr.DBPath(webhookID),
)
}
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while restart recovery runs.
// Its database file is gone, and recovery must not create it again.
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T,
) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
// The first query to return is recovery's read of the list of
// webhooks. Deleting the webhook right after it puts the delete
// between that read and the opening of the webhook's database.
deleted := false
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) {
if deleted {
return
}
deleted = true
require.NoError(t, mainDB.Delete(
&database.Webhook{}, "id = ?", webhookID,
).Error)
}))
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID))
}
// --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
@@ -515,6 +515,47 @@ func (w *archiveWriter) prune(expiry time.Duration) {
}
}
// ArchiveFileInfo is what the metadata of a database target's archive
// file says about it.
type ArchiveFileInfo struct {
// Size is the bytes on disk of the file and its -wal together.
Size int64
// Written is when the file or its -wal was last modified, whichever
// is later: a write lands in the -wal first.
Written time.Time
}
// StatArchive reads the metadata of the archive file at path and of
// its -wal, without opening the archive. With no file at path, which is
// so before the first write and after the operator moved it away, the
// error wraps fs.ErrNotExist.
func StatArchive(path string) (ArchiveFileInfo, error) {
file, err := os.Stat(path)
if err != nil {
return ArchiveFileInfo{}, err
}
info := ArchiveFileInfo{Size: file.Size(), Written: file.ModTime()}
wal, err := os.Stat(path + "-wal")
if errors.Is(err, fs.ErrNotExist) {
return info, nil
}
if err != nil {
return ArchiveFileInfo{}, err
}
info.Size += wal.Size()
if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime()
}
return info, nil
}
// fileExists reports whether a path currently exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
+44
View File
@@ -3,6 +3,7 @@ package delivery_test
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
@@ -183,6 +184,49 @@ func TestArchiveWriter_RecreatesAfterRemoval(
assert.Equal(t, "b", got[0].EventID)
}
// TestStatArchive proves StatArchive finds no file before the first
// write; after a write still held in the -wal, counts the -wal in the
// size and takes its later time as the last write; and finds no file
// again once the file has been moved away.
func TestStatArchive(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
_, err := delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
// With the clock stopped, the reopen debounce never passes, so
// the handle stays open after the write.
stopped := time.Now()
w := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "a"}, 0))
written := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
earlier := written.Add(-time.Hour)
require.NoError(t, os.Chtimes(path, earlier, earlier))
require.NoError(t, os.Chtimes(path+"-wal", written, written))
file, err := os.Stat(path)
require.NoError(t, err)
wal, err := os.Stat(path + "-wal")
require.NoError(t, err)
require.Positive(t, wal.Size())
got, err := delivery.StatArchive(path)
require.NoError(t, err)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = delivery.StatArchive(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
+1 -1
View File
@@ -521,7 +521,7 @@ func (h *Handlers) renderSourceDetail(
// target's stored config blob holds a credential, and it
// must never reach a template.
"Entrypoints": NewEntrypointViews(entrypoints),
"Targets": delivery.NewTargetViews(targets),
"Targets": h.targetRows(&webhook, targets),
"Events": events,
"BaseURL": baseURL,
"Stats": h.loadWebhookStats(webhook.ID, entrypoints, targets),
+90
View File
@@ -0,0 +1,90 @@
package handlers
import (
"errors"
"io/fs"
"path/filepath"
"time"
"github.com/dustin/go-humanize"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// TargetRowView is one row of the target list on a webhook's page.
type TargetRowView struct {
delivery.TargetView
// Archive is a database target's archive file, and nil for a target
// of any other type.
Archive *ArchiveFileView
}
// ArchiveFileView is what a database target's row shows about its
// archive file.
type ArchiveFileView struct {
Name string
// Note stands in for the size and the last write when there are
// none to show, and is empty when there are.
Note string
// Size is the size on disk. Written is how long ago the file was
// last written, and WrittenUTC the full time the page shows on
// hover.
Size string
Written string
WrittenUTC string
}
// targetRows projects a webhook's targets for the target list on its
// page.
func (h *Handlers) targetRows(
webhook *database.Webhook, targets []database.Target,
) []TargetRowView {
views := delivery.NewTargetViews(targets)
rows := make([]TargetRowView, len(views))
// NewTargetViews returns one view per target, in order.
for i := range views {
rows[i].TargetView = views[i]
if targets[i].Type == database.TargetTypeDatabase {
rows[i].Archive = h.archiveFileView(webhook, &targets[i])
}
}
return rows
}
// archiveFileView describes a database target's archive file from the
// file's metadata alone; the archive is never opened. The file is found
// by the name the archive writer uses, so it follows a rename of the
// webhook or the target.
func (h *Handlers) archiveFileView(
webhook *database.Webhook, target *database.Target,
) *ArchiveFileView {
path := delivery.ArchivePath(h.dbMgr, webhook, target)
view := &ArchiveFileView{Name: filepath.Base(path)}
file, err := delivery.StatArchive(path)
switch {
case errors.Is(err, fs.ErrNotExist):
view.Note = "not created yet"
case err != nil:
h.log.Error(
"failed to read archive file metadata",
"target_id", target.ID,
"error", err,
)
view.Note = "could not be read"
default:
view.Size = humanize.Bytes(uint64(file.Size)) //nolint:gosec // never negative
view.Written = humanize.Time(file.Written)
view.WrittenUTC = file.Written.UTC().Format(time.DateTime) + " UTC"
}
return view
}
+71
View File
@@ -0,0 +1,71 @@
package handlers_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/session"
)
// TestHandleSourceDetail_ShowsArchiveFile proves a database target's
// row names its archive file and says "not created yet" before the
// first write, adds the file's size and last write once it has one row,
// and says "not created yet" again once the file has been moved away.
// A target of another type shows no archive file.
func TestHandleSourceDetail_ShowsArchiveFile(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)
archive := seedTarget(t, db, wh.ID, database.TargetTypeDatabase)
seedTarget(t, db, wh.ID, database.TargetTypeLog)
path := delivery.ArchivePath(dbMgr, wh, archive)
body := renderSourceDetailPage(t, h, sess, wh.ID)
assert.Equal(t, 1, strings.Count(body, "Archive File:"))
assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:")
seedArchive(t, path, 1, 100)
file, err := os.Stat(path)
require.NoError(t, err)
body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path))
assert.NotContains(t, body, "not created yet")
assert.Regexp(t,
`Archive Size:</span>\s*<span>[1-9][0-9.]* [kM]?B</span>`, body,
)
assert.Contains(t, body,
`title="`+file.ModTime().UTC().Format(time.DateTime)+` UTC"`,
)
require.NoError(t, os.Rename(path, filepath.Join(t.TempDir(), "moved.db")))
body = renderSourceDetailPage(t, h, sess, wh.ID)
assert.Contains(t, body, filepath.Base(path))
assert.Contains(t, body, "not created yet")
assert.NotContains(t, body, "Archive Size:")
}
+1 -10
View File
@@ -308,7 +308,7 @@ func checkDataDir(dir string) error {
dbPath := filepath.Join(dir, database.MainDBFileName)
dbInfo, err := os.Stat(dbPath)
_, err = os.Stat(dbPath)
switch {
case errors.Is(err, fs.ErrNotExist):
@@ -319,15 +319,6 @@ func checkDataDir(dir string) error {
)
case err != nil:
return fmt.Errorf("checking %s: %w", dbPath, err)
case dbInfo.Size() == 0:
// SQLite opens a zero-length file as an empty database, so
// it holds no deployment either, and opening it would write
// an empty schema into it.
return fmt.Errorf(
"%w: %s is zero-length. The admin account is created by "+
"the first server start",
ErrNoDatabase, dbPath,
)
}
return nil
-27
View File
@@ -377,33 +377,6 @@ func TestMissingDatabaseCreatesNothing(t *testing.T) {
)
}
// TestZeroLengthDatabaseCreatesNothing covers a webhooker.db left at
// zero length, as a truncated copy leaves it. SQLite would open it as
// an empty database, so it is refused like a missing one and left as
// it is.
func TestZeroLengthDatabaseCreatesNothing(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
dbPath := filepath.Join(dir, database.MainDBFileName)
require.NoError(
t, os.WriteFile(dbPath, nil, database.SQLiteFilePerm),
)
code, _, stderr := run(t, newPassword+"\n", operatorUser)
require.Equal(t, exitFailure, code)
assert.Contains(t, stderr, dbPath)
entries, err := os.ReadDir(dir)
require.NoError(t, err)
assert.Len(t, entries, 1, "nothing may be created beside it")
info, err := os.Stat(dbPath)
require.NoError(t, err)
assert.Zero(t, info.Size(), "nothing may be written into it")
}
// TestUnknownUserFails states the decision: resetpw changes an
// existing account's password and never creates an account. A typo in
// the username must say so rather than quietly adding a second user.
+5 -2
View File
@@ -5,7 +5,10 @@ import (
"embed"
)
// Static holds the embedded CSS and JavaScript files for the web UI.
// Static holds the CSS and JavaScript files the web UI's pages load. They
// are named one by one so that a missing js/alpine.min.js, which make
// assets extracts and git does not track, fails the build instead of
// leaving the pages without Alpine.js.
//
//go:embed css js
//go:embed css/tailwind.css css/style.css js/app.js js/alpine.min.js
var Static embed.FS
+17
View File
@@ -179,6 +179,23 @@
<span>{{.Value}}</span>
</div>
{{end}}
{{with .Archive}}
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive File:</span>
<span class="break-all">{{.Name}}</span>
{{with .Note}}<span>({{.}})</span>{{end}}
</div>
{{if .Size}}
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Archive Size:</span>
<span>{{.Size}}</span>
</div>
<div class="text-xs text-gray-500 mt-1">
<span class="font-medium text-gray-700">Last Written:</span>
<span title="{{.WrittenUTC}}">{{.Written}}</span>
</div>
{{end}}
{{end}}
</div>
{{else}}
<div class="p-4 text-sm text-gray-500">No targets configured.</div>