Author SHA1 Message Date
sneak ed81db137e Implement the log delivery target (closes #70)
check / check (push) Successful in 4s
2026-08-07 19:57:16 +07:00
324 changed files with 4677 additions and 75887 deletions
+15 -84
View File
@@ -1,84 +1,15 @@
# .dockerignore does NOT use .gitignore semantics. Docker matches with
# moby/patternmatcher: filepath.Match plus `**`, so `*` does not cross
# `/` and an unprefixed pattern is anchored at the context root. Every
# depth-independent pattern therefore needs `**/`, or `config/.env` and
# `certs/server.key` still ship while this file reads as solved. Only
# genuinely root-anchored entries go unprefixed. Never transplant these
# into .gitignore, where `**/` is wrong.
#
# Matching is case-sensitive, so secrets use character ranges rather
# than an ALL-CAPS twin, which would still miss `Server.Key`.
#
# Extend with this repo's own host-built artifacts, written anchored:
# `/myapp`, never `**/myapp`, which also matches `cmd/myapp/` and
# deletes the package directory from the context.
# .git is sent without its config. Without a VERSION build argument the
# stage that compiles runs `git describe --tags --always` on .git, which
# does not need .git/config; that file can hold a credential, such as a
# password in a remote URL or the token the CI checkout step stores there.
# Each submodule keeps a config with the same exposure in its git directory
# under .git/modules/, nested again for a submodule's own submodules, or in
# its own .git directory when it keeps one.
# KNOWN GAP: a submodule whose name has a `config` segment (`config`,
# `deploy/config`, `config/lib`) loses its whole git directory, because
# `**/.git/modules/**/config` also matches that segment's directory
# under .git/modules/. Go's version stamping then fails the build;
# nothing leaks. Name such a submodule without that segment:
# `git submodule add --name`.
**/.git/config
**/.git/modules/**/config
# Agent scratch: one full checkout of the repo per in-flight agent.
# Anchored because it occurs once where agents run at the repo root.
# KNOWN GAP: a repo running agents in subdirectories still ships
# `services/api/.claude/` and must add its own anchored entry.
.claude
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Re-include a committed template with a negation if the
# build needs one: `!docs/example.env`.
**/*.[eE][nN][vV]
**/.[eE][nN][vV].*
**/.[eE][nN][vV][rR][cC]
# Private keys and the bundles carrying them. Public certificates
# (*.crt, *.cer) are deliberately absent: they are legitimate inputs.
**/*.[pP][eE][mM]
**/*.[kK][eE][yY]
**/*.[pP]12
**/*.[pP][fF][xX]
**/[iI][dD]_[rR][sS][aA]
**/[iI][dD]_[dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]
**/[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
**/[iI][dD]_[eE][dD]25519
**/[iI][dD]_[eE][dD]25519_[sS][kK]
# Dependencies: restored inside the image, never copied in.
**/node_modules
# OS metadata.
**/.DS_Store
**/Thumbs.db
# Editor state: never a build input, and it churns COPY.
**/*.swp
**/*.swo
**/*~
**/*.bak
**/.idea
**/.vscode
**/*.sublime-*
# This repository's own host-built artifacts: the binary `make build`
# writes, and the Alpine.js file `make assets` extracts from 3p/ (the
# build extracts its own).
/bin
/static/js/alpine.min.js
# SQLite databases, which hold the session key and webhook payloads, at
# any depth.
**/*.db
**/*.sqlite
**/*.sqlite3
.git/
bin/
*.md
LICENSE
.editorconfig
.env
.env.*
*.db
*.sqlite
*.sqlite3
.DS_Store
.idea/
.vscode/
tmp/
temp/
-3
View File
@@ -10,6 +10,3 @@ insert_final_newline = true
[Makefile]
indent_style = tab
[*.go]
indent_style = tab
+13 -7
View File
@@ -1,9 +1,15 @@
name: check
on: [push]
on:
push:
branches:
- '**'
jobs:
check:
runs-on: ubuntu-latest
steps:
# actions/checkout v4.2.2, 2026-02-22
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- run: script/cibuild
check:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
- name: Build Docker image (runs make check)
run: script/cibuild
+16 -55
View File
@@ -1,53 +1,3 @@
# OS
.DS_Store
Thumbs.db
# Editors
*.swp
*.swo
*~
*.bak
.idea/
.vscode/
*.sublime-*
# Agent scratch (worktrees of this repo, created and destroyed by
# in-flight tooling). Unanchored: .gitignore patterns already match at
# every depth, so no prefix is wanted here. This is not a .dockerignore
# entry and must not be given a `**/` prefix on the way into one.
.claude/
# Node
node_modules/
# Secrets. Unanchored like every entry above, so each matches at every
# depth. Matching is case-sensitive on Linux, so names use character
# ranges rather than a lowercase form that misses `Server.Key`.
# Environment files. `*.env` covers bare `.env` and the `prod.env`
# convention. Only the templates `example.env` and `sample.env` are
# re-included below. A repository that commits any other template adds
# its own negation after these lines, for example `!.env.example`.
*.[eE][nN][vV]
.[eE][nN][vV].*
.[eE][nN][vV][rR][cC]
!example.env
!sample.env
# Private keys and the bundles carrying them.
*.[pP][eE][mM]
*.[kK][eE][yY]
*.[pP]12
*.[pP][fF][xX]
[iI][dD]_[rR][sS][aA]
[iI][dD]_[dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]
[iI][dD]_[eE][cC][dD][sS][aA]_[sS][kK]
[iI][dD]_[eE][dD]25519
[iI][dD]_[eE][dD]25519_[sS][kK]
# This repository's own entries, after the shared content above.
# Binaries
*.exe
*.dll
@@ -65,6 +15,21 @@ bin/
# Go vendor directory
vendor/
# IDE specific files
.idea/
*.swp
*.swo
*~
.vscode/
# OS specific files
.DS_Store
Thumbs.db
# Environment and config files
.env
.env.local
# Data directory (SQLite databases)
data/
*.db
@@ -76,8 +41,4 @@ data/
# Temporary files
tmp/
temp/
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is
# what is committed.
/static/js/alpine.min.js
temp/
+14 -87
View File
@@ -1,105 +1,32 @@
version: "2"
# Config schema uses the golangci-lint v2 layout (settings live under
# linters.settings, not top-level linters-settings) so that the
# thresholds below are actually applied by golangci-lint >= v2.
run:
timeout: 5m
modules-download-mode: readonly
linters:
default: all
enable:
# Successor to the deprecated gomodguard. Named explicitly, rather than
# left to `default: all`, because it carries the module policy below.
- gomodguard_v2
disable:
# Genuinely incompatible with project patterns
- exhaustruct # Requires all struct fields
- exhaustruct_v5 # Requires all struct fields (successor to exhaustruct)
- depguard # Dependency allow/block lists
- godot # Requires comments to end with periods
- wsl # Deprecated, replaced by wsl_v5
- wrapcheck # Too verbose for internal packages
- varnamelen # Short names like db, id are idiomatic Go
# Deprecated: the warning is attached to the old name, so it is
# silenced by disabling that name, not by enabling the successor.
- wsl # Deprecated, replaced by wsl_v5
- gomodguard # Deprecated, replaced by gomodguard_v2
settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
depguard:
# Test-support code must not be compiled into the shipped binary. A
# test-support package exists to hand a test privileges the program
# itself must never have, so a file that is not a test must not import
# one. Test files, and the files inside a package whose directory name
# ends in `test`, are where that code belongs, and are exempt.
#
# The deny list below is the one part of this file a repository is
# expected to extend, and the only part it may. depguard matches an
# import path against a list of prefixes, so it cannot be told "any path
# whose last segment ends in test"; a repository's own test-support
# packages have to be named here one at a time, by full import path,
# under a module path that differs from repository to repository. Add
# them; change nothing else.
rules:
test-support:
list-mode: lax
files:
- "$all"
- "!$test"
- "!**/*test/**"
deny:
- pkg: net/http/httptest
desc: >-
Test-support code belongs in test files and in packages whose
directory name ends in test, not in the shipped binary.
- pkg: sneak.berlin/go/webhooker/internal/config/configtest
desc: test support; a file that is not a test must not import it
- pkg: sneak.berlin/go/webhooker/internal/database/databasetest
desc: test support; a file that is not a test must not import it
- pkg: sneak.berlin/go/webhooker/internal/middleware/middlewaretest
desc: test support; a file that is not a test must not import it
# Only decisions already recorded in the Go package defaults are
# listed here. Every entry matches the module path exactly.
gomodguard_v2:
blocked:
- module: github.com/rs/zerolog
recommendations:
- log/slog
reason: "Structured logging is stdlib log/slog."
# One entry per pre-fork module path, because the later releases
# are separate paths. A prefix match would be shorter but would
# also reach github.com/go-redis/redismock, the test double for
# the successor these entries recommend.
- module: github.com/go-redis/redis
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v7
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/go-redis/redis/v8
recommendations:
- github.com/redis/go-redis/v9
reason: "Pre-fork module; use the maintained go-redis v9."
- module: github.com/sergi/go-diff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "No unified diff output; use go-udiff."
- module: github.com/hexops/gotextdiff
recommendations:
- github.com/aymanbagabas/go-udiff
reason: "Unmaintained fork; use go-udiff."
linters-settings:
lll:
line-length: 88
funlen:
lines: 80
statements: 50
cyclop:
max-complexity: 15
dupl:
threshold: 100
issues:
exclude-use-default: false
max-issues-per-linter: 0
max-same-issues: 0
-2
View File
@@ -1,2 +0,0 @@
node_modules/
yarn.lock
-4
View File
@@ -1,4 +0,0 @@
{
"tabWidth": 4,
"proseWrap": "always"
}
-3
View File
@@ -1,3 +0,0 @@
# Install into node_modules/: the Dockerfile's lint and Markdown stages run
# ESLint and prettier from node_modules/.bin.
nodeLinker: node-modules
Binary file not shown.
+27 -217
View File
@@ -1,82 +1,10 @@
# Stylesheet stages. static/css/tailwind.css is generated, by this pinned
# tailwindcss, from static/css/input.css and the files its @source lines
# name. `make css` (script/css) writes it out from the css-output stage.
# The css-check stage fails when the committed file differs from what is
# generated; `make check` runs it, and so does the build stage below.
#
# tailwindcss v4.2.1 standalone CLI, released 2026-02-23: one binary per
# architecture, each pinned by its sha256 from the release's sha256sums.txt.
# debian:bookworm-slim, 2026-10-02: the binary needs glibc.
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-amd64
ADD --checksum=sha256:39e8d4e24b3c83b0a6e69e100a972fbc75d5fef8dce47b3ddac3cf92dea81fe3 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-x64 /usr/local/bin/tailwindcss
# Lint stage
# golangci/golangci-lint:v2.11.3 (Debian-based), 2026-03-17
# Using Debian-based image because mattn/go-sqlite3 (CGO) does not
# compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.11.3@sha256:e838e8ab68aaefe83e2408691510867ade9329c0e0b895a3fb35eb93d1c2a4ba AS lint
FROM debian:bookworm-slim@sha256:3783cc01769c7b2b1b83a5c5ad96c815348e28ed7da68e2e3687004faa906251 AS tailwind-arm64
ADD --checksum=sha256:d87e6486bb3f70b04ef1dcaacc4ee6548a5a15fbf521b31bc24d2c774f68a951 --chmod=755 \
https://github.com/tailwindlabs/tailwindcss/releases/download/v4.2.1/tailwindcss-linux-arm64 /usr/local/bin/tailwindcss
# TARGETARCH, set by docker, is the architecture being built for.
FROM tailwind-${TARGETARCH} AS css
WORKDIR /src
COPY . .
RUN tailwindcss -i static/css/input.css -o /out/tailwind.css --minify
FROM scratch AS css-output
COPY --from=css /out/tailwind.css /
# Both files are split after each "}", one rule per line, so that when they
# differ the diff shows the rules that differ.
FROM css AS css-check
RUN sed 's/}/}\n/g' static/css/tailwind.css > /tmp/committed.css \
&& sed 's/}/}\n/g' /out/tailwind.css > /tmp/generated.css \
&& diff -U0 /tmp/committed.css /tmp/generated.css || { \
echo "static/css/tailwind.css is not what make css generates; run make css" >&2; \
exit 1; \
}
# JavaScript lint stages: ESLint, at the version package.json and yarn.lock
# pin, checks static/js/ against eslint.config.mjs. js-deps installs it, and
# prettier for the Markdown stages below. The lint phase below runs js-lint.
#
# The image's own corepack runs the yarn that package.json's packageManager
# field names, yarn 4.18.1 (released 2026-09-24), and checks it against the
# hash there. The image also ships yarn 1, which `corepack enable yarn`
# replaces.
# node:24.21.0-alpine (LTS), 2026-09-18
FROM node:24.21.0-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1 AS js-deps
WORKDIR /src
COPY package.json yarn.lock .yarnrc.yml ./
RUN corepack enable yarn && yarn install --immutable --mode=skip-build
FROM js-deps AS js-lint
COPY . .
RUN --network=none node_modules/.bin/eslint static/js
# Markdown stages: prettier, at the version package.json and yarn.lock pin,
# formats every Markdown file in the tree with the settings in .prettierrc.
# `make fmt` (script/fmt) writes the formatted files out from markdown-output.
# markdown-check fails on any file prettier would change; `make fmt-check`
# runs it, and so does the build stage below.
FROM js-deps AS markdown
COPY . .
RUN --network=none node_modules/.bin/prettier --write '**/*.md' \
&& mkdir /out \
&& find . -name '*.md' ! -path './node_modules/*' -exec cp -p --parents {} /out \;
FROM scratch AS markdown-output
COPY --from=markdown /out /
FROM js-deps AS markdown-check
COPY . .
RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# Lint phase: the Go formatting check and golangci-lint over the Go code,
# and ESLint over static/js/ through the copy from js-lint at the end.
# `make lint` (script/lint) builds this stage alone; the build stage below
# depends on it.
#
# golangci/golangci-lint:v2.14.0 (Debian-based), 2026-09-24
FROM golangci/golangci-lint:v2.14.0@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /src
@@ -84,102 +12,23 @@ WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# gofmt and golangci-lint are invoked directly rather than through `make
# fmt-check` and `make lint`, which are themselves docker builds and would
# need a docker daemon inside this one. The Markdown half of `make
# fmt-check` is the markdown-check stage above.
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
# 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
# The golangci-lint steps run with --network=none. `golangci-lint config
# verify` is documented as fetching its JSON schema over HTTPS; this pinned
# image resolves the schema without any network, and --network=none enforces
# that. It also proves no linter reaches out at analysis time.
#
# `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
# --build-tags browser also lints the browser test, which is built only with
# that tag (make test-browser).
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# Nothing is wanted from js-lint; the copy is what makes this phase run it.
COPY --from=js-lint /src/yarn.lock /dev/null
# Test phase. -race needs cgo and so a C compiler, which the Debian Go image
# ships and the alpine one does not. `make test` (script/test) builds this
# stage alone; the build stage below depends on it.
#
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/.
RUN script/assets
# -timeout applies to each package on its own, so 90s has only to clear the
# slowest one. -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.
#
# The first run has no -v: go test then prints one result line per package,
# with its coverage, and for a package that fails, everything its tests
# wrote. Verbose output from the whole suite passes the 2 MiB at which the
# Docker build cuts off a step's log, so on a failure only the tests that
# failed run again, with -v. go test reports a failed test as a line starting
# "--- FAIL: TestName" (a failed subtest's line is indented, and reruns with
# its parent) and a failed package as "FAIL<tab>package/path<tab>...". A
# failure that names no test, such as a build error or a timeout, is already
# shown in full, so there is nothing to rerun. The step fails after the rerun
# whatever its result: the first run already showed the suite is broken.
#
# TMPDIR, where the tests keep their SQLite databases, is a tmpfs: SQLite
# waits for the disk at every commit, and on a busy host that waiting was
# about 40% of the slowest package's run time. GOTMPDIR keeps go's own
# build files, the test binaries among them, on disk.
#
# bash with pipefail, so that the first run's status is go test's, not tee's.
SHELL ["/bin/bash", "-o", "pipefail", "-c"]
RUN --mount=type=tmpfs,target=/tmp/tests,size=512m \
export TMPDIR=/tmp/tests GOTMPDIR=/tmp; \
go test -race -cover -p 4 -parallel 8 -timeout 90s ./... 2>&1 | tee /tmp/go-test.log && exit 0; \
tests="$(awk '/^--- FAIL: / { print $3 }' /tmp/go-test.log | paste -s -d '|' -)"; \
packages="$(awk '/^FAIL\t/ { print $2 }' /tmp/go-test.log)"; \
if [ -n "$tests" ]; then \
echo "--- Rerunning the failed tests with -v for details ---"; \
go test -race -v -p 4 -parallel 8 -timeout 90s -run "^($tests)\$" $packages; \
fi; \
exit 1
# Run formatting check and linter
RUN make fmt-check
RUN make lint
# Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17
# The image ships git and make, which the version step below uses.
# Using Debian-based image because gorm.io/driver/sqlite pulls in
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Nothing is wanted from the lint and test phases or from the stylesheet and
# Markdown checks; the copies are what make BuildKit build them first, so
# this stage cannot run unless they all passed.
# Depend on lint stage passing
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null
# A build context sent as a tar archive keeps its files' owners, and git
# refuses to read a checkout owned by another user. Trust this one
# whoever owns it.
RUN git config --system --add safe.directory /build
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /build
@@ -187,38 +36,25 @@ WORKDIR /build
COPY go.mod go.sum ./
RUN go mod download
# Copy source code
COPY . .
# Version stamped into the binary: the VERSION build arg when one is
# given, otherwise what script/version derives from the .git the build
# context carries, so any `docker build .` of a clone stamps its commit.
# With neither, as from a source tarball, it is "unknown".
ARG VERSION
# Run tests and build
RUN make test
RUN make build
# A context that carries .git must not stamp an empty version, "dev" or
# "unknown": that means git is missing here or could not read the
# checkout, and the image could not be traced back to its commit.
RUN version="$(make version VERSION="$VERSION")"; \
if [ -e .git ]; then \
case "$version" in ""|dev|unknown) \
echo "version is '$version' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi
# Builds through the Makefile's build target, which runs script/assets
# (Alpine.js, extracted from its tarball in 3p/) first. The target builds
# with cgo off, so the binary is statically linked and runs on the Alpine
# runtime image below as it is.
RUN make build VERSION="$VERSION"
# Rebuild with static linking for Alpine runtime.
# make build already verified compilation.
# The CGO binary from `make build` is dynamically linked against glibc,
# which doesn't exist on Alpine (musl). Rebuild with static linking so
# the binary runs on Alpine without glibc.
RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
# Runtime stage
# alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app
# as webhooker with it.
RUN apk --no-cache add ca-certificates su-exec=0.2-r3
RUN apk --no-cache add ca-certificates
# Create non-root user
RUN addgroup -g 1000 -S webhooker && \
@@ -229,43 +65,17 @@ WORKDIR /app
# Copy binary from builder
COPY --from=builder /build/bin/webhooker /app/webhooker
# Not under /app, which belongs to webhooker: this script runs as root.
COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Create data directory for all SQLite databases (main app DB +
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
RUN mkdir -p /var/lib/webhooker
RUN chown -R webhooker:webhooker /app /var/lib/webhooker
# No USER: the entrypoint starts as root to make the data directory
# webhooker's, then runs the app as webhooker.
USER webhooker
EXPOSE 8080
# The binary defaults BIND_ADDRESS to 127.0.0.1, which is right for a
# bare host: the cleartext listener serves the admin UI and the
# unauthenticated receiver, so it must not appear on every interface
# of a machine that configured nothing. A container is the other case.
# Its network namespace is already the isolation boundary, so binding
# every address inside it exposes nothing; what decides exposure is
# the publish flag, and `-p 127.0.0.1:8080:8080` is the operator's
# control there. Shipping the image on loopback would buy no security
# and would make the process unreachable through its own published
# port.
ENV BIND_ADDRESS=0.0.0.0
# The probe requests the container's own address, not loopback, so a
# listener that answers only on loopback (an empty or loopback
# BIND_ADDRESS) fails it. On a network with IPv4, hostname -i prints the
# container's IPv4 address, which 0.0.0.0 and :: both answer. On an
# IPv6-only network it prints none, and the probe requests the IPv6
# address /etc/hosts lists under the container's hostname instead, which
# only :: answers.
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD addr="$(hostname -i 2>/dev/null | cut -d ' ' -f 1)"; \
[ -n "$addr" ] || addr="[$(getent hosts "$(hostname)" | cut -d ' ' -f 1)]"; \
wget --no-verbose --tries=1 --spider "http://$addr:8080/.well-known/healthcheck" || exit 1
CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
CMD ["/app/webhooker"]
-29
View File
@@ -1,29 +0,0 @@
# Browser test image, built by script/test-browser (make test-browser). It
# runs the test in internal/server that loads the pages in a headless
# browser under the real Content-Security-Policy. That test is built only
# with the browser build tag, so make test leaves it out. Here the browser
# comes from a digest-pinned image, and if it is missing the test fails.
# golang:1.26.1-bookworm, 2026-03-17: the builder stage's image in Dockerfile.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
# The test binary embeds the templates and static files, so the browser
# stage needs nothing else. -p 4 keeps the compile's memory down, as in
# the test phase of Dockerfile.
RUN make assets && go test -c -p 4 -tags browser -o /browser.test ./internal/server
# chromedp/headless-shell:151.0.7922.109 (Debian trixie), 2026-08-11. The
# browser is on PATH as headless-shell, where the test's browser library
# looks for it.
FROM chromedp/headless-shell:151.0.7922.109@sha256:2d349b544a1ea6b5b5fd7c0fe99215ff662339c57407ee2e8c0a11af93516b04 AS browser
COPY --from=build /browser.test /browser.test
RUN /browser.test -test.v -test.timeout 90s -test.run '^TestAlpineRunsUnderTheSecurityPolicy$'
+5 -34
View File
@@ -1,36 +1,17 @@
.PHONY: bootstrap setup assets test test-browser lint fmt fmt-check check build run dev deps docker clean hooks css css-check version
.PHONY: bootstrap setup test lint fmt fmt-check check build run dev deps docker clean hooks css
# Default target
.DEFAULT_GOAL := check
# Version stamped into the binary. Derived from git by script/version;
# override it (`make build VERSION=v1.2.3`) to stamp a given value, which is
# how the Dockerfile passes its build arg in.
VERSION ?= $(shell script/version)
# An empty override (`make build VERSION=`, or the Dockerfile's `make build
# VERSION="$VERSION"` when no VERSION build arg was given) means unset,
# exactly as it does in script/version -- stamping "" would leave the binary
# reporting no version and the footer back on its "dev" fallback. `override`
# is required: a plain assignment loses to the command-line definition it
# exists to correct.
override VERSION := $(or $(strip $(VERSION)),$(shell script/version))
bootstrap:
@script/bootstrap
setup:
@script/setup
assets:
@script/assets
test:
@script/test
test-browser:
@script/test-browser
lint:
@script/lint
@@ -43,26 +24,19 @@ fmt-check:
check:
@script/check
# Built with cgo off, -trimpath and -s -w, as the Go Dockerfile in
# REPO_POLICIES.md does: the binary carries no build paths, symbol table or
# debug information, and on Linux, the image's included, it is statically
# linked.
build: assets
CGO_ENABLED=0 go build -trimpath -ldflags '-s -w -X main.version=$(VERSION)' -o bin/webhooker ./cmd/webhooker
build:
go build -o bin/webhooker ./cmd/webhooker
run: build
./bin/webhooker
dev: assets
dev:
go run ./cmd/webhooker
deps:
go mod download
go mod tidy
version:
@echo $(VERSION)
docker:
@script/docker
@@ -73,7 +47,4 @@ hooks:
@script/install-precommit
css:
@script/css
css-check:
@script/css-check
tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
+512 -3107
View File
File diff suppressed because it is too large Load Diff
+84 -355
View File
@@ -1,6 +1,6 @@
---
title: Repository Policies
last_modified: 2026-10-04
last_modified: 2026-07-06
---
This document covers repository structure, tooling, and workflow standards. Code
@@ -60,28 +60,17 @@ style conventions are in separate documents:
prerequisite since nvm requires bash. yarn is then pinned via
`corepack prepare yarn@<version> --activate`. Never install "latest" or "lts";
always exact versions. `script/cibuild` runs the CI build: it changes to the
repo root, runs `script/bootstrap`, runs `script/check`, and builds the image
with the version; the Gitea workflow calls it. **`script/cibuild` runs
`script/bootstrap` first**, because the workflow checks out the repo and runs
nothing else, while `script/fmt-check` runs the formatter on the host: on a
pristine checkout with nothing installed the run dies there, after the
containerised gates have passed. **The bootstrap alone is not enough**:
`script/bootstrap` installs node and yarn under nvm and leaves neither on the
`PATH` of the shell that called it, so a bare `yarn` still exits 127. The host
entrypoints that need yarn — `script/fmt` and `script/fmt-check` — therefore
source nvm for the pinned node version before invoking it, exactly as
`script/bootstrap`'s own install step does. A runner carrying nothing but
docker and git then gets through `script/check`. Four further scripts are our
own extensions to the standard: `script/check` runs `script/test`,
`script/lint` and `script/fmt-check`; `script/precommit` is what the git
pre-commit hook runs, and it calls `script/check`; `script/install-precommit`
installs the git pre-commit hook (the `make hooks` target shims to it); and
`script/projectname` (literally that filename) simply outputs the project's
name. Scripts that need the name call `script/projectname` — e.g.
`script/docker` assembles its image tag from it — so those scripts stay
byte-identical across all repos. Repo-type-specific pre-commit extras (e.g.
`go mod tidy` verification in Go repos) belong in `script/precommit`, not in
the hook itself. Model scripts are at
repo root and runs `docker build .`; the Gitea workflow calls it. Four further
scripts are our own extensions to the standard: `script/check` runs
`script/test`, `script/lint`, and `script/fmt-check`; `script/precommit` is
what the git pre-commit hook runs, and it calls `script/check`;
`script/install-precommit` installs the git pre-commit hook (the `make hooks`
target shims to it); and `script/projectname` (literally that filename) simply
outputs the project's name. Scripts that need the name call
`script/projectname` — e.g. `script/docker` assembles its image tag from it —
so those scripts stay byte-identical across all repos. Repo-type-specific
pre-commit extras (e.g. `go mod tidy` verification in Go repos) belong in
`script/precommit`, not in the hook itself. Model scripts are at
`https://git.eeqj.de/sneak/prompts/raw/branch/main/script/<name>`. The README
must document the provided scripts in an **Entrypoints** section (see the
README requirements below).
@@ -100,198 +89,87 @@ style conventions are in separate documents:
contributor should be able to understand the entire development workflow by
reading the Makefile.
- Every repo should have a `Dockerfile`, and it carries the repo's gates: a
`lint` phase and a `test` phase, with the final stage depending on both so the
image cannot be built unless they pass. For non-server repos the final stage
brings up a development environment; for server repos it is the runtime image.
The gate phases and the build stage start from their pinned base images and
install what those images lack either inline, as the canonical Go `Dockerfile`
below does for `git`, or by running `script/bootstrap`, as the `prompts`
repo's own `Dockerfile` does for its yarn packages. The development
environment stage installs development prerequisites by running
`script/bootstrap` rather than duplicating its installs inline. A stage that
runs `script/bootstrap` COPYs `script/` and the dependency manifests
(`package.json` + `yarn.lock`, `go.mod` + `go.sum`, etc.) before running it.
- Every repo should have a `Dockerfile`. All Dockerfiles must run `make check`
as a build step so the build fails if the branch is not green. For non-server
repos, the Dockerfile should bring up a development environment and run
`make check`. For server repos, `make check` should run as an early build
stage before the final image is assembled. Dockerfiles install development
prerequisites by running `script/bootstrap` rather than duplicating installs
inline; COPY `script/` and the dependency manifests (`package.json` +
`yarn.lock`, `go.mod` + `go.sum`, etc.) before running it so the bootstrap
layer stays cached until dependencies change.
- **Linting and testing run in Docker, as phases of the `Dockerfile`.** There is
no separate lint file. `script/lint` and `script/test` each build one phase
and nothing else:
- **Dockerfiles must use a separate lint stage for fail-fast feedback.** Go
repos use a multistage build where linting runs in an independent stage based
on the `golangci/golangci-lint` image (pinned by hash). This stage runs
`make fmt-check` and `make lint` before the full build begins. The build stage
then declares an explicit dependency on the lint stage via
`COPY --from=lint /src/go.sum /dev/null`, which forces BuildKit to complete
linting before proceeding to compilation and tests. This ensures lint failures
surface in seconds rather than minutes, without blocking on dependency
download or compilation in the build stage.
```sh
docker build --no-cache --target lint -t "$(script/projectname)-lint" .
docker build --no-cache --target test -t "$(script/projectname)-test" .
```
**A stage that is not the last one in the file is built only when the final
stage's chain depends on it, or when `--target` names it.** That is why the
two gates are always invoked by name here, and why the final stage carries a
`COPY --from=` of a harmless file from each of them: without that edge a
plain `docker build .` builds the last stage alone and exits 0 having linted
and tested nothing.
**Every `docker build` in `script/` is tagged**, here and in
`script/cibuild` and `script/docker`. An untagged build leaves a dangling
image behind on every invocation, on every developer host and every CI
runner; a tagged one replaces the previous image.
Inside a phase the tool is invoked directly — `golangci-lint`, `go test`,
`eslint`, `prettier` — never through `make lint` or `script/test`, which are
themselves a `docker build` and would recurse into a daemon that does not
exist in a build step. Formatting is the exception and stays on the host:
`script/fmt` writes the working tree, and `script/fmt-check` is its
read-only twin.
**No lint verdict may come from a host invocation of the linter.** On a
shared host golangci-lint reads a result cache keyed on file content rather
than location, so a second checkout of the same content is served the first
one's findings, and a host-global lock in `$TMPDIR` makes concurrent runs
exit non-zero with `parallel golangci-lint is running` — a status a caller
cannot tell from real findings. Both have produced wrong verdicts in this
org, in both directions. A container has its own cache, its own `TMPDIR` and
a digest-pinned binary, so neither is reachable.
- **Any build that runs checks is built with `--no-cache`.** Docker invalidates
a `COPY` layer only when the copied content changes, so on an unchanged tree
the check `RUN` is served from cache, nothing executes, and the build still
exits 0. Every `docker build` in `script/` therefore passes `--no-cache`:
`script/lint`, `script/test`, `script/cibuild` and `script/docker` are the
four, and there is no fifth — `script/check` runs the two gate phases and
`script/fmt-check`, and builds no image of its own. A bare `docker build .` is
not evidence that anything ran: a sub-second build reporting success is a
cache hit, not a result. Never invalidate by pruning — `docker builder prune`
and friends destroy a build cache shared with every other build on the host.
When a check is added or changed, prove it works by planting a defect it must
catch and watching the run fail on it, then revert the defect. A green run
alone shows neither that the check ran nor that it covers what it should.
- **The gate phases are separate stages, and the build stage depends on both.**
The lint phase is based on the `golangci/golangci-lint` image (pinned by
hash), so lint failures surface in seconds rather than after a full compile,
and the test phase is based on the Debian Go image. The canonical Go repo
`Dockerfile`:
The standard pattern for a Go repo Dockerfile is:
```dockerfile
# Lint phase
# Lint stage — fast feedback on formatting and lint issues
# golangci/golangci-lint:v2.x.x, YYYY-MM-DD
FROM golangci/golangci-lint@sha256:... AS lint
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN golangci-lint run --config .golangci.yml ./...
RUN make fmt-check
RUN make lint
# Test phase. -race needs cgo and so a C compiler, which the Debian Go
# image ships and the alpine one does not.
# golang:1.x, YYYY-MM-DD
FROM golang@sha256:... AS test
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN go test -timeout 90s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; }
# Build stage. Nothing is wanted from either phase above; the copies
# are what make BuildKit build them first, so this stage cannot run
# unless lint and test passed.
# Build stage
# golang:1.x-alpine, YYYY-MM-DD
FROM golang@sha256:... AS builder
COPY --from=lint /src/go.sum /dev/null
COPY --from=test /src/go.sum /dev/null
RUN apk add --no-cache git
# A tar-stream context keeps the sender's file owners, which git refuses.
RUN git config --system --add safe.directory /src
WORKDIR /src
# Force BuildKit to run the lint stage before proceeding
COPY --from=lint /src/go.sum /dev/null
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN make test
# The VERSION build arg when one is given, otherwise
# `git describe --tags --always` on the .git in the build context. With
# .git present, a version that is still empty, dev or unknown fails the
# build: git is missing or could not read the checkout.
ARG VERSION
RUN VERSION="${VERSION:-$(git describe --tags --always)}"; \
if [ -e .git ]; then \
case "$VERSION" in ""|dev|unknown) \
echo "version is '$VERSION' although .git is present" >&2; \
exit 1 ;; \
esac; \
fi; \
CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
ARG VERSION=dev
RUN CGO_ENABLED=0 go build -trimpath \
-ldflags="-s -w -X main.Version=${VERSION}" \
-o /app ./cmd/app/
# Runtime stage, and the last one
# Runtime stage
FROM alpine@sha256:...
COPY --from=builder /app /usr/local/bin/app
ENTRYPOINT ["app"]
```
Key points:
- The lint phase uses the `golangci/golangci-lint` image directly (it has
both Go and the linter), so nothing needs installing.
- `COPY --from=<phase> /src/go.sum /dev/null` is a no-op copy whose only
purpose is the ordering edge. BuildKit runs stages in parallel by default,
and a stage nothing depends on is not built at all, so without these two
lines a red gate would not fail the build.
- Keep the runtime stage last, and if you add a stage after it, give it the
same two copies. A plain `docker build .` builds the last stage's chain
and nothing else.
- The lint stage uses the `golangci/golangci-lint` image directly (it
includes both Go and the linter), so there is no need to install the
linter separately.
- `COPY --from=lint /src/go.sum /dev/null` is a no-op file copy that creates
a stage dependency. BuildKit runs stages in parallel by default; without
this line, the build stage would not wait for lint to finish and a lint
failure might not fail the overall build.
- If the project uses `//go:embed` directives that reference build artifacts
(e.g. a web frontend compiled in a separate stage), the lint phase must
(e.g. a web frontend compiled in a separate stage), the lint stage must
create placeholder files so the embed directives resolve. Example:
`RUN mkdir -p web/dist && touch web/dist/index.html web/dist/style.css`.
- If the project requires CGO or system libraries for linting, install them
in the lint phase. The `golangci/golangci-lint` image is Debian-based and
has no `apk`, so install with `apt-get` under the Debian package name
(`libvips-dev`, where alpine says `vips-dev`), and delete the package
lists in the same `RUN`, so the layer does not keep them:
```dockerfile
RUN apt-get update \
&& apt-get install -y --no-install-recommends libvips-dev \
&& rm -rf /var/lib/apt/lists/*
```
- `.dockerignore` lets `.git` into the build context. It keeps out every git
`config` at any depth (`**/.git/config`, `**/.git/modules/**/config`): the
repository's own, each submodule's under `.git/modules/`, and that of a
submodule keeping its own `.git` directory. `git describe` does not need
them, and each can hold a credential: a password in a remote URL, or the
token the CI checkout step stores there. A submodule whose name has a
`config` segment (`config`, `deploy/config`, `config/lib`) loses its whole
git directory to `**/.git/modules/**/config`, and Go's version stamping
then fails the build: give it a name without that segment
(`git submodule add --name`). The stage that compiles has `git` (the
Debian Go image has it; an alpine one needs `apk add --no-cache git`) and
takes the version from the `VERSION` build argument when one is given,
otherwise from `git describe --tags --always`. That gives the tag on a
tagged commit; on a later commit, the tag, the number of commits since it
and the short commit (`v1.2.3-4-gabc1234`); and the short commit when no
tag is reachable. The stage that compiles also marks its working directory
safe for git (`git config --system --add safe.directory /src`): a context
sent as a tar stream keeps the sender's file owners, and git refuses a
checkout owned by another user, so the version would come out empty.
`ARG VERSION` has no default, and the build fails if the context carries
`.git` and the version still comes out empty, `dev` or `unknown`. A plain
`docker build .` with no build arguments must succeed; a Dockerfile that
refuses an empty build argument drops that refusal and keeps the argument.
The lint stage should not depend on the actual build output — it exists to
fail fast.
- If the project requires CGO or system libraries for linting (e.g.
`vips-dev`), install them in the lint stage with `apk add`.
- The build stage runs `make test` after compilation setup. Tests run in the
build stage, not the lint stage, because they may require compiled
artifacts or heavier dependencies.
- Every repo should have a Gitea Actions workflow (`.gitea/workflows/`) that
runs `script/cibuild` on push, and checks out the repo as its only other step.
That script bootstraps, runs the gate phases, and then builds the image, so a
successful run means every check passed; a bare `docker build .` does not
carry the same guarantee, because its gate phases may come from the cache. The
image build is uncached and so runs the gate phases a second time. That is the
price of the rule above, and it is worth paying: the image that ships is built
from a run of its own gates rather than from a cache entry. A separate
workflow limited to `main` by a `branches` list under `on: push` cannot be
checked by review: to try a change to it, add the feature branch to that list
and push, then remove the branch from the list again before merging. Keep any
job in it that publishes behind `if: github.ref_name == 'main'`, so the run
from the feature branch publishes nothing.
runs `script/cibuild` (which runs `docker build .`) on push. Since the
Dockerfile already runs `make check`, a successful build implies all checks
pass.
- Use platform-standard formatters: `black` for Python, `prettier` for
JS/CSS/Markdown/HTML, `go fmt` for Go. Always use default configuration with
@@ -311,21 +189,14 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a
suite that exceeds it fails. Under 20 seconds is the target. A suite between
20 and 60 seconds is still green, but the overage must be filed as an
improvement bug against that repo. Add a 90-second timeout to the test
invocation (`go test -timeout 90s`). The backstop deliberately sits above the
hard cap so that it catches a genuinely hung test rather than a merely slow
one.
- `make test` must complete in under 20 seconds. Add a 30-second timeout in the
Makefile.
- **The test command should use the conditional verbose rerun pattern.** Run
tests without `-v` (verbose) first. If tests fail, automatically rerun with
`-v` to show full output. This keeps CI logs and `docker build` output clean
on success (just package/suite summaries) while providing full diagnostic
detail on failure (every test case, every assertion). The command lives in the
`test` phase of the `Dockerfile`, since `script/test` builds that phase; the
Makefile form below is the same pattern for any repo-local invocation:
- **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
show full output. This keeps CI logs and `docker build` output clean on
success (just package/suite summaries) while providing full diagnostic detail
on failure (every test case, every assertion). The general shell pattern:
```makefile
test:
@@ -338,26 +209,11 @@ style conventions are in separate documents:
```makefile
test:
@go test -count=1 -timeout 90s -race -cover ./... || \
@go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \
go test -count=1 -timeout 90s -race -v ./...; exit 1; }
go test -timeout 30s -race -v ./...; exit 1; }
```
`-count=1` is required on both invocations: it defeats Go's test _result_
cache, so neither run can report a stored pass in place of running the
tests. It leaves the build cache alone, so it costs the runtime of the suite
and no recompilation.
That cache is Go's own, separate from Docker's layer cache. Go stores a
passing result in its cache directory (`GOCACHE`), and when the same tests
run again on unchanged code it prints that result, marked `(cached)`,
without running them. That matters on a developer's machine, where this
target runs and the directory lasts from one run to the next. The `test`
phase of the `Dockerfile` needs no `-count=1`: its base image holds no
result for this repo's tests and nothing before its `go test` step runs a
test, so there is nothing to replay. `--no-cache` (above) is what makes that
step run on an unchanged tree.
Python example:
```makefile
@@ -383,84 +239,10 @@ style conventions are in separate documents:
must be in `.gitignore`. No exceptions.
- `.gitignore` should be comprehensive from the start: OS files (`.DS_Store`),
editor files (`.swp`, `*~`), in-repo agent scratch directories (`.claude/`),
language build artifacts, and `node_modules/`. Fetch the standard `.gitignore`
from `https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when
setting up a new repo. These patterns are written to `.gitignore`'s own
semantics, in which an unanchored pattern already matches at every depth; they
are not a `.dockerignore` and must not be transplanted into one unmodified.
- **`.dockerignore` does not use `.gitignore` semantics, and copying patterns
across unmodified leaves secrets in the build context.** Docker matches with
`moby/patternmatcher`: `filepath.Match` semantics plus a `**` extension, so
`*` does not cross `/` and a pattern without a leading `**/` is anchored at
the build-context root. A `.dockerignore` listing `.env`, `*.pem` and `*.key`
therefore excludes only the copies at the repository root, while `config/.env`
and `certs/server.key` still reach the context and can land in an image layer
— which is more dangerous than a short file with no secret patterns at all,
because it reads as solved and stops anyone looking. Give every
depth-independent pattern the `**/` prefix and leave only genuinely
root-anchored entries unprefixed: `.claude`, and the repo's own host-built
binary, written `/myapp` and never `**/myapp`, which would also match
`cmd/myapp/` and delete the package directory from the context. Matching is
case-sensitive, and an ALL-CAPS twin per pattern still misses `Server.Key`, so
secret names use character ranges — `**/*.[kK][eE][yY]`, `**/*.[pP][eE][mM]`,
and likewise for `.envrc` and the extensionless SSH keys. Where such a pattern
also catches something the build needs, re-include it with a negation
(`!docs/example.env`); deleting the pattern reopens the exposure for every
other file it covers. Fetch the standard `.dockerignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.dockerignore` and extend
it with the repo's own artifacts.
- **In-repo agent scratch belongs in both files, written to each file's own
semantics.** `.claude/` holds one worktree per in-flight agent — an entire
additional checkout of the repo — so under `COPY . .` the build context
inflates by a multiple of the repo and another session's unreviewed work can
be copied into an image layer. In `.gitignore` the entry is `.claude/`,
unanchored. In `.dockerignore` it is `.claude`, anchored and with **no** `**/`
prefix, because the prefixed form would also delete any nested directory of
that name from the build. Anchoring carries a known gap that the canonical
`.dockerignore` states in its own comment, since consuming repos receive the
file and not the tracker: the directory is created in the agent's working
directory, so a repo running agents in subdirectories still ships
`services/api/.claude/` and must add its own anchored entry there.
- **A plain `docker build .` of a clone stamps the version that
`git describe --tags --always` gives**, derived from the `.git` in the build
context as the canonical `Dockerfile` above shows. Without its failure check,
a missing `git` or an unreadable checkout would leave `-X main.Version=` empty
and the build would still exit 0. `script/docker` and `script/cibuild` pass
the version they compute on the host; it takes precedence. They do this
byte-identically across repos:
```sh
# Own line: a failing command substitution inside an argument does not
# trip `set -e`, so the inline form degrades to an empty constant.
version="$(git describe --tags --always --dirty 2>/dev/null || true)"
[ -n "$version" ] || version="unknown"
docker build --no-cache \
--build-arg VERSION="$version" \
-t "$(script/projectname)" .
```
`--always` makes an untagged repo yield an abbreviated commit hash rather
than failing, and the `[ -n "$version" ]` line is the single place the
fallback is applied — a live check that fires on a build from an export with
no `.git` and on a repository with no commits yet. Do not fold it into the
substitution as `|| echo unknown`, which makes the guard unreachable. The
Dockerfile's side is `ARG VERSION` in the stage that compiles, declared
there because `ARG` is stage-scoped; passing `VERSION` to a repo whose
Dockerfile declares no such `ARG` is ignored and costs nothing, which is why
the scripts stay byte-identical. One consequence for CI: the standard
checkout action clones shallow and fetches no tags, so a repo that embeds a
tag-derived version must set `fetch-depth: 0` on its checkout step.
- **Verify `.dockerignore` by enumerating the image, not by reading the
patterns.** Plant files at the root _and_ at least two directories deep, build
a probe image that does `COPY . .`, and list what actually landed
(`docker run --rm --entrypoint find IMAGE /app`). The `transferring context`
size is not a substitute: a nested secret is a few bytes, and BuildKit
transfers only the delta from the previous build.
editor files (`.swp`, `*~`), language build artifacts, and `node_modules/`.
Fetch the standard `.gitignore` from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.gitignore` when setting up
a new repo.
- **No build artifacts in version control.** Code-derived data (compiled
bundles, minified output, generated assets) must never be committed to the
@@ -476,56 +258,9 @@ style conventions are in separate documents:
- Make all changes on a feature branch. You can do whatever you want on a
feature branch.
- `.golangci.yml` is standardized. The vendored copy in a consuming repo must
_NEVER_ be modified by an agent: fetch it from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml` and keep it
byte-identical, so that no repo can quietly loosen its own linting. Linter
configuration changes are made to the canonical copy in the `prompts` repo and
reach consuming repos by re-vendoring; an agent may open a PR against
canonical, which only the user merges. One list is exempt from byte-identity,
because it cannot be written once for every repo: the `deny` list of the
`test-support` depguard rule, where a repo names its own test-support packages
by full import path. A repo adds entries there and changes nothing else, and a
re-vendor carries its entries forward. The canonical golangci-lint version is
v2.14.0 (released 2026-09-24), pinned as the digest of the lint phase's base
image
(`golangci/golangci-lint@sha256:ad862ba6b3798cbe0fd9fd7408d498fd74fbd2623a92406b2fd3898faf0bf98f`,
which reports `2.14.0 built with go1.27.0 from 114493f9`). A module's `go`
directive must not name a newer Go minor version than the one golangci-lint
was built with, or golangci-lint refuses to lint it: this release lints
`go 1.27.1` but not `go 1.28`. That digest is the only pin, since no repo
installs golangci-lint on the host. A repo sets the lint phase digest to the
one named here and re-vendors `.golangci.yml` in the same commit, whichever of
the two prompted the change: the canonical copy can name linters that an older
golangci-lint rejects, and a newer golangci-lint can add linters that
`default: all` switches on until the canonical copy disables them.
- **`script/bootstrap` installs a pinned tool by comparing versions, never by
testing presence.** An `if ! command -v <tool>; then install; fi` guard tests
`PATH` only, so on an already-provisioned machine the pin is inert and a
version bump is a silent no-op — while the Dockerfile, installing into a clean
image, gets the pinned version, so a local `make check` and `make docker` can
disagree about what the tool even is. The canonical form:
- compares the installed version against the pin over the **whole** version
token; a parser that stops at the first `-` reports `2.12.2` for a host
running `2.12.2-rc1` and skips the install;
- treats absent, non-zero, empty or unrecognised `--version` output as a
mismatch, so the failure direction is a redundant install and never a
skipped one;
- after installing, re-resolves the binary the way callers do — `hash -r`,
then through `PATH`, not through the directory the installer wrote to —
and fails naming the resolved path, since an install that a shadowing
binary hides succeeds while changing nothing any caller sees;
- is actually called, and prints the version on both success paths: a
function defined and never invoked has the same exit status and the same
empty output as one that worked.
Keep it POSIX sh: no arrays, no `[[`, no `grep -P`.
A Go tool a repo needs on the host is installed with `go install` pinned to
a commit hash (`go install <package>@<commit hash>`). It is never tracked as
a `go.mod` tool dependency or through a `tools.go` file, either of which
pulls the tool's own dependencies into the repo's `go.mod` and `go.sum`.
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
- When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD).
@@ -639,14 +374,12 @@ style conventions are in separate documents:
settings.
- Avoid putting files in the repo root unless necessary. Root should contain
only project-level config files (`README.md`, `AGENTS.md`, `Makefile`,
`Dockerfile`, `LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`,
and language-specific config). Everything else goes in a subdirectory.
Canonical subdirectory names:
only project-level config files (`README.md`, `Makefile`, `Dockerfile`,
`LICENSE`, `.gitignore`, `.editorconfig`, `REPO_POLICIES.md`, and
language-specific config). Everything else goes in a subdirectory. Canonical
subdirectory names:
- `bin/` — executable scripts and tools
- `cmd/` — Go command entrypoints; thin only: one `main.go` per binary whose
body is a single call into `internal/` or `pkg/`, no project logic in
`cmd/`
- `cmd/` — Go command entrypoints
- `configs/` — configuration templates and examples
- `deploy/` — deployment manifests (k8s, compose, terraform)
- `docs/` — documentation and markdown (README.md stays in root)
@@ -673,7 +406,3 @@ style conventions are in separate documents:
- Go: `go.mod`, `go.sum`, `.golangci.yml`
- JS: `package.json`, `yarn.lock`, `.prettierrc`, `.prettierignore`
- Python: `pyproject.toml`
- Guidance for coding agents lives in one `AGENTS.md` at the repository root. It
is never committed under a file or directory named after one agent tool, such
as `CLAUDE.md` or `.claude/`, and never split into separate memory files.
+58 -53
View File
@@ -1,72 +1,77 @@
# Workflow
One issue per unit of work, one branch and one PR per issue:
- ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `main`)
- pass an independent review, then the manager squash-merges into `next`
- push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and mergeable to
`main` without notice. One `next` -> `main` PR accumulates the milestone;
releases are cut from `main` separately.
The tracker (https://git.eeqj.de/sneak/webhooker/issues) is the record of what
is done and what is under way. This file keeps no list of either.
* branch (from `main`)
* do the work in Next Step
* move Next Step to the top of Completed Steps
* move the top item of Future Steps into Next Step
* commit (`TODO.md` changes in the same commit as the work)
* merge to `main` if the branch is not protected, otherwise open a PR
* push
# Status
What is still open for 1.0.0 is in its milestone
(https://git.eeqj.de/sneak/webhooker/milestone/9). `v1.0.0` is not tagged, and
only @sneak tags it.
Delivery is at-least-once by design, not by accident: a send whose result row
does not land is attempted again, so a receiver can see a duplicate. That is
deliberate — the alternative is a silent lost delivery — and the README says so
under Rationale. It is not a defect to re-file.
pre-1.0. No git tags exist. main (afe88c6) is a working webhook proxy
with auth, CSRF/SSRF protections, login rate limiting, Slack target,
policy compliance (#6), and pinned lint tooling (#55). Note: TODO.md was
deliberately deleted from this repo in f9a9569 (2026-03-01, #6); its
content was folded into the README TODO section, which this draft
reconstructs as of 2026-07-06.
# Next Step
@sneak runs webhooker in limited production, then tags `v1.0.0`. Merging `next`
into `main` is a separate act from tagging and waits on neither of those: `next`
is kept mergeable at all times, which is the point of the branch.
Implement automatic event retention cleanup based on retention_days: a
periodic maintenance job that deletes Events, Deliveries, and
DeliveryResults older than the parent webhook's retention_days from each
per-webhook event database. The field exists on the Webhook model and
the README promises the behavior, but nothing enforces it, so event
databases currently grow without bound.
# Completed Steps
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
Makefile shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
plain HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
with DNS rebinding defense, and per-IP login rate limiting (#42)
- 2026-03-17 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49);
absolute dev DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on
login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages
(#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
delivery engine with bounded worker pool and circuit breaker,
parallel fan-out, per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
into README (#6)
# Future Steps
- Per-webhook rate limits, set in the web UI and enforced in the receiver
handler, on top of the receiver's existing limits, which `RECEIVER_RATE_LIMIT`
sets for the whole server (see Rate Limiting in the README)
- API key authentication for programmatic access (the `APIKey` model exists, but
nothing creates a key and no middleware checks one)
- Manual event redelivery from the web UI (replay is a core promised
capability in the README rationale)
- Delivery status and retry management UI
- Per-webhook rate limiting in the receiver handler (per-webhook config
plus handler enforcement; global limits must not apply to receiver
endpoints)
- Webhook signature verification for GitHub and Stripe HMAC formats
- API key authentication for programmatic access (APIKey model exists;
Bearer token middleware does not)
- REST API v1
- CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints
- event redelivery endpoint
- OpenAPI specification
- Analytics in the web UI: response times summarised per webhook or per target,
and figures for all webhooks together. The webhook page already shows event,
delivery and failure counts and failure percentages, the target list counts
each target's deliveries, the event log and each event's page show every
attempt's duration, and `/metrics` carries server-wide counts and delivery
durations
- A remember-me option at login. Every session already survives a browser
restart: it lasts up to 7 days, and ends sooner after `SESSION_IDLE_TIMEOUT`
(default 24 hours) without activity
- Password reset flow for a forgotten password over the web. The authenticated
password _change_ flow already landed, and a lost password is recoverable from
the console with `webhooker resetpw`
(https://git.eeqj.de/sneak/webhooker/issues/208)
- Analytics dashboard: success rates, response times, volume
- Session expiration tuning and a remember-me option
- Password change and reset flow
- Later, nice to have
- email delivery target type
- SNS and S3 delivery targets
- data transformations (e.g. a configurable Slack message format; the Slack
target sends one fixed format today)
- JSONL file delivery with periodic S3 upload (the `database` target already
archives events to SQLite files, rotated hourly, daily or monthly if set,
and the webhook page offers them as one gzipped JSON download)
- webhook event search, and filtering beyond the event log's failed and
pending views
- multi-user with role-based access control (each webhook already belongs to
one account, but there is only the account made at first boot and no way
to add another)
- data transformations (e.g. webhook to Slack message formatting)
- JSONL file delivery with periodic S3 upload
- webhook event search and filtering
- multi-user with role-based access control
-107
View File
@@ -1,107 +0,0 @@
package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// dotEnvKey is a throwaway variable name these tests write and read,
// so they cannot disturb real configuration.
const dotEnvKey = "WEBHOOKER_TEST_DISPATCH_VALUE"
// writeDotEnvInWorkingDir puts contents in a .env file in a fresh
// temporary directory and moves the process there.
//
// The callers are deliberately not parallel and must stay that way:
// t.Chdir moves the whole process. Go releases parallel tests only
// after every sequential test in the package has finished, so nothing
// else runs while these do.
func writeDotEnvInWorkingDir(t *testing.T, contents string) {
t.Helper()
dir := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(dir, config.DotEnvPath),
[]byte(contents), 0o600,
))
t.Chdir(dir)
}
// TestDispatch_MalformedDotEnvRefuses pins the second half of the
// defect. godotenv applies nothing at all when a file will not parse,
// so one mistyped line used to revert every variable in it to its
// default and start the server anyway, with no log line naming the
// file. The refusal has to arrive before any subcommand runs, which
// is why `help` — the one subcommand that touches nothing — is still
// refused here.
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestDispatch_MalformedDotEnvRefuses(t *testing.T) {
writeDotEnvInWorkingDir(t, "PORT 19615\n")
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 1, code, "a broken .env must exit non-zero")
assert.Contains(
t, stderr.String(), config.DotEnvPath,
"the refusal must name the file",
)
assert.Empty(
t, stdout.String(),
"the subcommand must not have run",
)
}
// TestDispatch_LoadsDotEnvBeforeSubcommands pins the ordering the
// godotenv/autoload import used to provide for free. It ran in an
// init(), so .env was in the environment before anything read it —
// including config.DataDir, which both the DATA_DIR lock and resetpw
// call outside the fx graph. Loading any later would let a .env that
// sets DATA_DIR lock one directory while the config opened databases
// in another.
func TestDispatch_LoadsDotEnvBeforeSubcommands(t *testing.T) {
t.Setenv(dotEnvKey, "placeholder")
require.NoError(t, os.Unsetenv(dotEnvKey))
writeDotEnvInWorkingDir(t, dotEnvKey+"=from-dot-env\n")
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 0, code)
assert.Equal(
t, "from-dot-env", os.Getenv(dotEnvKey),
"the file must be applied before the subcommand runs",
)
}
// TestDispatch_MissingDotEnvIsFine pins the case most deployments are
// in: no .env at all, which must stay a normal start.
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestDispatch_MissingDotEnvIsFine(t *testing.T) {
t.Chdir(t.TempDir())
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 0, code)
assert.Empty(t, stderr.String())
}
+3 -191
View File
@@ -2,69 +2,19 @@
package main
import (
"fmt"
"io"
"log/slog"
"os"
"time"
"go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
"sneak.berlin/go/webhooker/internal/session"
)
// stopTimeout bounds the whole fx stop sequence, not each hook.
//
// fx defaults to 15s, which is longer than Docker's 10s default
// stop grace: the container would be SIGKILLed before the bound
// could fire, so nothing bounded by it would ever be observed.
// 5s leaves headroom inside that grace for signal delivery and
// process exit; the observed wedge case already exits at ~5.3s,
// so a larger bound would trade a rare skipped database close for
// a more common hard kill.
//
// The server's stop hook must fit inside it with room to spare: a
// hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the HTTP drain plus the Sentry flush that
// follows it in the same hook, and each is clamped to the stop
// context's remaining time less server.TailHookReserve rather than
// running for its own fixed 3s and 2s; the reserve is what the tail
// hooks live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length and every amount of budget the hooks
// before the server may already have spent.
//
// This does not make the database close unconditional: the
// archive.Sweeper and RetentionReaper hooks run before the server.
// What they spend comes out of the drain first, but past 3s it comes
// out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second
// exitUsage is the status for a command line this binary cannot make
// sense of, kept distinct from the 1 a refusal exits with so that a
// caller can tell "called wrong" from "declined".
const exitUsage = 2
// helpCommand is the subcommand that prints usage. The flag spellings
// beside it in the switch are aliases; this is the name the usage text
// documents and the one tests invoke.
const helpCommand = "help"
// Build-time variables set via -ldflags.
//
//nolint:gochecknoglobals // Build-time variables injected by the linker.
@@ -77,161 +27,23 @@ func main() {
globals.Appname = appname
globals.Version = version
os.Exit(dispatch(os.Args[1:], os.Stdin, os.Stdout, os.Stderr))
}
// dispatch routes the command line to a subcommand.
//
// No arguments runs the server, which is what the image's CMD and
// every existing deployment invoke; that path is unchanged, including
// where the DATA_DIR lock is taken relative to building the fx graph
// and how fx propagates a non-zero exit itself.
//
// The optional .env file is read here, before any subcommand and so
// before anything reads the environment — config.DataDir, which both
// the DATA_DIR lock and resetpw call outside the fx graph, above all.
// It used to be read from an init() in internal/config, which put it
// earlier still but threw the error away: a single malformed line
// applied none of the file and said nothing about it. A file that is
// not there stays fine, since .env is optional and most deployments
// do not have one.
func dispatch(
args []string,
stdin io.Reader,
stdout, stderr io.Writer,
) int {
err := config.LoadDotEnv()
if err != nil {
_, _ = fmt.Fprintf(stderr, "%s: %v\n", appname, err)
return 1
}
if len(args) == 0 {
return run(stderr)
}
switch args[0] {
case resetpw.Name:
return resetpw.Run(args[1:], stdin, stdout, stderr)
case helpCommand, "-h", "-help", "--help":
usage(stdout)
return 0
default:
_, _ = fmt.Fprintf(
stderr, "%s: unknown subcommand %q\n", appname, args[0],
)
usage(stderr)
return exitUsage
}
}
// usage lists what the binary can be asked to do.
func usage(w io.Writer) {
_, _ = fmt.Fprintf(w, `usage: %s [subcommand]
With no subcommand, runs the webhooker server.
Subcommands:
%s [-generate] <username>
Set an existing account's password on a stopped deployment.
Recovers an admin account whose bootstrap password was lost.
help
Print this message.
`, appname, resetpw.Name)
}
// run takes the exclusive DATA_DIR lock, then runs the application
// under it, and returns the process exit status.
//
// The lock is taken here rather than inside the fx graph because it has
// to be held before anything opens a database, and because a refusal
// has to reach the operator as a plain line on standard error rather
// than as one entry in an fx failure dump. It is released by the defer
// on a clean shutdown, and by the kernel closing the descriptor on any
// other exit — including the one fx performs itself when a start or
// stop hook fails, which skips deferred calls.
func run(stderr io.Writer) int {
lock, err := datadir.Acquire(config.DataDir())
if err != nil {
_, _ = fmt.Fprintf(stderr, "%s: %v\n", appname, err)
return 1
}
defer func() { _ = lock.Release() }()
newApp().Run()
return 0
}
// newApp builds the application graph. It is separate from main so
// a test can assert the options it carries.
func newApp() *fx.App {
return fx.New(
fx.StopTimeout(stopTimeout),
// fx's own events go through the service's logger, not fx's
// console logger on standard error. The exception is a failure
// before this logger is built, such as an invalid configuration
// value, which fx's console logger still prints there. fx holds
// its events back until this logger is built and then replays
// them, so it takes the configuration, which sets the level
// DEBUG=true asks for: without it the replay would run at INFO
// and drop every record of how the graph was built.
fx.WithLogger(
func(l *logger.Logger, _ *config.Config) fxevent.Logger {
return logger.NewFxLogger(l.Get())
},
),
fx.New(
fx.Provide(
globals.New,
logger.New,
// The plain logger the session, the middleware, the
// webhook database manager and the archive writers take.
func(l *logger.Logger) *slog.Logger { return l.Get() },
config.New,
database.New,
database.NewWebhookDBManager,
database.NewRetentionReaper,
healthcheck.New,
session.New,
handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.New,
// The one SSRF guard both target-creation validation
// and the delivery dialer consult, so they cannot
// disagree about a destination.
delivery.NewGuard,
delivery.New,
archive.NewWriters,
archive.NewSweeper,
// Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *archive.Writers as archive.Archives so deleting
// or renaming a webhook or target reaches its archive
// files.
func(w *archive.Writers) archive.Archives { return w },
// Wire *delivery.Engine as delivery.CircuitBreakers so
// the pages can show a target whose deliveries are
// paused.
func(e *delivery.Engine) delivery.CircuitBreakers { return e },
server.New,
),
fx.Invoke(
func(
*server.Server,
*delivery.Engine,
*database.RetentionReaper,
*archive.Sweeper,
) {
},
),
)
fx.Invoke(func(*server.Server, *delivery.Engine) {}),
).Run()
}
-291
View File
@@ -1,291 +0,0 @@
package main
import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config/configtest"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server"
)
// dockerStopGrace is Docker's default `docker stop` grace period.
// The Dockerfile sets no STOPSIGNAL or grace override, so this is
// the deadline the container is actually held to, and the fx stop
// timeout has to fit inside it with room for signal delivery and
// process exit.
const dockerStopGrace = 10 * time.Second
// TestNewApp_StopTimeout pins the fx stop timeout. Without the
// explicit fx.StopTimeout option the app reads fx's 15s
// DefaultTimeout, which exceeds dockerStopGrace: the container is
// SIGKILLed before the bound fires and every shutdown hook bounded
// by it — including the operator-facing timeout log — becomes
// unreachable in the image this repo produces.
//
// fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) {
configtest.ClearEnv(t)
t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout()
require.Equal(t, stopTimeout, got)
require.Less(t, got, dockerStopGrace)
}
// freePort returns a loopback TCP port that was free a moment ago, by
// taking one and releasing it.
func freePort(t *testing.T) int {
t.Helper()
var listenCfg net.ListenConfig
l, err := listenCfg.Listen(t.Context(), "tcp", "127.0.0.1:0")
require.NoError(t, err)
addr, ok := l.Addr().(*net.TCPAddr)
require.True(t, ok, "listener is not TCP")
require.NoError(t, l.Close())
return addr.Port
}
// TestNewApp_SendsFxEventsToTheLogger starts and stops the app main
// runs, with DEBUG=true, and reads back what reached the service's
// logger. fx's own events must arrive there as structured records:
// the start at INFO, and at DEBUG the records of how the graph was
// built.
//
// fx holds its events back until its logger is built and then replays
// them all at once, so the earliest of them arriving shows the replay
// ran at DEBUG: that globals.New was provided, which fx records before
// anything is built, and the run of logger.New, which happens before
// the configuration sets the level.
func TestNewApp_SendsFxEventsToTheLogger(t *testing.T) {
configtest.ClearEnv(t)
t.Setenv("DATA_DIR", t.TempDir())
t.Setenv("PORT", strconv.Itoa(freePort(t)))
t.Setenv("DEBUG", "true")
// internal/logger writes to whatever os.Stdout is when it builds
// its handler. A file is not a terminal, so that handler is the
// JSON one the service uses in production.
out, err := os.CreateTemp(t.TempDir(), "stdout")
require.NoError(t, err)
stdout := os.Stdout
os.Stdout = out
t.Cleanup(func() {
os.Stdout = stdout
_ = out.Close()
})
app := newApp()
require.NoError(t, app.Start(t.Context()))
require.NoError(t, app.Stop(t.Context()))
_, err = out.Seek(0, io.SeekStart)
require.NoError(t, err)
written, err := io.ReadAll(out)
require.NoError(t, err)
type record struct {
Level string `json:"level"`
Msg string `json:"msg"`
Name string `json:"name"`
Constructor string `json:"constructor"`
}
var records []record
for line := range strings.Lines(string(written)) {
var r record
// The first-boot banner is plain text, not a record.
if json.Unmarshal([]byte(line), &r) == nil {
records = append(records, r)
}
}
const pkg = "sneak.berlin/go/webhooker/internal/"
info := slog.LevelInfo.String()
debug := slog.LevelDebug.String()
assert.Contains(t, records, record{Level: info, Msg: "started"})
assert.Contains(t, records, record{
Level: debug, Msg: "provided", Constructor: pkg + "globals.New()",
})
assert.Contains(t, records, record{
Level: debug, Msg: "run", Name: pkg + "logger.New()",
})
assert.Contains(t, records, record{Level: debug, Msg: "invoking"})
assert.Contains(t, records, record{
Level: debug, Msg: "initialized custom fxevent.Logger",
})
}
// TestRunRefusesLockedDataDir pins what an operator's second start
// does. The entry point must refuse before it builds the fx graph —
// nothing may open a database in a DATA_DIR another process holds —
// and must exit non-zero with a message naming the directory rather
// than starting a second delivery engine over the same rows.
//
// flock(2) locks descriptors independently, so holding the lock here
// is the same denial a separate process gets; internal/datadir pins
// that property and covers the real two-process case.
func TestRunRefusesLockedDataDir(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { _ = lock.Release() }()
var stderr bytes.Buffer
code := run(&stderr)
require.Equal(
t, 1, code, "a second instance must exit non-zero",
)
assert.Contains(
t, stderr.String(), dir,
"the refusal must name the directory",
)
assert.Contains(t, stderr.String(), "another instance")
}
// TestDispatch_NoArgumentsRunsTheServer pins the routing of a bare
// invocation, which is what the image's CMD and every deployment use.
// Adding subcommands must not move the server off the empty argument
// list, and must not move the DATA_DIR lock: this asserts the refusal
// arrives with no fx graph built, exactly as run does on its own.
func TestDispatch_NoArgumentsRunsTheServer(t *testing.T) {
dir := t.TempDir()
t.Setenv("DATA_DIR", dir)
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { _ = lock.Release() }()
var stdout, stderr bytes.Buffer
code := dispatch(nil, strings.NewReader(""), &stdout, &stderr)
require.Equal(t, 1, code)
assert.Contains(t, stderr.String(), "another instance")
}
// TestDispatch_UnknownSubcommand keeps a mistyped subcommand from
// starting a server. Anything else would have `webhooker resetpww`
// silently take the DATA_DIR lock and serve.
func TestDispatch_UnknownSubcommand(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{"resetpww", "admin"},
strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 2, code)
assert.Contains(t, stderr.String(), "unknown subcommand")
assert.Contains(
t, stderr.String(), resetpw.Name,
"the usage must name the subcommand that does exist",
)
}
// TestDispatch_Help answers on standard output with a zero status, so
// `webhooker help` is usable in a pipe.
func TestDispatch_Help(t *testing.T) {
t.Parallel()
var stdout, stderr bytes.Buffer
code := dispatch(
[]string{helpCommand}, strings.NewReader(""), &stdout, &stderr,
)
require.Equal(t, 0, code)
assert.Empty(t, stderr.String())
assert.Contains(t, stdout.String(), resetpw.Name)
}
// tailHeadroom is the slack the fx stop budget must keep beyond the
// server stop hook. The hooks that run after the server — the
// delivery engine, the healthcheck, the webhook DB manager and the
// database close — are microsecond-scale in normal operation, so
// this is generous for them.
const tailHeadroom = 2 * time.Second
// TestStopTimeout_LeavesHeadroomForTailHooks pins the relationship
// between the server's stop hook and the fx stop budget. fx bounds
// the whole stop sequence, and returns without running its
// remaining hooks once the stop context has expired. If the hook
// could use the entire budget, every later hook — the database close
// included — would be skipped in exactly the case where the drain
// mattered.
//
// The hook is not just the HTTP drain: a Sentry flush follows it in
// the same hook, and sentry.Flush honours no context, so both halves
// have to be counted. The sweep walks every drain length the hook
// can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme.
//
// Nor does the hook start on a full budget: the archive.Sweeper and
// RetentionReaper hooks run before it, and whatever they spent is
// gone. The outer sweep walks every amount they can spend. Once they
// have eaten into the headroom themselves, the hook must spend
// nothing of what is left. A drain that starts on the full budget
// must still get all of ShutdownTimeout, so a smaller stopTimeout
// cannot silently shorten every drain.
//
// Shrinking either budget, or unbounding the drain or the flush
// again, must fail here rather than silently recreating a hook that
// swallows the whole sequence.
func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout)
require.Equal(
t, server.ShutdownTimeout, server.DrainBudget(stopTimeout),
"a drain that starts on the full stop budget is cut short",
)
const step = 10 * time.Millisecond
for spent := time.Duration(0); spent <= stopTimeout; spent += step {
remaining := stopTimeout - spent
longest := max(server.DrainBudget(remaining), 0)
for drain := time.Duration(0); drain <= longest; drain += step {
hook := drain + server.SentryFlushBudget(remaining-drain)
require.GreaterOrEqual(
t, remaining-hook, min(remaining, tailHeadroom),
"a %s drain after %s of earlier hooks leaves "+
"the tail hooks short", drain, spent,
)
}
}
}
-22
View File
@@ -1,22 +0,0 @@
#!/bin/sh
# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted
# data directory keeps its owner from the host, often root, and the app
# could not write to it. Started as root, this creates DATA_DIR if
# needed, gives it and everything in it to webhooker, sets its mode, and
# runs the command as webhooker, so the app never runs as root. Started
# as another user, it only runs the command.
set -eu
main() {
if [ "$(id -u)" != 0 ]; then
exec "$@"
fi
dir="${DATA_DIR:-/var/lib/webhooker}"
mkdir -p "$dir"
find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} +
chmod 750 "$dir"
exec su-exec webhooker "$@"
}
main "$@"
-18
View File
@@ -1,18 +0,0 @@
// ESLint configuration for static/js/. script/lint and the image build run
// ESLint in the Dockerfile's js-lint stage, never on the host.
//
// The rules are the ones the JavaScript styleguide linked from
// REPO_POLICIES.md states that a linter can check: const for everything,
// let only for a variable that is reassigned, never var.
export default [
// Alpine.js, extracted from 3p/ by make assets; not ours to lint.
{ ignores: ["static/js/alpine.min.js"] },
{
// The pages load static/js/app.js as a classic script, not a module.
languageOptions: { sourceType: "script" },
rules: {
"no-var": "error",
"prefer-const": "error",
},
},
];
+10 -16
View File
@@ -4,23 +4,18 @@ go 1.26.1
require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f
github.com/chromedp/chromedp v0.16.0
github.com/dustin/go-humanize v1.0.1
github.com/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.15.0
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3
github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.18.0
github.com/prometheus/client_model v0.5.0
github.com/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1
go.uber.org/fx v1.24.0
github.com/stretchr/testify v1.8.4
go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0
gorm.io/driver/sqlite v1.5.4
gorm.io/gorm v1.25.5
@@ -30,31 +25,30 @@ require (
require (
github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect
github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/kr/text v0.2.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/prometheus/client_model v0.5.0 // indirect
github.com/prometheus/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect
go.uber.org/multierr v1.10.0 // indirect
go.uber.org/zap v1.26.0 // indirect
go.uber.org/atomic v1.9.0 // indirect
go.uber.org/dig v1.17.0 // indirect
go.uber.org/multierr v1.9.0 // indirect
go.uber.org/zap v1.23.0 // indirect
golang.org/x/mod v0.17.0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // indirect
+26 -36
View File
@@ -1,15 +1,14 @@
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs=
github.com/benbjohnson/clock v1.3.0 h1:ip6w0uFQkncKQ979AypyG0ER7mqUSBdKLOgAle/AT8A=
github.com/benbjohnson/clock v1.3.0/go.mod h1:J11/hYXuz8f4ySSvYwY0FKfm+ezbsZBKZxNJlLklBHA=
github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f h1:8PK9FM4bE0C8GMoWBW5lVsef3U7sPICjDg6JqngyYhk=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk=
github.com/chromedp/chromedp v0.16.0/go.mod h1:rbuGKFT1vMcFcFqKfPIO1GpX/N+2s8onm2qMxZLbU5U=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
@@ -24,16 +23,6 @@ github.com/go-chi/httprate v0.15.0 h1:j54xcWV9KGmPf/X4H32/aTH+wBlrvxL7P+SdnRqxh5
github.com/go-chi/httprate v0.15.0/go.mod h1:rzGHhVrsBn3IMLYDOZQsSU4fJNWcjui4fWKJcCId1R4=
github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 h1:UADEEmDKgfXbtnGJZ97beY5XLo9ZechG1nlU4KnRrkE=
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/gofrs/flock v0.13.0 h1:95JolYOvGMqeH31+FC7D2+uULf6mG61mEZ/A8dRYMzw=
github.com/gofrs/flock v0.13.0/go.mod h1:jxeyy9R1auM5S6JYDBhDt+E2TCo7DkratH4Pgi8P+Z0=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
@@ -64,20 +53,17 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.17 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pingcap/errors v0.11.4 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+FoDDqP5Tbk=
@@ -94,24 +80,28 @@ github.com/rogpeppe/go-internal v1.10.0 h1:TMyTOH3F/DB16zRVcYyreMH6GnZZrwQVAoYjR
github.com/rogpeppe/go-internal v1.10.0/go.mod h1:UQnix2H7Ngw/k4C5ijL5+65zddjncjaFoBhdsK/akog=
github.com/slok/go-http-metrics v0.11.0 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0=
github.com/zeebo/xxh3 v1.0.2 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
go.uber.org/zap v1.23.0 h1:OjGQ5KQDEUawVHxNwQgPpiypGHOxo2mNZsOqTak4fFY=
go.uber.org/zap v1.23.0/go.mod h1:D+nX8jyLsMHMYrln8A0rJjFt/T/9/bGgIhAqxv5URuY=
golang.org/x/crypto v0.38.0 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw=
golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA=
@@ -119,8 +109,8 @@ golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.25.0 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-454
View File
@@ -1,454 +0,0 @@
// Package archive manages the SQLite archive files the database target
// writes: how they are named and rotated, the writers that open, write,
// close and reopen them, pruning the rows past a target's expiry,
// renaming a target's files and moving them back when that fails, the
// gzipped JSON export, the idle sweep, and the locks that keep a rename
// apart from archive writes, from other edits and from exports.
package archive
import (
"fmt"
"log/slog"
"strings"
"sync"
"time"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/database"
)
// archiveNameMaxLen is how many characters of a webhook or target
// name an archive file name keeps.
const archiveNameMaxLen = 40
// Archives is how the handlers keep the database targets' archive
// files in step with the configuration. Deleting a webhook or a
// target releases the cached archive writers, whose open file
// handles would otherwise outlive them; renaming one renames the
// archive files, which are named for the webhook and the target
// (see FileName). RenameLock returns the lock every rename runs under
// (see Writers.RenameLock).
//
// It is an interface so that the handlers' tests can stand in for
// Writers.
//
// Neither eviction deletes an archive file. Both are idempotent
// and are no-ops for a webhook or target with no writer.
type Archives interface {
RenameLock() *sync.Mutex
EvictWebhook(webhookID string)
EvictTarget(targetID string)
Rename(targetID, webhookName, targetName string) error
}
// WritersParams holds the fx dependencies for Writers.
type WritersParams struct {
fx.In
Database *database.Database
DBManager *database.WebhookDBManager
Logger *slog.Logger
}
// Writers holds the archive writer of each database target, and the
// lock every rename of archive files runs under. The database target
// writes an event through it, the handlers rename and evict through
// it, the archive sweeper prunes through it, and the delivery engine
// evicts every writer when it stops. See archiveWriter for the
// close/reopen, auto-recreate, and expiry semantics.
type Writers struct {
db *database.Database
dbMgr *database.WebhookDBManager
log *slog.Logger
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex
writers map[string]*archiveWriter
// renameMu makes the webhook edit, the target edit and target
// creation run one at a time, each held from loading the stored
// names through the archive rename, the save and any move back.
// Interleaved, one could rename an archive between another's
// rename and save, leaving the file named for one edit and the
// stored names from the other. An archive download holds it while
// it reads the stored names and lists the files they give, and
// again for each file while it finds the file under the names
// stored then and opens it. RenameLock returns it.
renameMu sync.Mutex
}
// NewWriters builds the archive writers. It opens no archive file:
// each target's writer is made on its first use.
func NewWriters(params WritersParams) *Writers {
return &Writers{
db: params.Database,
dbMgr: params.DBManager,
log: params.Logger,
}
}
// RenameLock implements Archives. It returns the lock every rename of
// archive files runs under: see renameMu for who holds it and for how
// long. Archive writes and Rename itself do not take it.
func (ws *Writers) RenameLock() *sync.Mutex {
return &ws.renameMu
}
// FileName returns the file name of a database target's
// archive: archive-WEBHOOKNAME-TARGETNAME-TARGETID.db, with both
// names passed through archiveNamePart. The target ID keeps the
// name unique when two targets' names come out the same.
func FileName(webhookName, targetName, targetID string) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" + targetID + ".db"
}
// archiveNamePart makes a webhook or target name safe to put in a
// file name. It is lowercased; ASCII letters and digits are kept,
// every other run of characters becomes a single "-", and no "-" is
// left at either end. It is cut to archiveNameMaxLen characters, and
// a name with nothing left is "unnamed".
func archiveNamePart(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
if (r < 'a' || r > 'z') && (r < '0' || r > '9') {
dash = b.Len() > 0
continue
}
if dash {
b.WriteByte('-')
dash = false
}
b.WriteRune(r)
}
part := b.String()
if len(part) > archiveNameMaxLen {
part = strings.TrimRight(part[:archiveNameMaxLen], "-")
}
if part == "" {
return "unnamed"
}
return part
}
// Write writes the full event of a delivery to a database target as
// a row into the target's archive database, honouring the optional
// per-target expiry and rotation parsed from the target config JSON.
// With rotation, the event goes to the file for the period of its
// receive time.
func (ws *Writers) Write(d *database.Delivery) error {
webhookID := d.Event.WebhookID
if webhookID == "" {
return errArchiveMissingWebhookID
}
expiry, err := ParseExpiry(d.Target.Config)
if err != nil {
return err
}
rotation, err := ParseRotation(d.Target.Config)
if err != nil {
return err
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see delivery's Engine.hydrateEvent), and goes
// to the file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := ws.writerFor(d.TargetID)
if err != nil {
return err
}
row := archivedEvent{
EventID: d.Event.ID,
WebhookID: webhookID,
EntrypointID: d.Event.EntrypointID,
Method: d.Event.Method,
RawQuery: d.Event.RawQuery,
Headers: d.Event.Headers,
Body: d.Event.Body,
ContentType: d.Event.ContentType,
}
return w.write(row, expiry, archivePeriod(rotation, receivedAt))
}
// Rename implements Archives. It renames every one of a database
// target's archive files to FileName(webhookName, targetName,
// targetID), each keeping the period in its name. It goes through the
// target's writer, so the move holds the lock that the target's
// archive writes and the idle sweep take, and later writes use the
// new name. It never replaces a file: if one already has a new name,
// the error is ErrNameTaken.
//
// The writer is created if there is none, and it stays cached. The
// handlers rename before they save the new name, so until the save
// the main database still has the old one; a delivery in that window
// must find this writer rather than build one from the old name.
func (ws *Writers) Rename(
targetID, webhookName, targetName string,
) error {
w, err := ws.writerFor(targetID)
if err != nil {
return err
}
return w.rename(FileName(webhookName, targetName, targetID))
}
// EvictTarget implements Archives. It drops a database target's
// archive writer from the registry and closes its handle, so a
// deleted target does not leave a writer (and an open archive handle
// within its debounce window) alive for the process lifetime.
//
// The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race
// a write.
//
// Eviction is idempotent and silent for a target with no writer,
// which is the common case: only a database target that has
// received an event or been renamed has one. It never deletes the
// archive file.
func (ws *Writers) EvictTarget(targetID string) {
ws.mu.Lock()
w, ok := ws.writers[targetID]
if ok {
delete(ws.writers, targetID)
}
ws.mu.Unlock()
if !ok {
return
}
w.evict()
ws.log.Info(
"evicted archive writer",
"target_id", targetID,
"path", w.path,
)
}
// EvictWebhook implements Archives. It evicts, exactly as EvictTarget
// does, the writer of every database target of a webhook. The archive
// files themselves are left on disk — they are long-term storage the
// operator owns.
func (ws *Writers) EvictWebhook(webhookID string) {
ws.mu.Lock()
var gone []*archiveWriter
for targetID, w := range ws.writers {
if w.webhookID == webhookID {
delete(ws.writers, targetID)
gone = append(gone, w)
}
}
ws.mu.Unlock()
for _, w := range gone {
w.evict()
ws.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// EvictAll evicts every cached archive writer, exactly as EvictTarget
// does for one target. The delivery engine calls it at shutdown, once
// its workers have returned. Closing the last handle on an archive
// moves the contents of its -wal into the .db and removes the
// -wal, so a clean stop leaves each archive as a single file.
func (ws *Writers) EvictAll() {
ws.mu.Lock()
writers := ws.writers
ws.writers = nil
ws.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// writerFor returns the archive writer for a database target,
// creating and caching it on first use. Each target has one writer
// so its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock
// as its writes.
func (ws *Writers) writerFor(
targetID string,
) (*archiveWriter, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
var err error
w, err = ws.newWriter(targetID)
if err != nil {
return nil, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
ws.writers[targetID] = w
}
// A delivery claims the entry: even if the idle sweep created
// it moments ago, it now belongs to the registry proper and
// the sweep must leave it in place when it finishes.
w.sweepOwned = false
return w, nil
}
// sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep
// itself created the registry entry.
//
// The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done.
func (ws *Writers) sweepWriterFor(
targetID string,
) (*archiveWriter, bool, error) {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if ok {
return w, false, nil
}
w, err := ws.newWriter(targetID)
if err != nil {
return nil, false, err
}
if ws.writers == nil {
ws.writers = make(map[string]*archiveWriter)
}
w.sweepOwned = true
ws.writers[targetID] = w
return w, true, nil
}
// releaseSweepWriter drops a registry entry that the idle sweep
// created, so a sweep leaves the registry exactly as it found it.
//
// The entry is removed only if it is still the very writer the
// sweep installed and no delivery has claimed it in the meantime
// (writerFor clears sweepOwned when it hands a writer to the
// write path). Both conditions are evaluated under the registry
// lock, so an eviction that raced the sweep — which removes the
// entry outright — simply finds nothing left to do here, and a
// delivery that adopted the writer keeps a registered, evictable
// one.
func (ws *Writers) releaseSweepWriter(
targetID string, w *archiveWriter,
) {
ws.mu.Lock()
defer ws.mu.Unlock()
cur, ok := ws.writers[targetID]
if !ok || cur != w || !cur.sweepOwned {
return
}
delete(ws.writers, targetID)
}
// newWriter builds the writer for a database target's archive. Its
// path is the one Path gives for the webhook and the target as
// the main database names them now; from then on only rename changes
// the name the writer uses. It does not touch the archive files.
func (ws *Writers) newWriter(
targetID string,
) (*archiveWriter, error) {
if ws.dbMgr == nil {
return nil, errArchiveNoDataDir
}
var target database.Target
err := ws.db.DB().
Preload("Webhook").
First(&target, "id = ?", targetID).Error
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
Path(ws.dbMgr, &target.Webhook, &target),
ws.log,
)
w.webhookID = target.WebhookID
return w, nil
}
// sweepArchive prunes one database target's archive of rows older
// than expiry, without requiring a write. A missing archive file is
// left missing (see sweepExpired), so a sweep never creates an
// archive for a target that has never received an event.
//
// It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot
// resurrect the writer the eviction just dropped.
func (ws *Writers) sweepArchive(
targetID string, expiry time.Duration,
) error {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return err
}
if created {
defer ws.releaseSweepWriter(targetID, w)
}
return w.sweepExpired(expiry)
}
-773
View File
@@ -1,773 +0,0 @@
package archive_test
import (
"database/sql"
"fmt"
"io/fs"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
const (
// testContentType is the event content type used in tests.
testContentType = "application/json"
// eventQuery is the query string an archived event carries.
eventQuery = "a=1&b=2"
)
func archiveTestLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
))
}
// openArchiveDBForRead opens an archive file read-only so a
// test can inspect the rows the writer persisted.
func openArchiveDBForRead(
t *testing.T, path string,
) *gorm.DB {
t.Helper()
sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf("file:%s?mode=ro", path),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
)
require.NoError(t, err)
return gdb
}
// archiveFileSuffixes returns the archive file itself and the
// SQLite sidecars that accompany an open database. A test that
// asserts no archive was created has to check all of them.
func archiveFileSuffixes() []string {
return []string{"", "-wal", "-shm"}
}
// removeArchiveFiles simulates an operator moving the archive
// away by deleting the SQLite file and its sidecar files.
func removeArchiveFiles(t *testing.T, path string) {
t.Helper()
for _, suffix := range []string{
"", "-wal", "-shm", "-journal",
} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
t.Fatalf("removing %s%s: %v", path, suffix, err)
}
}
}
// assertStoredTime asserts that text, a timestamp as SQLite holds it,
// is in the form GORM stores created_at in: the layout of
// time.Time.String, with no " m=+…" monotonic clock reading after it.
func assertStoredTime(t *testing.T, text string) {
t.Helper()
_, err := time.Parse(
"2006-01-02 15:04:05.999999999 -0700 MST", text,
)
assert.NoError(t, err, "stored as %q", text)
}
// newEvent returns an event received now with the given body, as the
// webhook handler stores it.
func newEvent(body string) database.Event {
return database.Event{
BaseModel: database.BaseModel{
ID: uuid.New().String(),
CreatedAt: time.Now(),
},
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: testContentType,
}
}
// databaseTargetDelivery returns a delivery of event to a database
// target, as the database target hands it to Writers.Write.
func databaseTargetDelivery(
event database.Event, tgt *database.Target,
) *database.Delivery {
return &database.Delivery{
EventID: event.ID,
TargetID: tgt.ID,
Event: event,
Target: database.Target{
Name: tgt.Name,
Type: database.TargetTypeDatabase,
Config: tgt.Config,
},
}
}
func TestArchiveWriter_WritesRow(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
row := archive.ExportArchivedEvent{
EventID: "ev-1",
WebhookID: "wh-1",
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X":"Y"}`,
Body: `{"hello":"world"}`,
ContentType: "application/json",
}
require.NoError(t, w.Write(row, 0))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "ev-1", got[0].EventID)
assert.Equal(t, "wh-1", got[0].WebhookID)
assert.Equal(t, "ep-1", got[0].EntrypointID)
assert.Equal(t, row.Method, got[0].Method)
assert.Equal(t, row.ContentType, got[0].ContentType)
assert.JSONEq(t, `{"hello":"world"}`, got[0].Body)
assert.False(t, got[0].ArchivedAt.IsZero())
var archivedAt string
require.NoError(t, w.DB().Raw(
"SELECT CAST(archived_at AS TEXT) FROM archived_events",
).Row().Scan(&archivedAt))
assertStoredTime(t, archivedAt)
}
func TestArchiveWriter_RecreatesAfterRemoval(
t *testing.T,
) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
assert.FileExists(t, path)
// The operator moves the archive away while the handle is
// still open.
removeArchiveFiles(t, path)
require.NoFileExists(t, path)
// The next write recreates the file with a fresh schema and
// only the new row.
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
assert.FileExists(t, path)
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "b", got[0].EventID)
}
// TestArchiveHandleIsWAL closes the last gap in the durability
// evidence: the main and per-webhook tiers each assert their journal
// mode on a live handle, and the archive tier gets its settings from
// the same code path but nothing checked the running file.
func TestArchiveHandleIsWAL(t *testing.T) {
t.Parallel()
w := archive.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive-wal.db"),
archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
var mode string
row := w.DB().Raw("pragma journal_mode").Row()
require.NoError(t, row.Scan(&mode))
assert.Equal(t, "wal", strings.ToLower(mode))
var busy string
row = w.DB().Raw("pragma busy_timeout").Row()
require.NoError(t, row.Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestStatArchive proves Stat 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 := archive.Stat(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 := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
w.SetNow(func() time.Time { return stopped })
require.NoError(t, w.Write(archive.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 := archive.Stat(path)
require.NoError(t, err)
assert.Equal(t, 1, got.Files)
assert.Equal(t, file.Size()+wal.Size(), got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
removeArchiveFiles(t, path)
_, err = archive.Stat(path)
require.ErrorIs(t, err, fs.ErrNotExist)
}
func TestArchiveWriter_ReopenDebounce(t *testing.T) {
t.Parallel()
const debounce = 2 * time.Second
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), debounce,
)
// The writer measures its reopen debounce on this clock, which
// only the test moves, so how long the host takes between
// writes cannot change the result.
now := time.Now()
w.SetNow(func() time.Time { return now })
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "a"}, 0,
))
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "b"}, 0,
))
// Two writes inside the debounce window trigger only the
// initial open — no extra close/reopen.
assert.Equal(t, 1, w.Reopens())
now = now.Add(debounce)
require.NoError(t, w.Write(
archive.ExportArchivedEvent{EventID: "c"}, 0,
))
// A write after the window elapses closes and reopens once.
assert.Equal(t, 2, w.Reopens())
}
func TestArchiveWriter_ExpiryPrune(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(0))
old := archive.ExportArchivedEvent{
EventID: "old",
ArchivedAt: time.Now().Add(-2 * time.Hour),
}
fresh := archive.ExportArchivedEvent{
EventID: "fresh",
ArchivedAt: time.Now(),
}
require.NoError(t, w.DB().Create(&old).Error)
require.NoError(t, w.DB().Create(&fresh).Error)
// Reopening with a one-hour expiry prunes the old row.
require.NoError(t, w.Reopen(time.Hour))
var got []archive.ExportArchivedEvent
require.NoError(t, w.DB().Find(&got).Error)
require.Len(t, got, 1)
assert.Equal(t, "fresh", got[0].EventID)
}
func TestParseArchiveExpiry(t *testing.T) {
t.Parallel()
cases := []struct {
name string
in string
want time.Duration
wantErr bool
}{
{"empty config", "", 0, false},
{"explicit never", `{"expiry":"never"}`, 0, false},
{"empty expiry", `{"expiry":""}`, 0, false},
{"duration", `{"expiry":"1h"}`, time.Hour, false},
{"unparseable", `{"expiry":"nonsense"}`, 0, true},
{"zero duration", `{"expiry":"0s"}`, 0, true},
{"negative duration", `{"expiry":"-5h"}`, 0, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, err := archive.ParseExpiry(tc.in)
if tc.wantErr {
require.Error(t, err)
return
}
require.NoError(t, err)
assert.Equal(t, tc.want, got)
})
}
}
func TestValidateArchiveExpiry(t *testing.T) {
t.Parallel()
valid := []string{"", "never", "1h", "720h", "30m"}
for _, in := range valid {
require.NoError(t,
archive.ValidateExpiry(in),
"expiry %q should be accepted", in,
)
}
invalid := []string{"nonsense", "7d", "-5h", "0s", "0"}
for _, in := range invalid {
require.Error(t,
archive.ValidateExpiry(in),
"expiry %q should be rejected", in,
)
}
}
// TestArchiveFileName pins the archive file name and the rules
// that make a webhook or target name safe to put in it.
func TestArchiveFileName(t *testing.T) {
t.Parallel()
const id = "3f2a1c9e-8d4b-4c1a-9e2f-0a1b2c3d4e5f"
cases := []struct {
name string
webhook string
target string
want string
}{
{
"plain names", "orders", "archive",
"archive-orders-archive-" + id + ".db",
},
{
"lowercased", "Orders", "Main Archive",
"archive-orders-main-archive-" + id + ".db",
},
{
"a run of other characters is one dash",
`a /\..b`, "c__--d",
"archive-a-b-c-d-" + id + ".db",
},
{
"no dash at either end", " --orders!! ", "(archive)",
"archive-orders-archive-" + id + ".db",
},
{
"path separators", "../../etc/passwd", "a/b",
"archive-etc-passwd-a-b-" + id + ".db",
},
{
"letters outside ASCII are dropped",
"Bestellungen Größe", "café",
"archive-bestellungen-gr-e-caf-" + id + ".db",
},
{
"nothing left is unnamed", "", "!!!",
"archive-unnamed-unnamed-" + id + ".db",
},
{
"cut to 40 characters", strings.Repeat("a", 50), "x",
"archive-" + strings.Repeat("a", 40) + "-x-" + id + ".db",
},
{
"no dash left by the cut",
strings.Repeat("a", 39) + " b", "x",
"archive-" + strings.Repeat("a", 39) + "-x-" + id + ".db",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.Equal(
t, tc.want,
archive.FileName(tc.webhook, tc.target, id),
)
})
}
}
// TestWrite_EachTargetHasItsOwnArchive proves two
// database targets of one webhook archive into separate files.
func TestWrite_EachTargetHasItsOwnArchive(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
event := newEvent(`{"n":1}`)
for _, tgt := range []*database.Target{first, second} {
require.NoError(t, env.writers.Write(
databaseTargetDelivery(event, tgt),
))
}
require.NotEqual(
t, env.archivePath(first), env.archivePath(second),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(first)),
)
assert.Equal(
t, []string{event.ID},
archivedEventIDs(t, env.archivePath(second)),
)
}
// TestRename_MovesTheFile proves a rename moves the archive, rows
// and all, and that later writes go to the new name.
func TestRename_MovesTheFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(first, tgt)))
require.FileExists(t, oldPath)
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.NoFileExists(t, oldPath)
assert.Equal(t, []string{first.ID}, archivedEventIDs(t, newPath))
second := newEvent(`{"n":2}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(second, tgt)))
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, newPath),
)
assert.NoFileExists(
t, oldPath, "a write after the rename must use the new name",
)
}
// TestRename_NeverReplacesAFile plants a file at the new name, once
// the .db alone, once a lone -wal and once a lone -shm, and proves
// each time that the rename is refused, the planted file survives,
// and the archive keeps its name and its rows.
func TestRename_NeverReplacesAFile(t *testing.T) {
t.Parallel()
for _, suffix := range archiveFileSuffixes() {
t.Run("planted .db"+suffix, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
first := newEvent(`{"n":1}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(first, tgt)),
)
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
plantedPath := newPath + suffix
require.NoError(
t, os.WriteFile(plantedPath, []byte("planted"), 0o600),
)
require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken,
)
//nolint:gosec // reads the file the test planted under t.TempDir()
planted, err := os.ReadFile(plantedPath)
require.NoError(t, err)
assert.Equal(t, "planted", string(planted))
second := newEvent(`{"n":2}`)
require.NoError(
t, env.writers.Write(databaseTargetDelivery(second, tgt)),
)
assert.ElementsMatch(
t, []string{first.ID, second.ID},
archivedEventIDs(t, oldPath),
)
})
}
}
// TestRename_BeforeTheNameIsSaved covers the order the handlers
// use: they rename before they save the new name, so a delivery in
// between must write under the new name although the main database
// still has the old one. It also shows that renaming an archive that
// does not exist yet is not an error.
func TestRename_BeforeTheNameIsSaved(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
require.NoError(
t, env.writers.Rename(tgt.ID, "Orders", "Archive"),
)
event := newEvent(`{"n":1}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
assert.FileExists(
t,
filepath.Join(
env.dataDir, "archive-orders-archive-"+tgt.ID+".db",
),
)
assert.NoFileExists(t, env.archivePath(tgt))
}
// TestArchiveWriter_RenameMovesSidecars proves a rename carries
// the -wal and -shm a crash can leave beside an archive no handle
// has opened since. SQLite finds them by name, so a -wal left
// behind would lose the transactions it holds.
func TestArchiveWriter_RenameMovesSidecars(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newPath := filepath.Join(dir, "archive-new.db")
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.NoError(t, w.Rename("archive-new.db"))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, oldPath+suffix)
assert.FileExists(t, newPath+suffix)
}
assert.Equal(t, newPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackOnFailure makes the -wal fail to
// move after the .db has moved, and proves the .db is moved back, so
// the archive is never split across two names. The new name is 255
// bytes, the longest a file name may be, so the .db can take it but
// the -wal, four bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackOnFailure(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
newName := strings.Repeat("a", 252) + ".db"
for _, suffix := range archiveFileSuffixes() {
require.NoError(
t, os.WriteFile(oldPath+suffix, []byte(suffix), 0o600),
)
}
w := archive.NewExportArchiveWriter(
oldPath, archiveTestLogger(), 0,
)
require.Error(t, w.Rename(newName))
for _, suffix := range archiveFileSuffixes() {
assert.FileExists(t, oldPath+suffix)
}
assert.NoFileExists(t, filepath.Join(dir, newName))
assert.Equal(t, oldPath, w.Path())
}
// TestArchiveWriter_RenameMovesBackEveryFile renames a target with a
// file without a period and a file for a month, and proves that when
// the month's file fails to move, the file already moved is moved back:
// both files are under the old name with their rows, and nothing is
// under the new name. The new name is 251 bytes, so the file without a
// period, with its -wal and -shm, can take it, but the month's file,
// eight bytes longer, cannot.
func TestArchiveWriter_RenameMovesBackEveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
oldPath := filepath.Join(dir, "archive-old.db")
monthPath := filepath.Join(dir, "archive-old-2026-03.db")
w := archive.NewExportArchiveWriter(oldPath, archiveTestLogger(), 0)
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-none"}, 0, "",
))
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-month"}, 0, "2026-03",
))
require.Error(t, w.Rename(strings.Repeat("a", 248)+".db"))
assert.Equal(t, []string{"in-none"}, archivedEventIDs(t, oldPath))
assert.Equal(t, []string{"in-month"}, archivedEventIDs(t, monthPath))
entries, err := os.ReadDir(dir)
require.NoError(t, err)
for _, entry := range entries {
assert.True(t, strings.HasPrefix(entry.Name(), "archive-old"),
"%s is not under the old name", entry.Name())
}
assert.Equal(t, oldPath, w.Path())
}
// TestRenameLock_HeldByOneEditAtATime holds the rename lock, as an edit
// does from loading the stored names until it has saved them. While it
// is held, the edit's rename and an archive write still finish, since
// neither takes it, and the event ends up under the new name whichever
// ran first. A second edit waits for the lock until the first lets go.
func TestRenameLock_HeldByOneEditAtATime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
event := newEvent(`{"n":1}`)
env.writers.RenameLock().Lock()
finished := make(chan error, 2)
go func() {
finished <- env.writers.Rename(tgt.ID, "Orders", "Long Term")
}()
go func() {
finished <- env.writers.Write(databaseTargetDelivery(event, tgt))
}()
for range 2 {
select {
case err := <-finished:
require.NoError(t, err)
case <-time.After(10 * time.Second):
t.Fatal("a rename or a write waited for the rename lock")
}
}
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
assert.Equal(t, []string{event.ID}, archivedEventIDs(t, newPath))
secondEdit := make(chan struct{})
go func() {
env.writers.RenameLock().Lock()
close(secondEdit)
env.writers.RenameLock().Unlock()
}()
select {
case <-secondEdit:
t.Fatal("a second edit took the rename lock while it was held")
case <-time.After(100 * time.Millisecond):
}
env.writers.RenameLock().Unlock()
select {
case <-secondEdit:
case <-time.After(10 * time.Second):
t.Fatal("a second edit did not get the rename lock once released")
}
}
-388
View File
@@ -1,388 +0,0 @@
package archive
import (
"compress/gzip"
"context"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// archiveTableQuery counts the archive's table: 0 when the archive
// writer has created the file but not yet the table in it.
const archiveTableQuery = "SELECT count(*) FROM sqlite_master " +
"WHERE type = 'table' AND name = 'archived_events'"
// Path returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the
// name FileName gives it.
func Path(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
) string {
return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)),
FileName(webhook.Name, target.Name, target.ID),
)
}
// ExportFileName returns the name a database target's archive
// downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in FileName and the time in UTC.
func ExportFileName(
webhookName, targetName string, at time.Time,
) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" +
at.UTC().Format("20060102T150405Z") + ".json.gz"
}
// Export is a database target's archive listed for download. It
// opens one of the target's files at a time, only when its rows are
// about to be written out, and closes it before it opens the next, so
// an export holds at most one file open however many the target has.
//
// Each file is read on its own connection inside one read-only
// transaction, so its rows are written out as the file stood when it
// was opened. Archives are in WAL mode, where a reader works from a
// snapshot and never blocks a writer: archive writes go on while a file
// is open, and the export does not see them. SQLite cannot checkpoint
// a -wal past an open snapshot, so the open file's -wal grows until the
// export has written that file out.
type Export struct {
// periods are the periods of the target's files when the export
// was listed, "" for the file without one, in the order
// archiveFiles lists them.
periods []string
// lock is held while currentPath is called and a file is opened,
// so that a rename, which holds it too, cannot move the file in
// between.
lock sync.Locker
// currentPath returns the path Path gives the target under
// the names stored for it now, which a rename may have changed since
// the export was listed.
currentPath func() (string, error)
log *slog.Logger
}
// exportFile is one archive file opened for an export.
type exportFile struct {
db *sql.DB
tx *gorm.DB
// period is the period in the file's name, "" for none.
period string
// empty is true for a file without the archive's table yet.
empty bool
}
// exportedName is how an export names its webhook and its target.
type exportedName struct {
ID string `json:"id"`
Name string `json:"name"`
}
// NewExport lists a database target's archive files for export,
// given the path Path gives it (see archiveFiles). It opens none
// of them. Its caller holds lock, which every rename of the target's
// files runs under (Writers.RenameLock returns it), from reading the names
// path is made of until it returns, so the files it lists are the ones
// those names give.
//
// WriteGzipJSON, called without lock held, finds each file again by its
// period under the path currentPath gives, holding lock while it does
// and while it opens the file, so a rename during the export loses no
// file. A file that is gone by then, emptied by the sweep or moved
// away, is skipped. The export never creates a file: with no files, it
// has no rows.
func NewExport(
path string,
lock sync.Locker,
currentPath func() (string, error),
log *slog.Logger,
) (*Export, error) {
files, err := archiveFiles(path)
if err != nil {
return nil, err
}
x := &Export{lock: lock, currentPath: currentPath, log: log}
for _, file := range files {
x.periods = append(x.periods, file.period)
}
return x, nil
}
// openExportFile opens one archive file for an export and takes its
// snapshot. The transaction lasts as long as ctx does.
func openExportFile(
ctx context.Context, file archiveFile, log *slog.Logger,
) (*exportFile, error) {
db, err := database.OpenSQLite(file.path, archiveModeExisting)
if err != nil {
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
}
gdb, err := gorm.Open(
sqlite.Dialector{Conn: db}, &gorm.Config{
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(log),
},
)
if err != nil {
_ = db.Close()
return nil, fmt.Errorf("opening archive %s: %w", file.path, err)
}
// ReadOnly makes the driver begin a deferred transaction in place
// of the BEGIN IMMEDIATE the connection string asks for, so the
// export never takes the archive's write lock.
tx := gdb.WithContext(ctx).Begin(&sql.TxOptions{ReadOnly: true})
if tx.Error != nil {
_ = db.Close()
return nil, fmt.Errorf(
"reading archive %s: %w", file.path, tx.Error,
)
}
// The transaction's first read is what takes the snapshot.
var tables int
err = tx.Raw(archiveTableQuery).Row().Scan(&tables)
if err != nil {
_ = tx.Rollback()
_ = db.Close()
return nil, fmt.Errorf("reading archive %s: %w", file.path, err)
}
return &exportFile{
db: db, tx: tx, period: file.period, empty: tables == 0,
}, nil
}
// WriteGzipJSON writes the export to w as one gzipped JSON object:
// webhook and target, each an id and a name; exported_at; and
// archived_events, one object per archived row, keyed by column name,
// the files in the order archiveFiles lists them. A row from a file
// named for a period has "period" beside its columns. A body that is
// not valid UTF-8 cannot be a JSON string, so it is written in base64,
// with "body_encoding": "base64" beside it.
//
// Each row is written out before the next is read, so neither the
// archive nor its JSON is ever held in memory whole, and each file is
// closed once its rows are written, before the next is opened. When it
// returns, no file is open. After an error the gzip stream is left
// unfinished, so what was written does not decompress as a whole file.
func (x *Export) WriteGzipJSON(
ctx context.Context,
w io.Writer,
webhook *database.Webhook,
target *database.Target,
exportedAt time.Time,
) error {
head, err := json.Marshal(map[string]any{
"webhook": exportedName{ID: webhook.ID, Name: webhook.Name},
"target": exportedName{ID: target.ID, Name: target.Name},
"exported_at": exportedAt.UTC(),
})
if err != nil {
return fmt.Errorf("encoding archive export: %w", err)
}
zw := gzip.NewWriter(w)
err = x.writeJSON(ctx, zw, head)
if err != nil {
return fmt.Errorf("writing archive export: %w", err)
}
return zw.Close()
}
// openFile finds the target's archive file for period under the path
// currentPath gives now, and opens it for the export, holding x.lock
// for both. For a file that is gone, the error wraps fs.ErrNotExist.
func (x *Export) openFile(
ctx context.Context, period string,
) (*exportFile, error) {
x.lock.Lock()
defer x.lock.Unlock()
path, err := x.currentPath()
if err != nil {
return nil, fmt.Errorf("finding archive file: %w", err)
}
file := archiveFile{path: archivePeriodPath(path, period), period: period}
_, err = os.Stat(file.path)
if err != nil {
return nil, err
}
return openExportFile(ctx, file, x.log)
}
// close ends the file's transaction and closes its connection.
func (f *exportFile) close() error {
_ = f.tx.Rollback()
return f.db.Close()
}
// writeJSON writes head with archived_events added as its last key,
// the rows going into it one at a time.
func (x *Export) writeJSON(
ctx context.Context, w io.Writer, head []byte,
) error {
// head goes out without its closing brace, so that
// archived_events can follow it.
_, err := w.Write(head[:len(head)-1])
if err != nil {
return err
}
_, err = io.WriteString(w, `,"archived_events":[`)
if err != nil {
return err
}
err = x.writeRows(ctx, w)
if err != nil {
return err
}
_, err = io.WriteString(w, "\n]}\n")
return err
}
// writeRows writes the archived rows of each file to w, one per line,
// separated by commas, opening each file in turn and closing it once
// its rows are written.
func (x *Export) writeRows(ctx context.Context, w io.Writer) error {
sep := "\n"
for _, period := range x.periods {
f, err := x.openFile(ctx, period)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return err
}
sep, err = f.writeRows(ctx, w, sep)
err = errors.Join(err, f.close())
if err != nil {
return err
}
}
return nil
}
// writeRows writes the file's archived rows to w, oldest first, the
// first after sep and each other after ",\n". It returns what goes
// before the next row: sep again when the file had no rows.
func (f *exportFile) writeRows(
ctx context.Context, w io.Writer, sep string,
) (string, error) {
if f.empty {
return sep, nil
}
rows, err := f.tx.WithContext(ctx).
Model(&archivedEvent{}).Order("id").Rows()
if err != nil {
return "", err
}
defer func() { _ = rows.Close() }()
for ; rows.Next(); sep = ",\n" {
var ev archivedEvent
err = f.tx.ScanRows(rows, &ev)
if err != nil {
return "", err
}
_, err = io.WriteString(w, sep)
if err != nil {
return "", err
}
err = writeRow(w, &ev, f.period)
if err != nil {
return "", err
}
}
return sep, rows.Err()
}
// writeRow writes an archived row to w as a JSON object keyed by
// column name, its body in base64 when it is not valid UTF-8, with
// the period of its file beside them unless that is "".
func writeRow(w io.Writer, ev *archivedEvent, period string) error {
row := map[string]any{
"id": ev.ID,
"event_id": ev.EventID,
"webhook_id": ev.WebhookID,
"entrypoint_id": ev.EntrypointID,
"method": ev.Method,
"raw_query": ev.RawQuery,
"headers": ev.Headers,
"body": ev.Body,
"content_type": ev.ContentType,
"archived_at": ev.ArchivedAt.UTC(),
}
if !utf8.ValidString(ev.Body) {
row["body"] = base64.StdEncoding.EncodeToString([]byte(ev.Body))
row["body_encoding"] = "base64"
}
if period != "" {
row["period"] = period
}
line, err := json.Marshal(row)
if err != nil {
return err
}
_, err = w.Write(line)
return err
}
-573
View File
@@ -1,573 +0,0 @@
package archive_test
import (
"bufio"
"bytes"
"compress/gzip"
"crypto/rand"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
)
// The webhook and the target the export tests' archives belong to.
const (
exportWebhookID = "wh-export"
exportWebhookName = "Orders (EU)"
exportTargetID = "tgt-export"
exportTargetName = "Long-term archive"
)
// binaryBody is a body that is not valid UTF-8.
const binaryBody = "\xff\xfe\x00\x01binary\x80"
// writeExportTo writes export to w as the archive of the export tests'
// webhook and target, exported at 2026-10-02T12:03:04Z.
func writeExportTo(
t *testing.T, export *archive.Export, w io.Writer,
) error {
t.Helper()
return export.WriteGzipJSON(
t.Context(), w,
&database.Webhook{
BaseModel: database.BaseModel{ID: exportWebhookID},
Name: exportWebhookName,
},
&database.Target{
BaseModel: database.BaseModel{ID: exportTargetID},
Name: exportTargetName,
},
time.Date(2026, 10, 2, 12, 3, 4, 0, time.UTC),
)
}
// listExport lists the archive at path for export, as the archive of a
// target whose names do not change.
func listExport(t *testing.T, path string) *archive.Export {
t.Helper()
return newExport(t, path, &sync.Mutex{}, func() (string, error) {
return path, nil
})
}
// newExport lists the archive at path for export, to find each file
// again under the path currentPath gives, holding lock while it does.
// Nothing else takes lock while it lists, so it does not hold lock.
func newExport(
t *testing.T,
path string,
lock sync.Locker,
currentPath func() (string, error),
) *archive.Export {
t.Helper()
export, err := archive.NewExport(
path, lock, currentPath, archiveTestLogger(),
)
require.NoError(t, err)
return export
}
// exportArchive runs a whole export of the archive at path and returns
// its JSON, decompressed and parsed.
func exportArchive(t *testing.T, path string) map[string]any {
t.Helper()
return writeExport(t, listExport(t, path))
}
// writeExport writes an opened export and returns its JSON,
// decompressed and parsed. Reading to the end makes the gzip reader
// check that the stream was finished.
func writeExport(
t *testing.T, export *archive.Export,
) map[string]any {
t.Helper()
var buf bytes.Buffer
require.NoError(t, writeExportTo(t, export, &buf))
zr, err := gzip.NewReader(&buf)
require.NoError(t, err)
raw, err := io.ReadAll(zr)
require.NoError(t, err)
var got map[string]any
require.NoError(t, json.Unmarshal(raw, &got))
return got
}
// exportedEvents returns an export's archived_events.
func exportedEvents(t *testing.T, got map[string]any) []map[string]any {
t.Helper()
list, ok := got["archived_events"].([]any)
require.True(t, ok, "archived_events must be an array: %v", got)
events := make([]map[string]any, len(list))
for i, v := range list {
events[i], ok = v.(map[string]any)
require.True(t, ok, "an archived event must be an object: %v", v)
}
return events
}
// exportedEventIDs returns the event_id of each of an export's
// archived_events.
func exportedEventIDs(t *testing.T, got map[string]any) []string {
t.Helper()
events := exportedEvents(t, got)
ids := make([]string, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
}
return ids
}
// TestArchiveExport_MatchesStoredRows proves an export holds the
// webhook, the target, the time, and every column of every stored
// row: a body that is valid UTF-8 as a string, and one that is not in
// base64, marked as such.
func TestArchiveExport_MatchesStoredRows(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies {
require.NoError(t, w.Write(archive.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
RawQuery: eventQuery,
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
}, 0))
}
var stored []archive.ExportArchivedEvent
require.NoError(t, openArchiveDBForRead(t, path).
Order("id").Find(&stored).Error)
got := exportArchive(t, path)
assert.Equal(t,
map[string]any{"id": exportWebhookID, "name": exportWebhookName},
got["webhook"],
)
assert.Equal(t,
map[string]any{"id": exportTargetID, "name": exportTargetName},
got["target"],
)
assert.Equal(t, "2026-10-02T12:03:04Z", got["exported_at"])
events := exportedEvents(t, got)
require.Len(t, events, len(bodies))
for i, row := range stored {
assertExportedRow(t, row, events[i])
}
}
// assertExportedRow checks that ev, from an export, holds every column
// of the stored row.
func assertExportedRow(
t *testing.T, row archive.ExportArchivedEvent, ev map[string]any,
) {
t.Helper()
archivedAt, err := time.Parse(
time.RFC3339Nano, fmt.Sprint(ev["archived_at"]),
)
require.NoError(t, err)
assert.True(t, archivedAt.Equal(row.ArchivedAt))
assert.EqualValues(t, row.ID, ev["id"])
assert.Equal(t, row.EventID, ev["event_id"])
assert.Equal(t, row.WebhookID, ev["webhook_id"])
assert.Equal(t, row.EntrypointID, ev["entrypoint_id"])
assert.Equal(t, row.Method, ev["method"])
assert.Equal(t, row.RawQuery, ev["raw_query"])
assert.Equal(t, row.Headers, ev["headers"])
assert.Equal(t, row.ContentType, ev["content_type"])
if row.Body != binaryBody {
assert.Equal(t, row.Body, ev["body"])
assert.Len(t, ev, 10, "the ten columns and nothing else: %v", ev)
return
}
body, err := base64.StdEncoding.DecodeString(fmt.Sprint(ev["body"]))
require.NoError(t, err)
assert.Equal(t, binaryBody, string(body))
assert.Equal(t, "base64", ev["body_encoding"])
assert.Len(t, ev, 11, "the ten columns and body_encoding: %v", ev)
}
// TestArchiveExport_Empty proves an archive with nothing in it exports
// as an empty archived_events: no file, which the export must not
// create; a file the archive writer has not yet put its table in; and
// a table with no rows.
func TestArchiveExport_Empty(t *testing.T) {
t.Parallel()
dir := t.TempDir()
missing := filepath.Join(dir, "missing.db")
noTable := filepath.Join(dir, "no-table.db")
noRows := filepath.Join(dir, "no-rows.db")
require.NoError(t, os.WriteFile(noTable, nil, 0o600))
require.NoError(t,
archive.NewExportArchiveWriter(noRows, archiveTestLogger(), 0).
Open(0),
)
for _, path := range []string{missing, noTable, noRows} {
assert.Empty(t, exportedEvents(t, exportArchive(t, path)), path)
}
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, missing+suffix)
}
}
// unlockHook is a sync.Locker that runs fn each time it is unlocked. An
// export unlocks its lock right after it opens a file.
type unlockHook struct {
sync.Mutex
fn func()
}
func (u *unlockHook) Unlock() {
u.Mutex.Unlock()
u.fn()
}
// TestArchiveExport_ReadsOneSnapshot proves an export writes a file
// out as it was when the export opened it, and holds up no archive
// write: a row written after the export was listed but before the file
// was opened is in the export, and one written while the file is open
// is stored, and is not. A write held up for the whole busy timeout
// would fail.
func TestArchiveExport_ReadsOneSnapshot(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "listed"}, 0))
opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "during"}, 0))
}}
export := newExport(t, path, opened, func() (string, error) {
return path, nil
})
require.NoError(t, w.Write(archive.ExportArchivedEvent{EventID: "before-open"}, 0))
assert.Equal(t,
[]string{"listed", "before-open"},
exportedEventIDs(t, writeExport(t, export)),
)
var stored int64
require.NoError(t, openArchiveDBForRead(t, path).
Model(&archive.ExportArchivedEvent{}).Count(&stored).Error)
assert.Equal(t, int64(3), stored)
}
// TestArchiveExport_FindsFilesAfterRename proves that renaming the
// archive after an export has listed it, as renaming its webhook or
// target does, loses no file: the export finds each file again by its
// period under the new name. A file moved away by then is skipped.
func TestArchiveExport_FindsFilesAfterRename(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "archive-old.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
current := path
export := newExport(t, path, &sync.Mutex{}, func() (string, error) {
return current, nil
})
require.NoError(t, w.Rename("archive-new.db"))
current = filepath.Join(dir, "archive-new.db")
removeArchiveFiles(t, periodPath(current, dayPeriod))
assert.Equal(t,
[]string{"in-", "in-" + hourPeriod},
exportedEventIDs(t, writeExport(t, export)),
)
}
// TestArchiveExport_EveryFileOldestFirst writes a row to a target's
// file without a period and to its files for a month, an hour and a
// day, and proves the export holds every row: the file without a
// period first, then the others oldest period first, each row from a
// file named for a period carrying that period. A file made after the
// export was listed is not in it.
func TestArchiveExport_EveryFileOldestFirst(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Written in an order that is not the export's.
for _, period := range []string{nextDayPeriod, "", hourPeriod, "2026-03"} {
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
export := listExport(t, path)
require.NoError(t, w.WritePeriod(
archive.ExportArchivedEvent{EventID: "later"}, 0, "2026-03-06",
))
events := exportedEvents(t, writeExport(t, export))
ids := make([]string, 0, len(events))
periods := make([]any, 0, len(events))
for _, ev := range events {
ids = append(ids, fmt.Sprint(ev["event_id"]))
periods = append(periods, ev["period"])
}
assert.Equal(t,
[]string{"in-", "in-2026-03", "in-" + hourPeriod, "in-" + nextDayPeriod},
ids,
)
assert.Equal(t,
[]any{nil, "2026-03", hourPeriod, nextDayPeriod}, periods,
)
assert.NotContains(t, events[0], "period",
"a row from the file without a period has no period")
}
// openFilesPeak is an io.Writer that discards what it is given and
// records the most archive files in dir the process had open at any
// write, as /proc/self/fd lists the files a process has open.
type openFilesPeak struct {
dir string
max int
}
func (p *openFilesPeak) Write(b []byte) (int, error) {
fds, err := os.ReadDir("/proc/self/fd")
if err != nil {
return 0, err
}
open := map[string]bool{}
for _, fd := range fds {
file, err := os.Readlink(filepath.Join("/proc/self/fd", fd.Name()))
if err == nil && filepath.Dir(file) == p.dir &&
strings.HasSuffix(file, ".db") {
open[file] = true
}
}
p.max = max(p.max, len(open))
return len(b), nil
}
// TestArchiveExport_OneFileOpenAtATime exports a target with a file for
// each of 24 hours and proves the export never had more than one of
// them open, and had one open while it wrote. Each file holds a row of
// 48 KiB of random base64, which gzip shrinks little, so the export
// writes output while it reads each file.
func TestArchiveExport_OneFileOpenAtATime(t *testing.T) {
t.Parallel()
if runtime.GOOS != "linux" {
t.Skip("only Linux lists a process's open files in /proc/self/fd")
}
// Readlink gives each open file's path with no symbolic link in it.
dir, err := filepath.EvalSymlinks(t.TempDir())
require.NoError(t, err)
path := filepath.Join(dir, "archive-wh.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10)
for hour := range 24 {
_, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(archive.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0, fmt.Sprintf("2026-10-01-%02d", hour)))
}
// The writer's own handle on the last file is not the export's.
w.Evict()
// Through a buffer, the open files are listed once per 8 KiB of
// output, a few times for each file, rather than at each of gzip's
// small writes, which takes far longer.
peak := &openFilesPeak{dir: dir}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, listExport(t, path), buffered))
require.NoError(t, buffered.Flush())
assert.Equal(t, 1, peak.max)
}
// heapPeak is an io.Writer that discards what it is given and records
// the largest heap it saw at a write. It collects garbage twice before
// each reading, so the heap it reads is what is still held. Once is not
// enough: the libraries the export calls (regexp, under GORM's table
// names, and encoding/json among them) cache buffers in a sync.Pool,
// which keeps them through one collection, so after one the reading
// counts however many happen to be cached. That varies from run to run
// by about as much as the limit in TestArchiveExport_Streams.
type heapPeak struct {
max uint64
}
func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats
runtime.GC()
runtime.GC()
runtime.ReadMemStats(&m)
p.max = max(p.max, m.HeapAlloc)
return len(b), nil
}
// exportHeapGrowth exports an archive of rows random bodies, each
// bodySize bytes of base64, and returns how far the heap rose above
// where it stood when the export began, at its highest.
func exportHeapGrowth(t *testing.T, rows, bodySize int) uint64 {
t.Helper()
path := filepath.Join(t.TempDir(), "archive.db")
w := archive.NewExportArchiveWriter(path, archiveTestLogger(), 0)
// Base64 makes four characters of every three bytes.
random := make([]byte, bodySize/4*3)
for range rows {
_, _ = rand.Read(random)
require.NoError(t, w.Write(archive.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0))
}
export := listExport(t, path)
// Twice, for the reason heapPeak gives.
runtime.GC()
runtime.GC()
var start runtime.MemStats
runtime.ReadMemStats(&start)
// Through a buffer, the heap is read once per 8 KiB of output
// rather than at each of gzip's small writes, which takes far
// longer.
peak := &heapPeak{max: start.HeapAlloc}
buffered := bufio.NewWriterSize(peak, 8<<10)
require.NoError(t, writeExportTo(t, export, buffered))
require.NoError(t, buffered.Flush())
return peak.max - start.HeapAlloc
}
// TestArchiveExport_Streams proves an export holds neither the archive
// nor its output in memory whole: exporting 384 KiB more of archive
// raises the heap's peak by less than half of that. The export's own
// memory, mostly gzip's compressor, is the same for both archives, so
// it cancels out. The bodies are random bytes in base64, which gzip
// shrinks by only a quarter, so an export that read every row before
// writing, or built the JSON or the gzipped file before writing it,
// would raise the peak by at least three quarters of the difference.
//
// The smaller archive has two rows so that its export, too, writes
// out more than the 8 KiB buffer in exportHeapGrowth before it ends:
// the heap must be read while the export's own memory is held.
//
//nolint:paralleltest // It measures the heap, which tests share.
func TestArchiveExport_Streams(t *testing.T) {
const (
bodySize = 16 << 10
smallRows = 2
largeRows = smallRows + 24
limit = (largeRows - smallRows) * bodySize / 2
)
small := exportHeapGrowth(t, smallRows, bodySize)
large := exportHeapGrowth(t, largeRows, bodySize)
assert.Less(t, large, small+limit,
"the heap rose by %d for %d rows and by %d for %d rows",
small, smallRows, large, largeRows,
)
}
// TestArchiveExportFileName proves the download is named for the
// webhook and the target, with the names made safe as for the archive
// file, and the export time in UTC.
func TestArchiveExportFileName(t *testing.T) {
t.Parallel()
cest := time.FixedZone("CEST", int((2 * time.Hour).Seconds()))
assert.Equal(t,
"archive-orders-eu-long-term-archive-20261002T120304Z.json.gz",
archive.ExportFileName(
exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
),
)
}
-413
View File
@@ -1,413 +0,0 @@
package archive_test
import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
)
// deliverTo archives one event to a database target, leaving the
// target's writer cached with its handle open.
func deliverTo(
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
t.Helper()
event := newEvent(`{"archived":true}`)
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
}
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets
// from the registry and closes their open handles, rather than
// leaving them alive for the process lifetime, and leaves another
// webhook's writer alone.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} {
deliverTo(t, env, tgt)
require.True(
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
"the writer should hold an open handle after a write",
)
}
env.writers.EvictWebhook(first.WebhookID)
for _, tgt := range []*database.Target{first, second} {
assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID),
"eviction should remove the registry entry",
)
assert.False(
t, env.writers.ExportArchiveHandleOpen(tgt.ID),
"eviction should close the archive handle",
)
assert.FileExists(
t, env.archivePath(tgt),
"eviction must not delete the archive file",
)
}
assert.True(
t, env.writers.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone",
)
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one
// database target leaves the writer of another target of the same
// webhook in place.
func TestEvictTarget_LeavesOtherTargets(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
doomed := env.seedDatabaseTarget(t, "")
kept := env.addDatabaseTarget(t, doomed.WebhookID, "")
deliverTo(t, env, doomed)
deliverTo(t, env, kept)
env.writers.EvictTarget(doomed.ID)
assert.False(t, env.writers.ExportHasArchiveWriter(doomed.ID))
assert.FileExists(
t, env.archivePath(doomed),
"eviction must not delete the archive file",
)
assert.True(
t, env.writers.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone",
)
}
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook or target that never had an
// archive writer, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
assert.NotPanics(t, func() {
env.writers.EvictWebhook("no-such-webhook")
env.writers.EvictWebhook("no-such-webhook")
env.writers.EvictTarget("no-such-target")
env.writers.EvictTarget("no-such-target")
})
assert.False(
t, env.writers.ExportHasArchiveWriter("no-such-target"),
"eviction must not create a writer",
)
}
// evictTestRow builds an archive row for the eviction tests.
func evictTestRow(eventID string) archive.ExportArchivedEvent {
return archive.ExportArchivedEvent{
EventID: eventID,
WebhookID: "wh-evict",
Method: http.MethodPost,
Body: `{"seeded":true}`,
}
}
// TestEvictedWriter_WriteDoesNotReopenFile is the direct test of
// the evicted guard on the write path. A writer that has left
// the registry is held by nobody, so a handle it opened could
// never be closed again: it must refuse the write outright
// rather than recreate the archive behind the registry's back.
//
// The archive file is removed before the eviction, so an
// unguarded write is unmistakable — it recreates the file.
func TestEvictedWriter_WriteDoesNotReopenFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(evictTestRow("ev-1"), 0))
require.FileExists(t, path)
// The operator moves the archive away for offline retention,
// which the write path would ordinarily undo on the next
// write by recreating the file.
require.NoError(t, os.Remove(path))
w.Evict()
err := w.Write(evictTestRow("ev-2"), 0)
require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse writes",
)
assert.NoFileExists(
t, path,
"an evicted writer must not reopen (or recreate) the "+
"archive file",
)
assert.False(
t, w.HandleOpen(),
"an evicted writer must hold no handle",
)
}
// TestEvictedWriter_SweepDoesNotReopenFile is the same test for
// the sweep path: an idle sweep that reaches a writer already
// evicted underneath it must return the sentinel rather than
// reopen a file nothing owns.
func TestEvictedWriter_SweepDoesNotReopenFile(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive-evicted.db")
w := archive.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Write(evictTestRow("ev-1"), 0))
require.FileExists(t, path)
w.Evict()
err := w.SweepExpired(time.Hour)
require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted,
"an evicted writer must refuse an idle sweep",
)
assert.False(
t, w.HandleOpen(),
"a refused sweep must not leave a handle open",
)
}
// racingWrites drives a pack of goroutines writing to one
// archive writer until each is refused, so an eviction on the
// test goroutine has to take the writer's mutex away from writes
// that are already contending for it.
type racingWrites struct {
wg sync.WaitGroup
mu sync.Mutex
sawEvicted bool
otherErr error
started chan struct{}
}
// racingWriteGoroutines is how many goroutines contend for the
// writer's mutex while the eviction lands.
const racingWriteGoroutines = 4
// startRacingWrites launches the writing goroutines. Each writes
// in a loop and stops at its first error, recording whether that
// error was the eviction sentinel. The deadline is a backstop
// against a hang, not a timing assumption: the first write after
// the eviction is refused.
func startRacingWrites(
w *archive.ExportArchiveWriter,
) *racingWrites {
r := &racingWrites{
started: make(chan struct{}, racingWriteGoroutines),
}
deadline := time.Now().Add(10 * time.Second)
r.wg.Add(racingWriteGoroutines)
for i := range racingWriteGoroutines {
go func() {
defer r.wg.Done()
first := true
for time.Now().Before(deadline) {
err := w.Write(
evictTestRow(fmt.Sprintf("ev-%d", i)), 0,
)
if first {
r.started <- struct{}{}
first = false
}
if err == nil {
continue
}
r.record(err)
return
}
}()
}
return r
}
// record classifies the error that stopped one goroutine.
func (r *racingWrites) record(err error) {
r.mu.Lock()
defer r.mu.Unlock()
if errors.Is(err, archive.ErrExportArchiveWriterEvicted) {
r.sawEvicted = true
return
}
r.otherErr = err
}
// awaitFirstWrite blocks until at least one write has run, so
// the eviction that follows is a genuine race.
func (r *racingWrites) awaitFirstWrite() {
<-r.started
}
// wait joins the goroutines and reports whether any write was
// refused with the eviction sentinel, plus any unexpected error.
func (r *racingWrites) wait() (bool, error) {
r.wg.Wait()
r.mu.Lock()
defer r.mu.Unlock()
return r.sawEvicted, r.otherErr
}
// TestEvictWebhook_RacingWriteDoesNotReopenHandle exercises the
// interleaving the evicted flag exists for: writes already
// contending for the writer's mutex when the eviction takes it.
// The write that wins the mutex after the eviction must abandon
// its work rather than reopen the archive, leaving the writer
// permanently handle-free. Run under -race.
func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
t *testing.T,
) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
// Prime the registry so the test can hold the very writer the
// eviction is about to detach.
deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
race := startRacingWrites(w)
// Evict only once writes are genuinely in flight, so the
// eviction has to contend for the writer's mutex.
race.awaitFirstWrite()
env.writers.EvictWebhook(tgt.WebhookID)
sawEvicted, otherErr := race.wait()
require.NoError(t, otherErr)
assert.True(
t, sawEvicted,
"a write after eviction must be refused",
)
assert.False(
t, w.HandleOpen(),
"no write may reopen the archive once the writer has "+
"been evicted",
)
assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID),
"the registry entry must stay gone",
)
}
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
// does not break archiving for a target that is still alive: a
// subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt)
require.True(t, env.writers.ExportHasArchiveWriter(tgt.ID))
env.writers.EvictWebhook(tgt.WebhookID)
// A fresh delivery for the same target gets a brand new
// writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt)
assert.True(
t, env.writers.ExportHasArchiveWriter(tgt.ID),
"a later delivery should recreate the writer",
)
}
// TestEvictAll_WriteAfterIsRefused proves that EvictAll, which the
// delivery engine calls when it stops, closes each archive writer the
// way deleting its webhook does: a write that reaches a writer after
// it is refused, reopens nothing and adds no row.
func TestEvictAll_WriteAfterIsRefused(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt)
w := env.writers.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
env.writers.EvictAll()
err := w.Write(evictTestRow("ev-after-stop"), 0)
require.ErrorIs(
t, err, archive.ErrExportArchiveWriterEvicted,
"a write after the stop must be refused",
)
assert.False(
t, w.HandleOpen(),
"a refused write must not reopen the archive",
)
assert.False(
t, env.writers.ExportHasArchiveWriter(tgt.ID),
"the stop should empty the registry",
)
count, err := countArchivedRows(w.Path())
require.NoError(t, err)
assert.Equal(
t, int64(1), count, "the refused row must not be written",
)
}
-280
View File
@@ -1,280 +0,0 @@
package archive
import (
"context"
"log/slog"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// ErrExportArchiveWriterEvicted exposes the sentinel returned by
// an evicted archive writer. It carries the Err prefix rather
// than this file's usual Export one because it is a sentinel
// error.
var ErrExportArchiveWriterEvicted = errArchiveWriterEvicted
// ExportArchivedEvent aliases the archive row type so black-box
// tests can construct and read archive rows.
type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics.
type ExportArchiveWriter struct {
w *archiveWriter
}
// NewExportArchiveWriter builds an archive writer for tests,
// optionally overriding the reopen debounce (a non-positive
// debounce keeps the production default).
func NewExportArchiveWriter(
path string, log *slog.Logger, debounce time.Duration,
) *ExportArchiveWriter {
w := newArchiveWriter(path, log)
if debounce > 0 {
w.debounce = debounce
}
return &ExportArchiveWriter{w: w}
}
// Write archives a row through the writer, into the file named
// without a period.
func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration,
) error {
return e.w.write(row, expiry, "")
}
// WritePeriod archives a row through the writer, into the file for
// period.
func (e *ExportArchiveWriter) WritePeriod(
row ExportArchivedEvent, expiry time.Duration, period string,
) error {
return e.w.write(row, expiry, period)
}
// Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry)
}
// Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen(
expiry time.Duration,
) error {
return e.w.reopen(e.w.path, expiry)
}
// SetNow replaces the clock the writer measures its reopen
// debounce on.
func (e *ExportArchiveWriter) SetNow(now func() time.Time) {
e.w.now = now
}
// Reopens reports how many times the file has been opened.
func (e *ExportArchiveWriter) Reopens() int {
return e.w.reopens
}
// DB returns the writer's current open handle for row
// inspection in tests.
func (e *ExportArchiveWriter) DB() *gorm.DB {
return e.w.db
}
// Path returns the archive file the writer owns.
func (e *ExportArchiveWriter) Path() string {
return e.w.path
}
// OpenExisting opens the archive without permitting creation,
// the way the idle sweep does.
func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration,
) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry)
}
// SweepExpired runs an idle sweep of the archive.
func (e *ExportArchiveWriter) SweepExpired(
expiry time.Duration,
) error {
return e.w.sweepExpired(expiry)
}
// Evict marks the writer evicted and closes its handle, exactly
// as leaving the registry does.
func (e *ExportArchiveWriter) Evict() {
e.w.evict()
}
// Rename gives the archive file a new name in the same directory,
// as a rename of the webhook or target does.
func (e *ExportArchiveWriter) Rename(name string) error {
return e.w.rename(name)
}
// HandleOpen reports whether the writer currently holds an open
// archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool {
e.w.mu.Lock()
defer e.w.mu.Unlock()
return e.w.db != nil
}
// Same reports whether both wrappers refer to the very same
// underlying archive writer, so a test can prove a registry entry
// is the writer it was handed rather than a replacement.
func (e *ExportArchiveWriter) Same(
other *ExportArchiveWriter,
) bool {
return other != nil && e.w == other.w
}
// ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is
// cached. It never creates one, so a test can hold a reference to
// the very writer an eviction is about to detach.
func (ws *Writers) ExportArchiveWriterFor(
targetID string,
) *ExportArchiveWriter {
ws.mu.Lock()
defer ws.mu.Unlock()
w, ok := ws.writers[targetID]
if !ok {
return nil
}
return &ExportArchiveWriter{w: w}
}
// ExportHasArchiveWriter reports whether the registry currently
// caches an archive writer for a target.
func (ws *Writers) ExportHasArchiveWriter(
targetID string,
) bool {
ws.mu.Lock()
defer ws.mu.Unlock()
_, ok := ws.writers[targetID]
return ok
}
// ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It
// returns false when no writer is cached.
func (ws *Writers) ExportArchiveHandleOpen(
targetID string,
) bool {
ws.mu.Lock()
w, ok := ws.writers[targetID]
ws.mu.Unlock()
if !ok {
return false
}
w.mu.Lock()
defer w.mu.Unlock()
return w.db != nil
}
// ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a
// test can prime the registry the way a delivery would.
func (ws *Writers) ExportEnsureArchiveWriter(
targetID string,
) (string, error) {
w, err := ws.writerFor(targetID)
if err != nil {
return "", err
}
return w.path, nil
}
// ExportSweepWriterFor takes a target's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines.
func (ws *Writers) ExportSweepWriterFor(
targetID string,
) (*ExportArchiveWriter, bool, error) {
w, created, err := ws.sweepWriterFor(targetID)
if err != nil {
return nil, false, err
}
return &ExportArchiveWriter{w: w}, created, nil
}
// ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does.
func (ws *Writers) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter,
) {
ws.releaseSweepWriter(targetID, w.w)
}
// NewTestSweeper builds a Sweeper backed by the given main
// database and archive writers, without the fx lifecycle.
// Intended for tests.
func NewTestSweeper(
db *database.Database,
writers *Writers,
log *slog.Logger,
) *Sweeper {
return &Sweeper{
db: db,
writers: writers,
log: log,
interval: time.Hour,
}
}
// ExportSweep runs a single archive sweep synchronously for
// tests.
func (s *Sweeper) ExportSweep(ctx context.Context) {
s.sweep(ctx)
}
// ExportStart starts the sweeper's background loop for tests.
func (s *Sweeper) ExportStart() {
s.start()
}
// ExportRegisterHooks registers the sweeper's real fx lifecycle
// hooks on a lifecycle supplied by a test, so a test can drive
// the exact OnStart/OnStop functions the application runs and
// hand OnStart the kind of context fx actually supplies.
func (s *Sweeper) ExportRegisterHooks(lc fx.Lifecycle) {
s.registerHooks(lc)
}
// ExportStop stops the sweeper's background loop for tests.
func (s *Sweeper) ExportStop(ctx context.Context) error {
return s.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the sweeper's WaitGroup
// that never observes cancellation and returns only when release
// is closed. It stands in for a prune stuck on a locked archive.
func (s *Sweeper) ExportWedgeLoop(
release <-chan struct{},
) {
s.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (s *Sweeper) ExportSetInterval(d time.Duration) {
s.interval = d
}
-198
View File
@@ -1,198 +0,0 @@
package archive
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"slices"
"strings"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// The archive rotations: how often a database target starts a new
// archive file. Every rotation but none puts the period of an event's
// receive time, in UTC, in the name of the file the event goes to,
// written in the layout beside it.
const (
archiveRotationNone = "none"
archiveRotationMonthly = "monthly"
archiveRotationDaily = "daily"
archiveRotationHourly = "hourly"
archiveMonthLayout = "2006-01"
archiveDayLayout = "2006-01-02"
archiveHourLayout = "2006-01-02-15"
)
// errArchiveRotationUnknown is returned for a rotation that is not one
// of the four.
var errArchiveRotationUnknown = errors.New(
"rotation must be none, monthly, daily or hourly",
)
// archiveFile is one of a database target's archive files, and the
// period in its name: "" for the file named without one.
type archiveFile struct {
path string
period string
}
// ValidateRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly,
// daily or hourly.
func ValidateRotation(rotation string) error {
switch rotation {
case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly:
return nil
default:
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
}
}
// ParseRotation reads the rotation from a database target's
// config JSON. An empty config or an empty rotation is none.
func ParseRotation(configJSON string) (string, error) {
if configJSON == "" {
return archiveRotationNone, nil
}
var cfg DatabaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return "", fmt.Errorf("parsing database target config: %w", err)
}
err = ValidateRotation(cfg.Rotation)
if err != nil {
return "", err
}
if cfg.Rotation == "" {
return archiveRotationNone, nil
}
return cfg.Rotation, nil
}
// archivePeriod returns the period, in UTC, that a rotation puts an
// event received at receivedAt in: "2026-10" for monthly,
// "2026-10-01" for daily, "2026-10-01-19" for hourly, and "" for none.
func archivePeriod(rotation string, receivedAt time.Time) string {
switch rotation {
case archiveRotationMonthly:
return receivedAt.UTC().Format(archiveMonthLayout)
case archiveRotationDaily:
return receivedAt.UTC().Format(archiveDayLayout)
case archiveRotationHourly:
return receivedAt.UTC().Format(archiveHourLayout)
default:
return ""
}
}
// archivePeriodPath returns the path of a database target's archive
// file for a period: path, as Path gives it, with "-" and the
// period put before its ".db". The period "" gives path itself.
func archivePeriodPath(path, period string) string {
if period == "" {
return path
}
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// PathAt returns the archive file a database target writes an
// event received at receivedAt to: Path's file, with the
// period in its name when the target rotates.
func PathAt(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
receivedAt time.Time,
) (string, error) {
rotation, err := ParseRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath(
Path(dbMgr, webhook, target),
archivePeriod(rotation, receivedAt),
), nil
}
// archiveFiles lists the database target's archive files that exist,
// given the path Path gives it: the file at path, then each
// file archivePeriodPath names for path and a period, oldest period
// first. Which rotation wrote a file does not matter, so the files of
// an earlier rotation setting are listed too.
func archiveFiles(path string) ([]archiveFile, error) {
dir := filepath.Dir(path)
entries, err := os.ReadDir(dir)
if err != nil {
return nil, fmt.Errorf("listing archive files: %w", err)
}
stem := strings.TrimSuffix(filepath.Base(path), ".db")
var files []archiveFile
for _, entry := range entries {
period, ok := archiveFilePeriod(stem, entry.Name())
if ok {
files = append(files, archiveFile{
path: filepath.Join(dir, entry.Name()),
period: period,
})
}
}
// A month sorts before the days and hours in it.
slices.SortFunc(files, func(a, b archiveFile) int {
return strings.Compare(a.period, b.period)
})
return files, nil
}
// archiveFilePeriod reports whether name is the name of an archive
// file of the target whose file name without a period is stem+".db",
// and the period in it.
func archiveFilePeriod(stem, name string) (string, bool) {
rest, ok := strings.CutPrefix(name, stem)
if !ok {
return "", false
}
rest, ok = strings.CutSuffix(rest, ".db")
if !ok {
return "", false
}
if rest == "" {
return "", true
}
period, ok := strings.CutPrefix(rest, "-")
if !ok {
return "", false
}
for _, layout := range []string{
archiveMonthLayout, archiveDayLayout, archiveHourLayout,
} {
_, err := time.Parse(layout, period)
if err == nil {
return period, true
}
}
return "", false
}
-388
View File
@@ -1,388 +0,0 @@
package archive_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
)
// The archive rotations, and the configs of a daily target and of one
// whose rotation is not one of the four.
const (
rotationNone = "none"
rotationMonthly = "monthly"
rotationDaily = "daily"
rotationHourly = "hourly"
dailyConfig = `{"rotation":"daily"}`
weeklyConfig = `{"rotation":"weekly"}`
)
// The periods the tests archive into most: two days, and an hour of
// the first.
const (
dayPeriod = "2026-03-04"
nextDayPeriod = "2026-03-05"
hourPeriod = "2026-03-04-05"
)
// periodPath returns the archive file for a period of the target whose
// file without a period is path.
func periodPath(path, period string) string {
return strings.TrimSuffix(path, ".db") + "-" + period + ".db"
}
// deliverReceivedAt archives, as a delivery to a database target
// does, an event whose receive time is receivedAt, and returns the
// event's id. The receive time is what decides a rotated archive's
// file, so setting it is how these tests move the clock across a
// period boundary.
func (env *archiveEnv) deliverReceivedAt(
t *testing.T, tgt *database.Target, receivedAt time.Time,
) string {
t.Helper()
event := newEvent(`{"n":1}`)
event.CreatedAt = receivedAt
require.NoError(t, env.writers.Write(databaseTargetDelivery(event, tgt)))
return event.ID
}
// TestWrite_RotatesAtEachPeriodBoundary writes, for each
// rotation, an event received in the last second of a period and one
// received in the first second of the next, and checks each lands in
// the file named for its own period, in UTC. Rotation none keeps both
// in the one file.
func TestWrite_RotatesAtEachPeriodBoundary(t *testing.T) {
t.Parallel()
berlin := time.FixedZone("CEST", 2*60*60)
cases := []struct {
name string
rotation string
before, after time.Time
// periods are the periods of before and after.
periods [2]string
}{
{
rotationMonthly, rotationMonthly,
time.Date(2026, 1, 31, 23, 59, 59, 0, time.UTC),
time.Date(2026, 2, 1, 0, 0, 0, 0, time.UTC),
[2]string{"2026-01", "2026-02"},
},
{
rotationDaily, rotationDaily,
time.Date(2026, 3, 4, 23, 59, 59, 0, time.UTC),
time.Date(2026, 3, 5, 0, 0, 0, 0, time.UTC),
[2]string{dayPeriod, nextDayPeriod},
},
{
// The same instants, received in a zone two hours ahead
// of UTC, where they fall on 5 March: the period is UTC's.
"daily in another zone", rotationDaily,
time.Date(2026, 3, 5, 1, 59, 59, 0, berlin),
time.Date(2026, 3, 5, 2, 0, 0, 0, berlin),
[2]string{dayPeriod, nextDayPeriod},
},
{
rotationHourly, rotationHourly,
time.Date(2026, 3, 4, 5, 59, 59, 0, time.UTC),
time.Date(2026, 3, 4, 6, 0, 0, 0, time.UTC),
[2]string{hourPeriod, "2026-03-04-06"},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+tc.rotation+`"}`)
path := env.archivePath(tgt)
first := env.deliverReceivedAt(t, tgt, tc.before)
second := env.deliverReceivedAt(t, tgt, tc.after)
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(path, tc.periods[0])))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, tc.periods[1])))
assert.NoFileExists(t, path,
"a rotated target never writes the file without a period")
})
}
t.Run(rotationNone, func(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationNone+`"}`)
first := env.deliverReceivedAt(t, tgt, cases[0].before)
second := env.deliverReceivedAt(t, tgt, cases[0].after)
assert.ElementsMatch(t, []string{first, second},
archivedEventIDs(t, env.archivePath(tgt)))
})
}
// TestWrite_EventWithoutReceiveTime proves an event whose
// receive time is not known goes to the file for the time it is
// archived, rather than to one for the year 1.
func TestWrite_EventWithoutReceiveTime(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, `{"rotation":"`+rotationMonthly+`"}`)
path := env.archivePath(tgt)
before := time.Now().UTC().Format("2006-01")
id := env.deliverReceivedAt(t, tgt, time.Time{})
file := periodPath(path, time.Now().UTC().Format("2006-01"))
_, err := os.Stat(file)
if err != nil {
// The month turned during the delivery.
file = periodPath(path, before)
}
assert.Equal(t, []string{id}, archivedEventIDs(t, file))
assert.NoFileExists(t, periodPath(path, "0001-01"))
}
// TestWrite_RotationChangeKeepsOldFiles changes a target's
// rotation from none to daily between two events, and checks the
// second goes to the daily file while the first stays where it was.
func TestWrite_RotationChangeKeepsOldFiles(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
path := env.archivePath(tgt)
at := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
first := env.deliverReceivedAt(t, tgt, at)
tgt.Config = dailyConfig
second := env.deliverReceivedAt(t, tgt, at)
assert.Equal(t, []string{first}, archivedEventIDs(t, path))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(path, dayPeriod)))
}
// TestArchiveSweep_PrunesEveryFile gives a daily target three files:
// the file without a period, left from before it rotated, and two
// daily files. Each holds a row older than the expiry, and one daily
// file also a newer row. The sweep prunes the old row from every file,
// deletes the daily file it leaves empty, and keeps the file without a
// period although it is empty too.
func TestArchiveSweep_PrunesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(
t, `{"expiry":"1h","rotation":"`+rotationDaily+`"}`,
)
path := env.archivePath(tgt)
emptied := periodPath(path, dayPeriod)
kept := periodPath(path, nextDayPeriod)
now := time.Now()
old := now.Add(-48 * time.Hour)
seedArchiveFile(t, path, tgt.WebhookID, old)
seedArchiveFile(t, emptied, tgt.WebhookID, old)
seedArchiveFile(t, kept, tgt.WebhookID, old, now.Add(-time.Minute))
env.sweeper.ExportSweep(t.Context())
assert.Empty(t, archivedEventIDs(t, path))
assert.Equal(t, []string{sweepRowNew}, archivedEventIDs(t, kept))
for _, suffix := range archiveFileSuffixes() {
assert.NoFileExists(t, emptied+suffix)
}
}
// TestRename_MovesEveryFile renames a daily target that also has a
// file without a period, and checks every file moves to the new name
// with its period, rows and all, and that a later write uses the new
// name.
func TestRename_MovesEveryFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
unrotated := env.deliverReceivedAt(t, tgt, day)
tgt.Config = dailyConfig
first := env.deliverReceivedAt(t, tgt, day)
second := env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
require.NoError(t, env.writers.Rename(tgt.ID, "Orders", "Long Term"))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
for _, old := range []string{
oldPath,
periodPath(oldPath, dayPeriod),
periodPath(oldPath, nextDayPeriod),
} {
assert.NoFileExists(t, old)
}
assert.Equal(t, []string{unrotated}, archivedEventIDs(t, newPath))
assert.Equal(t, []string{first},
archivedEventIDs(t, periodPath(newPath, dayPeriod)))
assert.Equal(t, []string{second},
archivedEventIDs(t, periodPath(newPath, nextDayPeriod)))
third := env.deliverReceivedAt(t, tgt, day.Add(48*time.Hour))
assert.Equal(t, []string{third},
archivedEventIDs(t, periodPath(newPath, "2026-03-06")))
}
// TestRename_NeverReplacesARotatedFile plants a file at the new name
// of a target's daily file, and proves the rename is refused and moves
// none of the target's files.
func TestRename_NeverReplacesARotatedFile(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, dailyConfig)
oldPath := env.archivePath(tgt)
day := time.Date(2026, 3, 4, 12, 0, 0, 0, time.UTC)
env.deliverReceivedAt(t, tgt, day)
env.deliverReceivedAt(t, tgt, day.Add(24*time.Hour))
newPath := filepath.Join(
env.dataDir, "archive-orders-long-term-"+tgt.ID+".db",
)
planted := periodPath(newPath, nextDayPeriod)
require.NoError(t, os.WriteFile(planted, []byte("planted"), 0o600))
require.ErrorIs(
t, env.writers.Rename(tgt.ID, "Orders", "Long Term"),
archive.ErrNameTaken,
)
assert.FileExists(t, periodPath(oldPath, dayPeriod))
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
}
// TestStatArchive_EveryFile proves Stat counts and adds up every
// one of a target's files, takes the latest write of any of them, and
// leaves out files whose names only look like the target's.
func TestStatArchive_EveryFile(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, "archive-wh.db")
files := []string{
path, periodPath(path, "2026-03"), periodPath(path, hourPeriod),
}
written := time.Date(2026, 3, 4, 5, 6, 7, 0, time.UTC)
var size int64
for i, file := range files {
require.NoError(t, os.WriteFile(file, make([]byte, 100*(i+1)), 0o600))
size += int64(100 * (i + 1))
at := written.Add(-time.Duration(i) * time.Hour)
require.NoError(t, os.Chtimes(file, at, at))
}
for _, other := range []string{
"archive-wh-2026-13.db", "archive-wh-2026-3.db",
"archive-wh-other.db", "archive-wh-2026-03.json",
"archive-whx.db",
} {
require.NoError(t,
os.WriteFile(filepath.Join(dir, other), []byte("x"), 0o600))
}
got, err := archive.Stat(path)
require.NoError(t, err)
assert.Equal(t, len(files), got.Files)
assert.Equal(t, size, got.Size)
assert.True(t, written.Equal(got.Written), got.Written)
}
// TestArchivePathAt names the file each rotation writes an event to.
func TestArchivePathAt(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
dbMgr := databasetest.NewWebhookDBManager(t, dataDir)
webhook := &database.Webhook{
BaseModel: database.BaseModel{ID: "wh-id"}, Name: "Orders",
}
at := time.Date(2026, 10, 1, 19, 30, 0, 0, time.UTC)
cases := map[string]string{
"": "",
`{"rotation":"` + rotationNone + `"}`: "",
`{"rotation":"` + rotationMonthly + `"}`: "-2026-10",
dailyConfig: "-2026-10-01",
`{"rotation":"` + rotationHourly + `"}`: "-2026-10-01-19",
}
for config, period := range cases {
target := &database.Target{
BaseModel: database.BaseModel{ID: "tgt-id"},
Name: "Archive",
Config: config,
}
got, err := archive.PathAt(dbMgr, webhook, target, at)
require.NoError(t, err, config)
assert.Equal(t,
filepath.Join(
dataDir, "archive-orders-archive-tgt-id"+period+".db",
),
got, config,
)
}
_, err := archive.PathAt(dbMgr, webhook, &database.Target{
Config: weeklyConfig,
}, at)
require.Error(t, err)
}
// TestValidateArchiveRotation accepts the four rotations, and empty,
// and refuses anything else.
func TestValidateArchiveRotation(t *testing.T) {
t.Parallel()
for _, ok := range []string{
"", rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
require.NoError(t, archive.ValidateRotation(ok), ok)
}
for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, archive.ValidateRotation(bad), bad)
}
}
-253
View File
@@ -1,253 +0,0 @@
package archive
import (
"context"
"errors"
"log/slog"
"sync"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger"
)
// SweeperParams holds the fx dependencies for the
// Sweeper.
type SweeperParams struct {
fx.In
Config *config.Config
Database *database.Database
Writers *Writers
Logger *logger.Logger
}
// Sweeper periodically prunes expired rows from the
// archive databases of database targets that carry a positive
// expiry.
//
// Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an
// archive whose target has stopped receiving events would keep
// its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry
// is unset or "never".
//
// It reuses Config.RetentionSweepInterval rather than
// introducing a second interval: this is a retention sweep with
// the same semantics as the event retention reaper.
type Sweeper struct {
db *database.Database
writers *Writers
log *slog.Logger
interval time.Duration
// cancel needs no lock: fx calls the stop hook only after the
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewSweeper creates the archive sweeper and registers
// its fx lifecycle hooks. The background sweep loop starts on
// OnStart and stops cleanly on OnStop via context cancellation.
func NewSweeper(
lc fx.Lifecycle,
params SweeperParams,
) *Sweeper {
s := &Sweeper{
db: params.Database,
writers: params.Writers,
log: params.Logger.Get(),
interval: params.Config.RetentionSweepInterval,
}
s.registerHooks(lc)
return s
}
// registerHooks wires the sweeper's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored
// (see start for why the background loop must not inherit it);
// the stop hook's context is honoured (see stop).
func (s *Sweeper) registerHooks(lc fx.Lifecycle) {
lc.Append(fx.Hook{
//nolint:contextcheck // Not passing the hook context is
// the point: see start.
OnStart: func(_ context.Context) error {
s.start()
return nil
},
OnStop: func(ctx context.Context) error {
return s.stop(ctx)
},
})
}
// start launches the background sweep loop.
//
// The loop's context is derived from context.Background(), NOT
// from the fx OnStart hook context. The hook context carries
// fx's start timeout (15s by default), so a loop derived from it
// is cancelled 15 seconds after the application starts — long
// before the first tick under the default one-hour sweep
// interval, leaving a sweeper that never sweeps. A long-lived
// goroutine must outlive the startup phase, so its lifetime is
// bounded by OnStop instead: stop cancels this context and waits
// on the WaitGroup.
func (s *Sweeper) start() {
ctx, cancel := context.WithCancel(context.Background())
s.cancel = cancel
s.wg.Add(1)
go s.run(ctx)
s.log.Info(
"archive sweeper started",
"interval", s.interval.String(),
)
}
// stop cancels the sweep loop's context and waits for it to
// exit, bounded by the stop hook's context: a prune wedged on a
// locked archive must not hang the process past fx's stop
// timeout.
func (s *Sweeper) stop(ctx context.Context) error {
s.log.Info("archive sweeper stopping")
if s.cancel != nil {
s.cancel()
}
err := lifecycle.WaitForShutdown(
ctx, s.log, "archive sweeper", &s.wg,
)
if err != nil {
return err
}
s.log.Info("archive sweeper stopped")
return nil
}
func (s *Sweeper) run(ctx context.Context) {
defer s.wg.Done()
ticker := time.NewTicker(s.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
s.sweep(ctx)
}
}
}
// sweep prunes every archive whose database target declares a
// positive expiry. Targets belonging to a deleted webhook are
// soft-deleted along with it, so GORM's default scope already
// excludes them.
//
// A failure for one target is logged and the sweep continues,
// matching how the write path already treats a prune error as
// non-fatal.
func (s *Sweeper) sweep(ctx context.Context) {
var targets []database.Target
err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error
if err != nil {
// The app stopping as a sweep starts cancels the listing.
// Stopping is not a failure, so it must not produce an
// error line.
if ctx.Err() != nil {
return
}
s.log.Error(
"archive sweep: failed to list database targets",
"error", err,
)
return
}
for i := range targets {
select {
case <-ctx.Done():
return
default:
}
s.sweepTarget(&targets[i])
}
}
// sweepTarget prunes the archive of a single database target.
// A missing, empty, or "never" expiry parses as a zero duration
// and is skipped entirely, so those archives are never pruned.
func (s *Sweeper) sweepTarget(target *database.Target) {
expiry, err := ParseExpiry(target.Config)
if err != nil {
s.log.Error(
"archive sweep: invalid database target config",
"webhook_id", target.WebhookID,
"target_id", target.ID,
"error", err,
)
return
}
if expiry <= 0 {
return
}
if s.writers == nil {
return
}
err = s.writers.sweepArchive(target.ID, expiry)
if err == nil {
return
}
// A writer evicted, or a target row gone, underneath the sweep
// means the operator deleted the target or its webhook while
// the sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an
// error line.
if errors.Is(err, errArchiveWriterEvicted) ||
errors.Is(err, gorm.ErrRecordNotFound) {
s.log.Debug(
"archive sweep: target deleted mid-sweep",
"webhook_id", target.WebhookID,
"target_id", target.ID,
)
return
}
s.log.Error(
"archive sweep: failed to prune archive",
"webhook_id", target.WebhookID,
"target_id", target.ID,
"error", err,
)
}
File diff suppressed because it is too large Load Diff
-694
View File
@@ -1,694 +0,0 @@
package archive
import (
"encoding/json"
"errors"
"fmt"
"io/fs"
"log/slog"
"os"
"path/filepath"
"sync"
"time"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// ExpiryNever is the expiry sentinel (and default) that
// disables pruning so archived rows are kept forever.
const ExpiryNever = "never"
// archiveReopenDebounce bounds how often an archive file is
// closed and reopened. After each write the handle is closed
// and reopened so an operator can move the file away for
// offline archiving, but never more than once per this window.
const archiveReopenDebounce = time.Second
const (
// archiveModeCreate is the SQLite URI mode used by the write
// path: open the archive file, creating it if missing, so a
// first write (or a write after the operator moved the file
// away) recreates it.
archiveModeCreate = database.SQLiteModeCreate
// archiveModeExisting is the SQLite URI mode used by the idle
// sweep: open read-write but never create. A sweep must never
// conjure an empty archive file for a webhook that has a
// database target but has never received an event.
archiveModeExisting = database.SQLiteModeExisting
)
var (
// errArchiveMissingWebhookID is returned when an event to
// archive has no webhook id to record in its archive row.
errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id",
)
// errArchiveNoDataDir is returned when the database target
// has no webhook database manager and so cannot locate the
// data directory for archive files.
errArchiveNoDataDir = errors.New(
"database target has no data directory",
)
// errArchiveExpiryNotPositive is returned when a
// user-supplied archive expiry parses as a duration but is
// zero or negative; "never" is the way to disable pruning.
errArchiveExpiryNotPositive = errors.New(
"expiry must be a positive duration or \"never\"",
)
// errArchiveWriterEvicted is returned when a writer that has
// been evicted (its target or its webhook was deleted) is used
// again. An evicted writer is no longer in the registry, so
// reopening its file would leak a handle nothing owns.
errArchiveWriterEvicted = errors.New(
"archive writer has been evicted",
)
// ErrNameTaken is returned when an archive cannot be
// renamed because a file already has the new name. That file may
// be an archive with rows of its own, so it is never replaced.
ErrNameTaken = errors.New(
"a file already has the archive's new name",
)
)
// DatabaseTargetConfig is the optional per-target JSON config
// for a database (archive) target.
type DatabaseTargetConfig struct {
// Expiry is a Go duration (e.g. "720h") after which
// archived rows are pruned, or "never" (the default) to
// keep them forever.
Expiry string `json:"expiry"`
// Rotation is none (the default), monthly, daily or hourly: see
// archivePeriod.
Rotation string `json:"rotation"`
}
// archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a
// self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention.
type archivedEvent struct {
ID uint `gorm:"primaryKey;autoIncrement"`
EventID string `gorm:"index"`
WebhookID string
EntrypointID string
Method string
RawQuery string
Headers string
Body string
ContentType string
// ArchivedAt is when the row was archived and is the age
// basis for expiry pruning.
ArchivedAt time.Time `gorm:"index"`
}
// ParseExpiry reads the optional expiry from a database
// target's config JSON. An empty config, an empty expiry, or
// the literal "never" all mean keep forever, returned as a zero
// duration. Any other value must parse as a positive Go
// duration; a set-but-invalid value (unparseable, zero, or
// negative) is an error rather than a silent default, matching
// ValidateExpiry at target creation.
func ParseExpiry(
configJSON string,
) (time.Duration, error) {
if configJSON == "" {
return 0, nil
}
var cfg DatabaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return 0, fmt.Errorf(
"parsing database target config: %w", err,
)
}
if cfg.Expiry == "" || cfg.Expiry == ExpiryNever {
return 0, nil
}
dur, err := time.ParseDuration(cfg.Expiry)
if err != nil {
return 0, fmt.Errorf(
"parsing archive expiry %q: %w", cfg.Expiry, err,
)
}
if dur <= 0 {
return 0, fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, cfg.Expiry,
)
}
return dur, nil
}
// ValidateExpiry checks a user-supplied archive expiry
// for a database target at configuration time. Valid values are
// empty, "never" (both meaning keep forever), or a positive Go
// duration such as "720h". Anything else is an error, so a bad
// expiry is rejected when the target is created rather than
// failing every subsequent delivery.
func ValidateExpiry(expiry string) error {
if expiry == "" || expiry == ExpiryNever {
return nil
}
dur, err := time.ParseDuration(expiry)
if err != nil {
return fmt.Errorf(
"expiry must be %q or a Go duration "+
"such as \"720h\": %w",
ExpiryNever, err,
)
}
if dur <= 0 {
return fmt.Errorf(
"%w: %q", errArchiveExpiryNotPositive, expiry,
)
}
return nil
}
// archiveWriter owns one database target's archive SQLite files.
// It serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the
// file is opened create-if-missing and its schema is migrated
// on every open.
type archiveWriter struct {
mu sync.Mutex
// path is the target's archive file as Path names it. A
// target that rotates writes to the files archivePeriodPath names
// for path and a period instead.
path string
// current is the file db is open on.
current string
log *slog.Logger
debounce time.Duration
db *gorm.DB
lastReopen time.Time
reopens int
// now is the clock the reopen debounce is measured on. It is
// time.Now outside tests.
now func() time.Time
// evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the
// file again: nothing holds it any more, so a reopen would
// leak the handle for the process lifetime.
evicted bool
// webhookID is the webhook the archive's target belongs to,
// so deleting the webhook can find its writers. It is set
// when the writer is created and never changes.
webhookID string
// sweepOwned marks a registry entry that the idle sweep
// created because no writer was cached for the target. The
// sweep removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry
// for a target that has been deleted. A delivery that adopts
// the writer clears the flag, handing the entry to the
// registry proper.
//
// Unlike every other field here it is guarded by
// Writers.mu, not by this writer's mu: it describes the
// registry entry rather than the file.
sweepOwned bool
}
// newArchiveWriter builds an archiveWriter for a file path with
// the default reopen debounce.
func newArchiveWriter(
path string, log *slog.Logger,
) *archiveWriter {
return &archiveWriter{
path: path,
log: log,
debounce: archiveReopenDebounce,
now: time.Now,
}
}
// write appends the event as a row to the archive file for period
// (see archivePeriodPath), then applies the debounced close/reopen.
// When period names a different file from the one open, the open one
// is closed first. It recreates the archive file if it was moved or
// removed since the last open. A positive expiry prunes rows older
// than it on each (re)open.
func (w *archiveWriter) write(
row archivedEvent, expiry time.Duration, period string,
) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
file := archivePeriodPath(w.path, period)
if w.db == nil || w.current != file || !fileExists(file) {
err := w.reopen(file, expiry)
if err != nil {
return err
}
}
// GORM's clock, the one that stamps created_at in the other
// databases, so this is stored in the same form. A bare time.Now()
// carries a monotonic clock reading, which the driver would store
// as an " m=+…" suffix.
row.ArchivedAt = w.db.NowFunc()
err := w.db.Create(&row).Error
if err != nil {
return fmt.Errorf(
"archiving event to %s: %w", file, err,
)
}
if w.now().Sub(w.lastReopen) >= w.debounce {
return w.reopen(file, expiry)
}
return nil
}
// open opens (creating if missing) an archive file, migrates
// its schema, records the reopen time, and prunes expired rows
// when expiry is positive.
func (w *archiveWriter) open(file string, expiry time.Duration) error {
return w.openMode(file, archiveModeCreate, expiry)
}
// openMode opens an archive file with the given SQLite URI
// mode, migrates its schema, records the reopen time, and
// prunes expired rows when expiry is positive. The write path
// passes archiveModeCreate so a missing file is recreated; the
// idle sweep passes archiveModeExisting so a missing file is an
// error rather than a newly conjured empty archive.
func (w *archiveWriter) openMode(
file, mode string, expiry time.Duration,
) error {
// Opened through database.OpenSQLite so an archive file carries
// the same WAL journaling, busy timeout, immediate-transaction
// locking, and pool bounds as every other database file. See
// internal/database/sqlite_open.go.
sqlDB, err := database.OpenSQLite(file, mode)
if err != nil {
return fmt.Errorf(
"opening archive database %s: %w", file, err,
)
}
gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(w.log),
},
)
if err != nil {
_ = sqlDB.Close()
return fmt.Errorf(
"connecting to archive database %s: %w",
file, err,
)
}
err = migrations.Apply(gdb, migrations.Archive)
if err != nil {
_ = sqlDB.Close()
return fmt.Errorf(
"migrating archive database %s: %w", file, err,
)
}
w.db = gdb
w.current = file
w.lastReopen = w.now()
w.reopens++
if expiry > 0 {
w.prune(expiry)
}
return nil
}
// reopen closes any open handle and opens file afresh. The
// fresh open recreates the file if it was moved away.
func (w *archiveWriter) reopen(file string, expiry time.Duration) error {
w.close()
return w.open(file, expiry)
}
// close closes the underlying handle, if any.
func (w *archiveWriter) close() {
if w.db == nil {
return
}
sqlDB, err := w.db.DB()
if err == nil {
_ = sqlDB.Close()
}
w.db = nil
}
// sweepExpired prunes the target's archive files, which may have
// gone idle, with no write to trigger the usual on-reopen prune. It
// lists the files under the writer's own mutex, then takes the mutex
// again for one file at a time, so a write waits for at most one
// file's prune, and each prune is ordered against concurrent writes
// rather than reaching around them to the file.
//
// It never creates an archive file: it prunes only the files
// archiveFiles lists, skips one that is gone by the time it is
// reached (moved away, or renamed since the listing), and opens each
// with archiveModeExisting so SQLite itself refuses to create one if
// the file disappears between the check and the open. A file named
// for a period that the prune leaves empty is deleted.
//
// The archive is left CLOSED afterwards. An idle archive holding
// no handle is what keeps the operator's move-the-file-away
// workflow working; the next write reopens (and recreates) the
// file as it always has.
func (w *archiveWriter) sweepExpired(expiry time.Duration) error {
w.mu.Lock()
files, err := archiveFiles(w.path)
w.mu.Unlock()
if err != nil {
return err
}
var errs []error
for _, file := range files {
err = w.sweepFile(file, expiry)
if errors.Is(err, errArchiveWriterEvicted) {
return err
}
if err != nil {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// sweepFile prunes one of the target's archive files for sweepExpired,
// holding w.mu while it does. It skips a file that is gone, and deletes
// the file, with its -wal and -shm, when it is named for a period and
// the prune leaves it empty.
func (w *archiveWriter) sweepFile(
file archiveFile, expiry time.Duration,
) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
if !fileExists(file.path) {
return nil
}
// Drop any live handle first so the prune runs against a
// freshly opened file, matching the write path's semantics.
w.close()
err := w.openMode(file.path, archiveModeExisting, expiry)
if err != nil {
return err
}
if file.period == "" {
w.close()
return nil
}
var rows int64
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
w.close()
if err != nil {
return fmt.Errorf(
"counting rows in archive %s: %w", file.path, err,
)
}
if rows > 0 {
return nil
}
for _, suffix := range []string{"", "-wal", "-shm"} {
err = os.Remove(file.path + suffix)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("deleting empty archive file: %w", err)
}
}
w.log.Info("deleted empty archive file", "path", file.path)
return nil
}
// rename gives every one of the target's archive files the new
// name, keeping the period in the name of each (see
// archivePeriodPath), and the writer uses the files under that name
// from now on. The handle is closed first, which folds the -wal into
// the .db; any -wal or -shm still beside a file (left by a crash) is
// moved with it, because SQLite finds them by name. A target with no
// files is not an error: the operator may have moved them away, and
// the next write creates its file under the new name.
//
// If a file already has one of the new names, nothing is moved and
// the error is ErrNameTaken. If one file fails to move, those
// already moved are moved back before the error is returned, so the
// archive is never split across two names.
func (w *archiveWriter) rename(name string) error {
w.mu.Lock()
defer w.mu.Unlock()
if w.evicted {
return fmt.Errorf(
"%w: %s", errArchiveWriterEvicted, w.path,
)
}
path := filepath.Join(filepath.Dir(w.path), name)
if path == w.path {
return nil
}
files, err := archiveFiles(w.path)
if err != nil {
return err
}
// from[i] moves to to[i].
var from, to []string
for _, file := range files {
renamed := archivePeriodPath(path, file.period)
for _, suffix := range []string{"", "-wal", "-shm"} {
from = append(from, file.path+suffix)
to = append(to, renamed+suffix)
}
}
for _, taken := range to {
if fileExists(taken) {
return fmt.Errorf(
"%w: %s", ErrNameTaken, filepath.Base(taken),
)
}
}
w.close()
for i := range from {
err = os.Rename(from[i], to[i])
if err == nil || errors.Is(err, fs.ErrNotExist) {
continue
}
for j := range i {
backErr := os.Rename(to[j], from[j])
if backErr != nil && !errors.Is(backErr, fs.ErrNotExist) {
w.log.Error(
"failed to move archive file back",
"from", to[j],
"to", from[j],
"error", backErr,
)
}
}
return fmt.Errorf(
"renaming archive %s to %s: %w", from[i], to[i], err,
)
}
w.path = path
return nil
}
// evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, because its target
// or its webhook was deleted, or at shutdown. The archive FILE is
// deliberately left on disk: it is long-term storage an operator
// may still want.
func (w *archiveWriter) evict() {
w.mu.Lock()
defer w.mu.Unlock()
w.evicted = true
w.close()
}
// prune deletes archived rows older than expiry, measured from
// each row's archived time. It runs on every (re)open, so a
// steadily written archive is swept by its own write traffic. An
// archive that goes idle receives no further reopens, which is
// why Sweeper exists to drive sweepExpired on a timer.
// Failures are logged, not fatal: a prune error must not stop
// archiving.
func (w *archiveWriter) prune(expiry time.Duration) {
cutoff := time.Now().Add(-expiry)
res := w.db.Where("archived_at < ?", cutoff).
Delete(&archivedEvent{})
if res.Error != nil {
w.log.Error(
"failed to prune expired archive rows",
"path", w.current,
"error", res.Error,
)
return
}
if res.RowsAffected > 0 {
w.log.Info(
"pruned expired archive rows",
"path", w.current,
"rows_deleted", res.RowsAffected,
)
}
}
// FileInfo is what the metadata of a database target's archive
// files says about them.
type FileInfo struct {
// Files counts the files.
Files int
// Size is the bytes on disk of the files and their -wal together.
Size int64
// Written is when a file or a -wal was last modified, whichever is
// latest: a write lands in the -wal first.
Written time.Time
}
// Stat reads the metadata of a database target's archive
// files, given the path Path gives it (see archiveFiles), and
// of their -wal, without opening them. With no files, which is so
// before the first write and after the operator moved them away, the
// error wraps fs.ErrNotExist.
func Stat(path string) (FileInfo, error) {
files, err := archiveFiles(path)
if err != nil {
return FileInfo{}, err
}
var info FileInfo
for _, file := range files {
db, err := os.Stat(file.path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return FileInfo{}, err
}
info.Files++
info.Size += db.Size()
if db.ModTime().After(info.Written) {
info.Written = db.ModTime()
}
wal, err := os.Stat(file.path + "-wal")
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return FileInfo{}, err
}
info.Size += wal.Size()
if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime()
}
}
if info.Files == 0 {
return FileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist,
)
}
return info, nil
}
// fileExists reports whether a path currently exists.
func fileExists(path string) bool {
_, err := os.Stat(path)
return err == nil
}
-155
View File
@@ -1,155 +0,0 @@
package archive_test
import (
"bytes"
"log"
"log/slog"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
gormlogger "gorm.io/gorm/logger"
"sneak.berlin/go/webhooker/internal/archive"
"sneak.berlin/go/webhooker/internal/middleware"
)
// archiveGORMTailMarker sits at the far end of the value this file
// drives into an archive lookup. Its presence in a log line means the
// whole value reached the log, so nothing truncated it.
const archiveGORMTailMarker = "ENDOFCLIENTVALUE"
// archiveGORMFillBytes is how much text the lookup carries. It is far
// past every budget in play.
const archiveGORMFillBytes = 8 << 10
// gormDefaultBuf collects what GORM's package-level default logger
// writes, if anything reaches it.
type gormDefaultBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (g *gormDefaultBuf) Write(p []byte) (int, error) {
g.mu.Lock()
defer g.mu.Unlock()
return g.b.Write(p)
}
func (g *gormDefaultBuf) String() string {
g.mu.Lock()
defer g.mu.Unlock()
return g.b.String()
}
// captureArchiveGORMDefault replaces GORM's package-level default
// logger with one configured exactly as GORM configures its own,
// writing to a buffer.
//
// This duplicates the detector in internal/handlers rather than
// sharing it: a test helper cannot cross a package's test boundary
// without exporting production code to carry it, and a logging
// detector is not worth a production symbol. What it detects is the
// third gorm.Open in this service, at
// internal/archive/writer.go — the archive writer,
// whose type is unexported, so nothing outside this package can drive
// it.
func captureArchiveGORMDefault(t *testing.T) *gormDefaultBuf {
t.Helper()
buf := &gormDefaultBuf{}
orig := gormlogger.Default
gormlogger.Default = gormlogger.New(
log.New(buf, "", log.LstdFlags),
gormlogger.Config{
SlowThreshold: 200 * time.Millisecond,
LogLevel: gormlogger.Warn,
IgnoreRecordNotFoundError: false,
Colorful: false,
},
)
t.Cleanup(func() { gormlogger.Default = orig })
return buf
}
// TestArchiveWriter_NeverUsesGORMsDefaultLogger pins the archive
// writer's gorm.Open to the adapter.
//
// Restore a bare &gorm.Config{} at
// internal/archive/writer.go and this fails: the
// default logger prints the fully interpolated SELECT on every
// ErrRecordNotFound, so the client-chosen event id below arrives whole
// and unbounded on stdout, answering to no level the operator set.
//
// Not parallel: gormlogger.Default is process-global. Go runs every
// non-parallel top-level test to completion before it resumes the
// parallel ones.
//
//nolint:paralleltest // Deliberately sequential; see above.
func TestArchiveWriter_NeverUsesGORMsDefaultLogger(t *testing.T) {
var captured bytes.Buffer
gormDefault := captureArchiveGORMDefault(t)
w := archive.NewExportArchiveWriter(
filepath.Join(t.TempDir(), "archive.db"),
slog.New(slog.NewTextHandler(
&captured, &slog.HandlerOptions{Level: slog.LevelDebug},
)),
0,
)
require.NoError(t, w.Open(0))
t.Cleanup(w.Evict)
// A lookup that misses, carrying a value the size of an inbound
// event id. Under the default logger this is the line that gets
// interpolated and printed.
value := strings.Repeat("\x01", archiveGORMFillBytes) +
archiveGORMTailMarker
var row archive.ExportArchivedEvent
err := w.DB().Where("event_id = ?", value).First(&row).Error
require.ErrorIs(t, err, gorm.ErrRecordNotFound)
got := gormDefault.String()
assert.Empty(
t, got,
"GORM's default logger wrote %d bytes, so the archive "+
"writer's gorm.Open is back on a bare &gorm.Config{}; "+
"the first of them: %s",
len(got), got[:min(len(got), 300)],
)
// The adapter drops a miss, so this should be silent too — and
// whatever it does write stays inside the stated ceiling.
out := captured.String()
assert.NotContains(
t, out, archiveGORMTailMarker,
"the far end of the client-chosen value reached the log",
)
for line := range strings.SplitSeq(strings.TrimRight(out, "\n"), "\n") {
if line == "" {
continue
}
assert.LessOrEqual(
t, len(line), middleware.MaxAccessLogLineBytes,
"log line exceeded its bound: %s",
line[:min(len(line), 300)],
)
}
}
-47
View File
@@ -1,47 +0,0 @@
// Package banner renders the operator-facing blocks that carry a
// plaintext credential.
//
// A generated password printed as one more structured log line is lost:
// a boot writes roughly 45 fx PROVIDE/RUN/HOOK lines around it, and
// under `docker run -d` it is one line in a log subject to rotation. A
// credential that is shown exactly once has to be findable by eye when
// an operator scrolls back, so it is written as a ruled block rather
// than as a log record.
//
// It is deliberately not a log line: it goes straight to the writer the
// caller names — standard output for both the first-boot account and
// the `resetpw` subcommand — so it is neither levelled, filtered, nor
// rendered as JSON by whichever handler internal/logger installed.
package banner
import (
"fmt"
"io"
"strings"
)
// ruleWidth is the length of the horizontal rules, chosen to fit an
// 80-column terminal without wrapping.
const ruleWidth = 72
// Credentials writes a ruled block naming an account and its plaintext
// password. headline says which event produced it, and note says what
// the operator must do about it; both are written verbatim, so a
// multi-line note must already be wrapped.
func Credentials(
w io.Writer,
headline, username, password, note string,
) error {
rule := strings.Repeat("=", ruleWidth)
_, err := fmt.Fprintf(
w,
"\n%s\n%s\n\n username: %s\n password: %s\n\n%s\n%s\n\n",
rule, headline, username, password, note, rule,
)
if err != nil {
return fmt.Errorf("writing credentials banner: %w", err)
}
return nil
}
-59
View File
@@ -1,59 +0,0 @@
package banner_test
import (
"bytes"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/banner"
)
// TestCredentials_IsFindableByEye pins the properties that make the
// block worth having: rules above and below it, the two fields on
// their own lines, and blank lines separating it from whatever the
// surrounding log wrote.
func TestCredentials_IsFindableByEye(t *testing.T) {
t.Parallel()
var out bytes.Buffer
require.NoError(t, banner.Credentials(
&out, "HEADLINE", "admin", "s3cret", "NOTE",
))
got := out.String()
lines := strings.Split(strings.Trim(got, "\n"), "\n")
require.GreaterOrEqual(t, len(lines), 3)
assert.Equal(t, lines[0], lines[len(lines)-1], "rules must match")
assert.Greater(
t, len(lines[0]), 40, "the rule must be visible at a glance",
)
assert.Equal(t, strings.Repeat("=", len(lines[0])), lines[0])
assert.Contains(t, got, "\n username: admin\n")
assert.Contains(t, got, "\n password: s3cret\n")
assert.Contains(t, got, "HEADLINE")
assert.Contains(t, got, "NOTE")
assert.True(t, strings.HasPrefix(got, "\n"))
}
// failingWriter reports the write error a banner must not swallow: it
// is the one copy of a password that will never be shown again.
type failingWriter struct{}
func (failingWriter) Write([]byte) (int, error) {
return 0, assert.AnError
}
func TestCredentials_ReportsAWriteFailure(t *testing.T) {
t.Parallel()
err := banner.Credentials(
failingWriter{}, "HEADLINE", "admin", "s3cret", "NOTE",
)
require.ErrorIs(t, err, assert.AnError)
}
-37
View File
@@ -1,37 +0,0 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
)
// TestClearEnv_RemovesAddedVariables pins that a variable set
// after the clear other than through t.Setenv, as a test's .env file
// sets one, is gone once the test ends, so it cannot reach the tests
// that run after it.
//
//nolint:paralleltest // ClearEnv uses t.Setenv.
func TestClearEnv_RemovesAddedVariables(t *testing.T) {
// The outer clear keeps a value of the key exported in the shell
// from making it a variable the inner clear has to put back.
configtest.ClearEnv(t)
t.Run("loads a .env file after the clear", func(t *testing.T) {
configtest.ClearEnv(t)
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
require.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
})
_, present := os.LookupEnv(dotEnvKey)
assert.False(
t, present,
"a variable set after the clear must not outlive the test",
)
}
+61 -761
View File
@@ -4,19 +4,18 @@ package config
import (
"errors"
"fmt"
"io/fs"
"log/slog"
"net/netip"
"os"
"strconv"
"strings"
"time"
"github.com/getsentry/sentry-go"
"github.com/joho/godotenv"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
// Populates the environment from a ./.env file automatically for
// development configuration. Kept in one place only (here).
_ "github.com/joho/godotenv/autoload"
)
const (
@@ -25,124 +24,14 @@ const (
// EnvironmentProd represents production environment.
EnvironmentProd = "prod"
// DefaultDataDir is where all SQLite databases live when DATA_DIR
// is unset. The same default applies in every environment.
DefaultDataDir = "/var/lib/webhooker"
// defaultPort is the default HTTP listen port.
defaultPort = 8080
// defaultBindAddress is the interface the plaintext HTTP
// listener claims when BIND_ADDRESS is unset.
//
// Loopback, because the listener speaks cleartext and serves
// both the admin UI and the unauthenticated receiver: a
// wildcard default publishes them on every interface of every
// host that never configured anything, which is the failure
// this default exists to prevent. Reaching webhooker from off
// the host is then a deliberate act — a reverse proxy in front
// of it, or an explicit BIND_ADDRESS.
//
// This is the binary's default only. The Dockerfile ships
// ENV BIND_ADDRESS=0.0.0.0, so a container deployment needs
// nothing set and is unaffected by this constant. The two
// differ because they answer different questions: a container's
// network namespace is already the boundary this default is
// reaching for, so binding every address inside it exposes
// nothing, and what decides exposure there is the publish flag
// (-p 127.0.0.1:8080:8080). A loopback bind inside a container
// buys no security and makes the process unreachable through
// its own published port.
//
// The split is expressed as two explicit defaults rather than
// container auto-detection, because a heuristic that guesses
// wrong opens the cleartext port exactly where nobody is
// looking.
defaultBindAddress = "127.0.0.1"
// defaultRetentionSweepInterval is how often the retention
// reaper deletes events older than each webhook's RetentionDays.
defaultRetentionSweepInterval = time.Hour
// defaultSessionIdleTimeout is how long a session may go without
// authenticated activity before it expires.
defaultSessionIdleTimeout = 24 * time.Hour
// defaultHSTSMaxAge is the max-age of the Strict-Transport-Security
// header when HSTS_MAX_AGE is unset: ten years.
defaultHSTSMaxAge = 87600 * time.Hour
// defaultReceiverRateLimit is the default number of requests
// per minute each client IP may send to a single webhook
// receiver entrypoint. Generous for legitimate webhook
// senders while bounding abuse of the one unauthenticated,
// internet-exposed endpoint.
defaultReceiverRateLimit = 120
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the
// RFC 1918 private ranges, which a reverse proxy reaching the
// process over a Docker network or a private LAN connects from.
defaultTrustedProxies = "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
// maxPort is the highest valid TCP port number.
maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
// covers the same addresses as an IPv4 /8.
mappedV4Offset = 96
// DotEnvPath is the optional file of KEY=value lines read into the
// environment at startup, relative to the process working
// directory. Exported so that documentation and tests name the
// same path the loader opens.
DotEnvPath = ".env"
)
// ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
// contains an unrecognised value.
var ErrInvalidEnvironment = errors.New("invalid environment")
// ErrNonPositiveValue is returned when an environment variable that
// requires a positive integer is set to zero or a negative number.
var ErrNonPositiveValue = errors.New("value must be positive")
// ErrInvalidPort is returned when an environment variable holding a
// TCP port number is set to a number outside 1 to 65535.
var ErrInvalidPort = errors.New("invalid port")
// ErrInvalidCIDR is returned when an environment variable holding a
// list of CIDR blocks contains an entry that is neither a CIDR block
// nor a bare IP address.
var ErrInvalidCIDR = errors.New("invalid CIDR")
// ErrInvalidBindAddress is returned when BIND_ADDRESS is set to
// something that is not an IP address literal.
var ErrInvalidBindAddress = errors.New("invalid bind address")
// ErrInvalidSentryDSN is returned when SENTRY_DSN is set to something
// the Sentry SDK cannot parse as a DSN.
var ErrInvalidSentryDSN = errors.New("invalid Sentry DSN")
// ErrInvalidHSTSMaxAge is returned when HSTS_MAX_AGE is set to a
// negative duration or one that is not a whole number of seconds.
var ErrInvalidHSTSMaxAge = errors.New("invalid HSTS max-age")
// ErrDotEnvUnreadable is returned when the optional .env file exists
// but cannot be read or parsed. A file that is not there is not an
// error; a file that is there and broken is.
var ErrDotEnvUnreadable = errors.New("unreadable .env file")
// ErrIncompleteMetricsAuth is returned when exactly one of
// METRICS_USERNAME and METRICS_PASSWORD carries a value. Neither
// fallback is acceptable: serving /metrics on the username alone
// publishes an endpoint whose password is the empty string, and
// silently leaving it unmounted withholds an endpoint the operator
// asked for. Half-set is a configuration error, so startup fails.
var ErrIncompleteMetricsAuth = errors.New(
"incomplete metrics credentials",
)
//nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct {
fx.In
@@ -156,67 +45,14 @@ type ConfigParams struct {
type Config struct {
DataDir string
Debug bool
MaintenanceMode bool
Environment string
MetricsPassword string
MetricsUsername string
Port int
SentryDSN string
// BindAddress is the IP address the plaintext HTTP listener
// binds, as an address literal. It defaults to
// defaultBindAddress and is never empty: an empty string would
// mean the wildcard to net.Listen, which is the opposite of the
// default this ships.
BindAddress string
// RetentionSweepInterval is how often the retention reaper runs.
// Always positive: it becomes a time.NewTicker period.
RetentionSweepInterval time.Duration
// SessionIdleTimeout is the sliding inactivity window after
// which a session expires. Non-positive disables idle expiry.
SessionIdleTimeout time.Duration
// HSTSMaxAge is the max-age of the Strict-Transport-Security
// header. Zero means the header is not sent at all.
HSTSMaxAge time.Duration
// ReceiverRateLimit is the number of requests per minute each
// client IP may send to a single webhook receiver entrypoint.
ReceiverRateLimit int
// TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it
// is the RFC 1918 private ranges (defaultTrustedProxies); a set
// value replaces them. If any client can reach the process, or
// the proxy in front of it, from an RFC 1918 source address
// (directly, or through anything that can rewrite source
// addresses, such as NAT or a published container port), it
// must be set to the proxy's address alone, or every rate limit
// can be bypassed by those clients.
TrustedProxies []netip.Prefix
// AllowedEgressCIDRs is the set of networks a delivery target
// may reach even though the SSRF guard's default blocklist
// covers them. It is empty unless ALLOWED_EGRESS_CIDRS is set,
// and empty means every private/reserved range stays refused.
//
// This only ever adds destinations to what the guard would
// otherwise refuse. The guard itself is always on: there is no
// setting that disables SSRF protection, and delivery's
// alwaysBlockedNetworks stays blocked no matter what is listed
// here. That set is link-local, the unspecified addresses
// 0.0.0.0 and ::, and the cloud metadata endpoints outside
// link-local that disclose credentials or user data at a
// provider-fixed, non-public address; it is not exhaustive of
// every cloud's metadata address. See
// alwaysBlockedNetworks for the authoritative list and why
// each entry is on it.
AllowedEgressCIDRs []netip.Prefix
params *ConfigParams
log *slog.Logger
params *ConfigParams
log *slog.Logger
}
// IsDev returns true if running in development environment.
@@ -229,584 +65,34 @@ func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd
}
// MetricsAuthEnabled reports whether /metrics is served behind basic
// auth. It is the only answer to that question in the codebase: the
// route mount, the Prometheus recording middleware and the startup
// log's hasMetricsAuth field all read this one method, so the log
// cannot report auth as off while the route is mounted.
//
// It requires both credentials rather than the username alone.
// loadFromEnv already rejects a half-set pair, but a Config built in
// code bypasses that, and the failure mode this guards is an endpoint
// mounted with a credential map whose only password is the empty
// string.
func (c *Config) MetricsAuthEnabled() bool {
return c.MetricsUsername != "" && c.MetricsPassword != ""
}
// SentryEnabled reports whether error reporting is shipped to Sentry.
// It is the only answer to that question in the codebase: the SDK
// initialisation, the sentryhttp middleware registration and the
// startup log's sentryEnabled field all read this one method, so the
// log cannot report reporting as on while nothing is sending.
//
// A non-empty DSN is enough because loadFromEnv already parsed it with
// the SDK's own parser and refused to build a Config around one the
// SDK would reject, and because initialising the SDK with a DSN that
// parsed and failed anyway aborts the process rather than leaving this
// true and the client absent.
func (c *Config) SentryEnabled() bool {
return c.SentryDSN != ""
}
// envString returns the value of the named environment variable,
// or an empty string if not set.
func envString(key string) string {
return os.Getenv(key)
}
// LoadDotEnv reads DotEnvPath into the environment when that file is
// present, and reports a file that is present but broken.
//
// It has to run before anything reads the environment, so that every
// reader agrees on what the environment holds — the DATA_DIR lock
// taken before the fx graph exists as much as loadFromEnv itself. A
// variable already set in the real environment wins: godotenv never
// overwrites one.
//
// A missing file is not an error. It is a development convenience and
// most deployments set the environment directly.
//
// Any other failure is. godotenv parses the whole file before setting
// anything, so a single malformed line applies none of it: every
// variable in the file silently reverts to its default, which defeats
// the fail-loud guarantee for all of them at once.
func LoadDotEnv() error {
return loadDotEnvFile(DotEnvPath)
}
// loadDotEnvFile is LoadDotEnv over a named file, so tests can point
// at a temporary one instead of the process working directory.
func loadDotEnvFile(path string) error {
err := godotenv.Load(path)
if err == nil || errors.Is(err, fs.ErrNotExist) {
return nil
}
return fmt.Errorf(
"%w: %s: %w; nothing in it was applied, so fix the file or "+
"remove it",
ErrDotEnvUnreadable, path, err,
)
}
// DataDir resolves DATA_DIR, applying DefaultDataDir when it is unset
// or empty. It is exported so that entry points which must act on the
// data directory before the fx graph exists — taking the exclusive
// directory lock, above all — resolve it exactly as Config does.
func DataDir() string {
dir := envString("DATA_DIR")
if dir == "" {
return DefaultDataDir
}
return dir
}
// envBool returns the value of the named environment variable
// parsed as a boolean. Returns defaultValue if not set. If the
// variable is set but cannot be parsed, it returns a wrapped error
// naming the key and the bad value, so startup fails loudly rather
// than silently falling back to the default.
//
// Parsing is strconv.ParseBool, which accepts 1, t, T, TRUE, true,
// True, 0, f, F, FALSE, false and False. Anything else — "yes",
// "on", or a typo like "ture" — is an error rather than a silent
// false.
func envBool(key string, defaultValue bool) (bool, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
// parsed as a boolean. Returns defaultValue if not set.
func envBool(key string, defaultValue bool) bool {
if v := os.Getenv(key); v != "" {
return strings.EqualFold(v, "true") || v == "1"
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf(
"invalid boolean for %s: %q: %w", key, v, err,
)
}
return b, nil
return defaultValue
}
// envPositiveInt returns the value of the named environment variable
// parsed as a positive integer. Returns defaultValue if not set. If
// the variable is set but cannot be parsed, or parses to less than
// one, it returns a wrapped error naming the key and the bad value,
// so startup fails loudly rather than silently falling back to the
// default.
func envPositiveInt(
key string,
defaultValue int,
) (int, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
}
i, err := strconv.Atoi(v)
if err != nil {
return 0, fmt.Errorf(
"invalid integer for %s: %q: %w", key, v, err,
)
}
if i < 1 {
return 0, fmt.Errorf(
"%w: %s must be at least 1, got %q",
ErrNonPositiveValue, key, v,
)
}
return i, nil
}
// envPort returns the value of the named environment variable parsed
// as a TCP port number. Returns defaultValue if not set. A set value
// that is unparseable, below 1, or above maxPort is a hard error
// naming the key and the bad value; every out-of-range value wraps
// ErrInvalidPort, including one too large or too small for an int.
func envPort(key string, defaultValue int) (int, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
}
// strconv.ErrRange means a number too large or too small for an
// int, which is outside the port range as well.
port, err := strconv.Atoi(v)
if err != nil && !errors.Is(err, strconv.ErrRange) {
return 0, fmt.Errorf(
"invalid integer for %s: %q: %w", key, v, err,
)
}
if err != nil || port < 1 || port > maxPort {
return 0, fmt.Errorf(
"%w: %s must be from 1 to %d, got %q",
ErrInvalidPort, key, maxPort, v,
)
}
return port, nil
}
// envDuration returns the value of the named environment variable
// parsed as a Go duration (e.g. "1h", "30m"). Returns defaultValue if
// not set. If the variable is set but cannot be parsed, it returns a
// wrapped error naming the key and the bad value, so startup fails
// loudly rather than silently falling back to the default.
func envDuration(
key string,
defaultValue time.Duration,
) (time.Duration, error) {
v := os.Getenv(key)
if v == "" {
return defaultValue, nil
}
d, err := time.ParseDuration(v)
if err != nil {
return 0, fmt.Errorf(
"invalid duration for %s: %q: %w", key, v, err,
)
}
return d, nil
}
// envPositiveDuration returns the value of the named environment
// variable parsed as a Go duration that must be greater than zero.
// Returns defaultValue if not set. A set value that is unparseable or
// non-positive is a hard error naming the key and the bad value.
//
// This is for durations that reach time.NewTicker, which panics on a
// non-positive period, in a goroutine started after startup has
// already reported success. It is deliberately not used for durations
// where non-positive means "disabled" (SESSION_IDLE_TIMEOUT).
func envPositiveDuration(
key string,
defaultValue time.Duration,
) (time.Duration, error) {
d, err := envDuration(key, defaultValue)
if err != nil {
return 0, err
}
if d <= 0 {
return 0, fmt.Errorf(
"%w: %s must be greater than zero, got %s",
ErrNonPositiveValue, key, d,
)
}
return d, nil
}
// parseCIDR parses one trusted-proxy list entry, which may be a
// CIDR block ("10.0.0.0/8") or a bare address ("10.0.0.1", treated
// as a single-host block).
//
// Both forms are unmapped, because peer addresses are unmapped
// before they are matched against the list: an IPv4-mapped prefix
// left in that form would silently never match.
func parseCIDR(entry string) (netip.Prefix, error) {
if strings.Contains(entry, "/") {
prefix, err := netip.ParsePrefix(entry)
if err != nil {
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
// envInt returns the value of the named environment variable
// parsed as an integer. Returns defaultValue if not set or
// unparseable.
func envInt(key string, defaultValue int) int {
if v := os.Getenv(key); v != "" {
i, err := strconv.Atoi(v)
if err == nil {
return i
}
if addr := prefix.Addr(); addr.Is4In6() &&
prefix.Bits() >= mappedV4Offset {
prefix = netip.PrefixFrom(
addr.Unmap(), prefix.Bits()-mappedV4Offset,
)
}
return prefix.Masked(), nil
}
addr, err := netip.ParseAddr(entry)
if err != nil {
return netip.Prefix{}, err //nolint:wrapcheck // wrapped by caller
}
return netip.PrefixFrom(addr.Unmap(), addr.Unmap().BitLen()), nil
}
// envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value is read as defaultValue
// instead. A set value containing an unparseable entry is a hard
// error naming the key and the bad entry, so startup fails loudly
// rather than silently running with a list the operator did not
// intend.
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
v = defaultValue
}
var prefixes []netip.Prefix
for entry := range strings.SplitSeq(v, ",") {
entry = strings.TrimSpace(entry)
if entry == "" {
continue
}
prefix, err := parseCIDR(entry)
if err != nil {
return nil, fmt.Errorf(
"%w: %s: %q: %w", ErrInvalidCIDR, key, entry, err,
)
}
prefixes = append(prefixes, prefix)
}
return prefixes, nil
}
// envBindAddress returns the value of the named environment variable
// parsed as an IP address literal. An unset (or empty, or
// whitespace-only) value yields defaultValue.
//
// Only literals are accepted: no hostname is resolved, so `localhost`
// is an error rather than a DNS lookup at startup whose answer could
// be either loopback family, could change under the process, and
// could return several addresses of which only one would be bound. A
// value with a port in it (`127.0.0.1:8080`) is likewise an error —
// the port is PORT's business, and silently accepting it would bind
// something other than what was asked for.
//
// A set value that is not a literal is a hard error naming the key
// and the bad value, so startup fails loudly rather than falling back
// to a default the operator plainly did not want. A literal that is
// not an address of this host parses here and fails at listen time
// instead, which ends the process non-zero.
func envBindAddress(key, defaultValue string) (string, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return defaultValue, nil
}
addr, err := netip.ParseAddr(v)
if err != nil {
return "", fmt.Errorf(
"%w: %s: %q must be an IP address literal such as "+
"127.0.0.1, 0.0.0.0 or ::, not a hostname and not "+
"host:port: %w",
ErrInvalidBindAddress, key, v, err,
)
}
return addr.String(), nil
}
// envSentryDSN returns the value of the named environment variable
// checked as a Sentry DSN. An unset (or empty, or whitespace-only)
// value yields "", which means error reporting stays off — the common
// case, and a normal start.
//
// A set value is parsed with sentry.NewDsn, which is the call
// sentry.Init makes on the DSN it is handed, so what passes here is
// exactly what the SDK will accept later and the two cannot disagree.
// Reproducing the check by hand instead would cost this package its
// dependency on the SDK — already a module dependency, already linked
// into the binary — in exchange for a second definition of "valid DSN"
// free to drift from the one that decides.
//
// A set value that does not parse is a hard error naming the key, so
// startup fails loudly. Losing error reporting is the failure this
// variable exists to prevent, and a typo in a DSN is silent forever:
// nothing later in the process can notice that reports are going
// nowhere. The bad value is quoted because it is a URL to a public
// endpoint carrying a public key, not a secret.
func envSentryDSN(key string) (string, error) {
v := strings.TrimSpace(os.Getenv(key))
if v == "" {
return "", nil
}
_, err := sentry.NewDsn(v)
if err != nil {
return "", fmt.Errorf(
"%w: %s: %q: %w", ErrInvalidSentryDSN, key, v, err,
)
}
return v, nil
}
// resolveMetricsAuth reads the /metrics basic-auth credentials and
// rejects a half-set pair, naming both variables either way. The
// error carries neither value: the password is a secret.
func resolveMetricsAuth() (string, string, error) {
username := envString("METRICS_USERNAME")
password := envString("METRICS_PASSWORD")
if (username == "") == (password == "") {
return username, password, nil
}
set, empty := "METRICS_USERNAME", "METRICS_PASSWORD"
if username == "" {
set, empty = empty, set
}
return "", "", fmt.Errorf(
"%w: %s is set but %s is empty; METRICS_USERNAME and "+
"METRICS_PASSWORD must both be set to serve /metrics, "+
"or both be empty to leave it unmounted",
ErrIncompleteMetricsAuth, set, empty,
)
}
// resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to prod
// when it is unset so a deployment that forgets the variable is not
// silently permissive; dev must be set explicitly. It rejects
// unrecognised values.
func resolveEnvironment() (string, error) {
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentProd
}
if environment != EnvironmentDev &&
environment != EnvironmentProd {
return "", fmt.Errorf(
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
ErrInvalidEnvironment,
EnvironmentDev, EnvironmentProd, environment,
)
}
return environment, nil
}
// resolveHSTSMaxAge reads HSTS_MAX_AGE. Zero is allowed and turns the
// Strict-Transport-Security header off. A fraction of a second is an
// error rather than rounded, because the header carries whole seconds
// and would otherwise say something other than what was set.
func resolveHSTSMaxAge() (time.Duration, error) {
maxAge, err := envDuration("HSTS_MAX_AGE", defaultHSTSMaxAge)
if err != nil {
return 0, err
}
if maxAge < 0 || maxAge%time.Second != 0 {
return 0, fmt.Errorf(
"%w: HSTS_MAX_AGE must be zero or a positive whole "+
"number of seconds, got %s",
ErrInvalidHSTSMaxAge, maxAge,
)
}
return maxAge, nil
}
// resolveListener reads the two variables that describe the HTTP
// listener: which port it claims and which address it claims it on.
// They are read together because neither is meaningful alone, and
// because a validation failure in either has to abort startup before
// anything binds.
func resolveListener() (int, string, error) {
port, err := envPort("PORT", defaultPort)
if err != nil {
return 0, "", err
}
bindAddress, err := envBindAddress(
"BIND_ADDRESS", defaultBindAddress,
)
if err != nil {
return 0, "", err
}
return port, bindAddress, nil
}
// loadFromEnv builds a Config from the environment. Every value that
// needs parsing fails loudly when it is set but unparseable: the
// documented defaults apply only to variables that are unset (or
// empty), never as a substitute for a value the operator actually
// provided.
func loadFromEnv() (*Config, error) {
environment, err := resolveEnvironment()
if err != nil {
return nil, err
}
port, bindAddress, err := resolveListener()
if err != nil {
return nil, err
}
debug, err := envBool("DEBUG", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval,
)
if err != nil {
return nil, err
}
// Non-positive is "disabled" here, not invalid, so this stays on
// envDuration.
sessionIdleTimeout, err := envDuration(
"SESSION_IDLE_TIMEOUT",
defaultSessionIdleTimeout,
)
if err != nil {
return nil, err
}
hstsMaxAge, err := resolveHSTSMaxAge()
if err != nil {
return nil, err
}
receiverRateLimit, err := envPositiveInt(
"RECEIVER_RATE_LIMIT",
defaultReceiverRateLimit,
)
if err != nil {
return nil, err
}
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies)
if err != nil {
return nil, err
}
allowedEgressCIDRs, err := envPrefixList("ALLOWED_EGRESS_CIDRS", "")
if err != nil {
return nil, err
}
metricsUsername, metricsPassword, err := resolveMetricsAuth()
if err != nil {
return nil, err
}
sentryDSN, err := envSentryDSN("SENTRY_DSN")
if err != nil {
return nil, err
}
return &Config{
DataDir: DataDir(),
Debug: debug,
Environment: environment,
MetricsUsername: metricsUsername,
MetricsPassword: metricsPassword,
Port: port,
BindAddress: bindAddress,
SentryDSN: sentryDSN,
RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout,
HSTSMaxAge: hstsMaxAge,
ReceiverRateLimit: receiverRateLimit,
TrustedProxies: trustedProxies,
AllowedEgressCIDRs: allowedEgressCIDRs,
}, nil
}
// PrefixStrings renders a prefix list as its CIDR strings, for
// logging a list an operator has to be able to read back.
func PrefixStrings(prefixes []netip.Prefix) []string {
out := make([]string, 0, len(prefixes))
for _, prefix := range prefixes {
out = append(out, prefix.String())
}
return out
}
// warnEgressAllowlist logs the effective ALLOWED_EGRESS_CIDRS
// whenever it is non-empty.
//
// It prints the blocks themselves rather than a count, because
// this is the one setting that lets a delivery target reach the
// host's own network: an operator reading the startup log has to
// be able to see exactly which hole is open. Silence means the
// list is empty and the SSRF guard is refusing every
// private/reserved range, which is the default.
func (c *Config) warnEgressAllowlist(log *slog.Logger) {
if len(c.AllowedEgressCIDRs) == 0 {
return
}
log.Warn(
"ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+
"otherwise-blocked networks. Anyone who can create a "+
"delivery target can now make this process issue "+
"requests into them, and read back the response. Only "+
"the addresses the README lists as blocked "+
"unconditionally stay blocked regardless of what is "+
"listed here; a public cloud metadata address such as "+
"168.63.129.16 is reachable once it, or a block "+
"covering it, is listed.",
"allowedEgressCIDRs",
strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","),
)
return defaultValue
}
// New creates a Config by reading environment variables.
@@ -815,16 +101,44 @@ func (c *Config) warnEgressAllowlist(log *slog.Logger) {
func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log := params.Logger.Get()
// A set-but-unparseable value anywhere in the environment is a
// hard error, so fx aborts startup rather than running with a
// silently substituted default.
s, err := loadFromEnv()
if err != nil {
return nil, err
// Determine environment from WEBHOOKER_ENVIRONMENT env var,
// default to dev
environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" {
environment = EnvironmentDev
}
s.log = log
s.params = &params
// Validate environment
if environment != EnvironmentDev &&
environment != EnvironmentProd {
return nil, fmt.Errorf(
"%w: WEBHOOKER_ENVIRONMENT must be '%s' or '%s', got '%s'",
ErrInvalidEnvironment,
EnvironmentDev, EnvironmentProd, environment,
)
}
// Load configuration values from environment variables
s := &Config{
DataDir: envString("DATA_DIR"),
Debug: envBool("DEBUG", false),
MaintenanceMode: envBool("MAINTENANCE_MODE", false),
Environment: environment,
MetricsUsername: envString("METRICS_USERNAME"),
MetricsPassword: envString("METRICS_PASSWORD"),
Port: envInt("PORT", defaultPort),
SentryDSN: envString("SENTRY_DSN"),
log: log,
params: &params,
}
// Set default DataDir. All SQLite databases (main application
// DB and per-webhook event DBs) live here. The same default is
// used regardless of environment; override with DATA_DIR if
// needed.
if s.DataDir == "" {
s.DataDir = "/var/lib/webhooker"
}
if s.Debug {
params.Logger.EnableDebugLogging()
@@ -834,27 +148,13 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log.Info("Configuration loaded",
"environment", s.Environment,
"port", s.Port,
// Logged because which interfaces the cleartext listener
// answers on is not otherwise observable from inside a
// container, and it decides whether anything but the local
// host can reach the admin UI.
"bindAddress", s.BindAddress,
"debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here
// disables idle expiry entirely, and that is worth showing
// back to the operator.
"sessionIdleTimeout", s.SessionIdleTimeout.String(),
"hstsMaxAge", s.HSTSMaxAge.String(),
"receiverRateLimit", s.ReceiverRateLimit,
"trustedProxies", len(s.TrustedProxies),
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs),
"sentryEnabled", s.SentryEnabled(),
"hasMetricsAuth", s.MetricsAuthEnabled(),
"hasSentryDSN", s.SentryDSN != "",
"hasMetricsAuth",
s.MetricsUsername != "" && s.MetricsPassword != "",
)
s.warnEgressAllowlist(log)
return s, nil
}
File diff suppressed because it is too large Load Diff
-52
View File
@@ -1,52 +0,0 @@
// Package configtest holds test support for code that reads the
// process environment.
package configtest
import (
"os"
"strings"
"testing"
)
// ClearEnv unsets every variable in the process environment
// for the rest of the test, so a test sees only the variables it sets
// itself, not whatever the developer's shell exports. When the test
// ends it leaves the environment exactly as it found it: each variable
// it unset is put back, and any variable added since is removed.
func ClearEnv(t *testing.T) {
t.Helper()
present := make(map[string]bool)
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
present[key] = true
// t.Setenv registers the restore; the Unsetenv after it is
// what makes the key absent, since a key set to the empty
// string is still present, and godotenv will not overwrite a
// present key.
t.Setenv(key, "")
err := os.Unsetenv(key)
if err != nil {
t.Fatalf("unsetting %s: %v", key, err)
}
}
// A variable the test adds other than through t.Setenv, as loading
// a .env file does, has no restore of its own.
t.Cleanup(func() {
for _, entry := range os.Environ() {
key, _, _ := strings.Cut(entry, "=")
if present[key] {
continue
}
err := os.Unsetenv(key)
if err != nil {
t.Errorf("unsetting %s: %v", key, err)
}
}
})
}
-148
View File
@@ -1,148 +0,0 @@
package config_test
import (
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
)
// dotEnvKey is a throwaway variable name the .env tests write and
// read, so they cannot disturb real configuration.
const dotEnvKey = "WEBHOOKER_TEST_DOTENV_VALUE"
// malformedDotEnv is a file godotenv cannot parse. The first line is
// the realistic typo — a space where the `=` belongs — and the rest
// make sure nothing downstream treats the file as salvageable line by
// line.
const malformedDotEnv = "PORT 19615\n" +
"this is not = valid ! syntax\n" +
"\"unclosed\n"
// writeDotEnv writes contents to a .env file in a fresh temporary
// directory and returns its path.
func writeDotEnv(t *testing.T, contents string) string {
t.Helper()
path := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, os.WriteFile(path, []byte(contents), 0o600))
return path
}
// TestLoadDotEnv_MissingFileIsFine pins the case most deployments are
// in. The file is optional: it is a development convenience, and a
// deployment that configures the environment directly must start
// normally rather than be refused for a file it was never meant to
// have.
//
//nolint:paralleltest // ClearEnv uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
configtest.ClearEnv(t)
absent := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, config.LoadDotEnvFileForTest(absent))
_, present := os.LookupEnv(dotEnvKey)
assert.False(t, present, "nothing may be set from an absent file")
}
// TestLoadDotEnv_AppliesValues pins that a well-formed file still
// reaches the environment, which is the whole reason the file is read
// at all.
//
//nolint:paralleltest // ClearEnv uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) {
configtest.ClearEnv(t)
path := writeDotEnv(t, "# a comment\n"+dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
assert.Equal(t, "from-dot-env", os.Getenv(dotEnvKey))
}
// TestLoadDotEnv_RealEnvironmentWins pins that the file cannot
// override a variable the process was actually started with. A
// deployment that sets DATA_DIR in its unit file must not have it
// silently replaced by a stale .env left in the working directory.
func TestLoadDotEnv_RealEnvironmentWins(t *testing.T) {
t.Setenv(dotEnvKey, "from-environment")
path := writeDotEnv(t, dotEnvKey+"=from-dot-env\n")
require.NoError(t, config.LoadDotEnvFileForTest(path))
assert.Equal(t, "from-environment", os.Getenv(dotEnvKey))
}
// TestLoadDotEnv_MalformedFileAborts is the defect this fixes. One bad
// line makes godotenv apply none of the file, so every variable in it
// reverts to its default; the process used to start that way with no
// log line naming the file at all.
//
//nolint:paralleltest // ClearEnv uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
configtest.ClearEnv(t)
path := writeDotEnv(
t, malformedDotEnv+dotEnvKey+"=from-dot-env\n",
)
err := config.LoadDotEnvFileForTest(path)
require.Error(t, err)
require.ErrorIs(t, err, config.ErrDotEnvUnreadable)
assert.Contains(
t, err.Error(), config.DotEnvPath,
"the failure must name the file it could not read",
)
_, present := os.LookupEnv(dotEnvKey)
assert.False(
t, present,
"a rejected file must apply nothing, not part of itself",
)
}
// TestLoadDotEnv_UnreadableFileAborts pins that only absence is
// tolerated. A .env that exists but cannot be read is a file the
// operator meant to be applied, so it fails like a malformed one
// rather than being treated as though it were not there.
func TestLoadDotEnv_UnreadableFileAborts(t *testing.T) {
t.Parallel()
// A directory in the file's place: open succeeds and the read
// fails, which no umask or root-ness can turn back into success
// the way a chmod could.
path := filepath.Join(t.TempDir(), config.DotEnvPath)
require.NoError(t, os.Mkdir(path, 0o750))
err := config.LoadDotEnvFileForTest(path)
require.Error(t, err)
require.ErrorIs(t, err, config.ErrDotEnvUnreadable)
}
// TestLoadDotEnv_ReadsTheWorkingDirectory pins the path LoadDotEnv
// itself opens, which the tests above bypass. It is relative to the
// process working directory, as it was under godotenv/autoload and as
// the README documents.
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestLoadDotEnv_ReadsTheWorkingDirectory(t *testing.T) {
configtest.ClearEnv(t)
dir := t.TempDir()
require.NoError(t, os.WriteFile(
filepath.Join(dir, config.DotEnvPath),
[]byte(dotEnvKey+"=from-working-directory\n"),
0o600,
))
t.Chdir(dir)
require.NoError(t, config.LoadDotEnv())
assert.Equal(t, "from-working-directory", os.Getenv(dotEnvKey))
}
-667
View File
@@ -1,667 +0,0 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
// testEnvKey is a throwaway variable name used only by the helper
// tables below, so they cannot disturb real configuration.
const testEnvKey = "WEBHOOKER_TEST_VALUE"
// Real configuration variables exercised by the config.New tests.
const (
envKeyPort = "PORT"
envKeyDebug = "DEBUG"
envKeyBindAddress = "BIND_ADDRESS"
)
// Sample BIND_ADDRESS values used by the tables below.
const (
// bindAddressDefault is the shipped default. It is asserted
// against the package's own constant in
// TestNewUsesDefaultsWhenUnset, so the two cannot drift.
bindAddressDefault = "127.0.0.1"
// bindAddressWildcard is the value a container deployment sets.
bindAddressWildcard = "0.0.0.0"
// bindAddressSample is an arbitrary specific address, standing
// for "one interface of several".
bindAddressSample = "10.1.2.3"
)
// envBoolCase is one row of the envBool table.
type envBoolCase struct {
name string
set bool
value string
defaultValue bool
expectError bool
expected bool
}
// envBoolCases is the envBool table, kept out of the test body so
// the test itself stays readable.
func envBoolCases() []envBoolCase {
return []envBoolCase{
{
name: "unset uses default false",
defaultValue: false,
expected: false,
},
{
name: "unset uses default true",
defaultValue: true,
expected: true,
},
{
name: "empty uses default true",
set: true,
value: "",
defaultValue: true,
expected: true,
},
{
name: "true is parsed",
set: true,
value: "true",
expected: true,
},
{
name: "one is parsed",
set: true,
value: "1",
expected: true,
},
{
name: "False is parsed",
set: true,
value: "False",
defaultValue: true,
expected: false,
},
{
name: "zero is parsed",
set: true,
value: "0",
defaultValue: true,
expected: false,
},
{
name: "yes is rejected",
set: true,
value: "yes",
expectError: true,
},
{
name: "on is rejected",
set: true,
value: "on",
expectError: true,
},
{
name: "typo is rejected",
set: true,
value: "ture",
expectError: true,
},
}
}
func TestEnvBool(t *testing.T) {
for _, tt := range envBoolCases() {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := config.EnvBoolForTest(
testEnvKey, tt.defaultValue,
)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
// envIntCase is one row of the envPositiveInt and envPort tables.
type envIntCase struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}
// runEnvIntCases runs each row through parse, which is
// envPositiveInt or envPort, with testEnvKey set to the row's value
// or left unset.
func runEnvIntCases(
t *testing.T,
parse func(key string, defaultValue int) (int, error),
defaultValue int,
tests []envIntCase,
) {
t.Helper()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := parse(testEnvKey, defaultValue)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPositiveInt(t *testing.T) {
const defaultValue = 7
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{
{
name: "unset returns the default integer",
expected: defaultValue,
},
{
name: "empty returns the default integer",
set: true,
value: "",
expected: defaultValue,
},
{
name: "positive value is parsed",
set: true,
value: "42",
expected: 42,
},
{
name: "unparseable value is rejected",
set: true,
value: "not-a-number",
expectError: true,
},
{
name: "zero is rejected",
set: true,
value: "0",
expectError: true,
errIs: config.ErrNonPositiveValue,
},
{
name: "negative is rejected",
set: true,
value: "-5",
expectError: true,
errIs: config.ErrNonPositiveValue,
},
})
}
//nolint:paralleltest // runEnvIntCases uses t.Setenv.
func TestEnvPort(t *testing.T) {
const defaultValue = 8080
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{
{
name: "unset returns the default port",
expected: defaultValue,
},
{
name: "valid port is parsed",
set: true,
value: "9000",
expected: 9000,
},
{
name: "highest port is accepted",
set: true,
value: "65535",
expected: 65535,
},
{
name: "unparseable value is rejected",
set: true,
value: "not-a-port",
expectError: true,
},
{
name: "zero is rejected",
set: true,
value: "0",
expectError: true,
errIs: config.ErrInvalidPort,
},
{
name: "negative is rejected",
set: true,
value: "-1",
expectError: true,
errIs: config.ErrInvalidPort,
},
{
name: "above the port range is rejected",
set: true,
value: "65536",
expectError: true,
errIs: config.ErrInvalidPort,
},
{
name: "too large for an int is rejected",
set: true,
value: "99999999999999999999",
expectError: true,
errIs: config.ErrInvalidPort,
},
})
}
// TestEnvBindAddress covers BIND_ADDRESS parsing.
//
// Only IP address literals are accepted. Every rejection below is a
// value an operator plausibly writes — a hostname, a host:port, a
// CIDR block — and each has to abort startup rather than fall back to
// the default, because falling back would bind an address other than
// the one asked for and, in the wildcard-default case this setting
// exists to end, publish cleartext on every interface.
func TestEnvBindAddress(t *testing.T) {
for _, tt := range envBindAddressCases() {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
if tt.set {
t.Setenv(testEnvKey, tt.value)
}
got, err := config.EnvBindAddressForTest(
testEnvKey, bindAddressDefault,
)
if tt.expectError {
require.Error(t, err)
require.ErrorIs(t, err, config.ErrInvalidBindAddress)
assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
// envBindAddressCase is one row of the envBindAddress table.
type envBindAddressCase struct {
name string
set bool
value string
expectError bool
expected string
}
// envBindAddressCases is the envBindAddress table, kept out of the
// test body so the test itself stays readable.
func envBindAddressCases() []envBindAddressCase {
return append(
envBindAddressAcceptedCases(),
envBindAddressRejectedCases()...,
)
}
// envBindAddressAcceptedCases are the values that parse: the three
// spellings of "unset" that take the default, and the literals.
func envBindAddressAcceptedCases() []envBindAddressCase {
return []envBindAddressCase{
{
name: "unset returns the default",
expected: bindAddressDefault,
},
{
name: "empty returns the default",
set: true,
value: "",
expected: bindAddressDefault,
},
{
name: "whitespace returns the default",
set: true,
value: " ",
expected: bindAddressDefault,
},
{
name: "ipv4 wildcard is parsed",
set: true,
value: bindAddressWildcard,
expected: bindAddressWildcard,
},
{
name: "ipv4 literal is parsed",
set: true,
value: bindAddressSample,
expected: bindAddressSample,
},
{
name: "surrounding whitespace is trimmed",
set: true,
value: " " + bindAddressSample + " ",
expected: bindAddressSample,
},
{
name: "ipv6 wildcard is parsed",
set: true,
value: "::",
expected: "::",
},
{
name: "ipv6 literal is parsed",
set: true,
value: "2001:db8::5",
expected: "2001:db8::5",
},
}
}
// envBindAddressRejectedCases are the values that abort startup.
// Each is something an operator plausibly writes, and none may fall
// back to the default: the default is loopback, so a silent fallback
// would bind somewhere other than what was asked for.
func envBindAddressRejectedCases() []envBindAddressCase {
return []envBindAddressCase{
{
name: "garbage is rejected",
set: true,
value: "not-an-address",
expectError: true,
},
{
name: "hostname is rejected",
set: true,
value: "localhost",
expectError: true,
},
{
name: "unresolvable hostname is rejected",
set: true,
value: "no-such-host.invalid",
expectError: true,
},
{
name: "host and port is rejected",
set: true,
value: bindAddressDefault + ":8080",
expectError: true,
},
{
name: "bracketed ipv6 is rejected",
set: true,
value: "[::1]",
expectError: true,
},
{
name: "CIDR block is rejected",
set: true,
value: "10.0.0.0/8",
expectError: true,
},
}
}
// buildConfig constructs a Config through fx exactly as the
// application does, returning the config and any construction error.
func buildConfig(t *testing.T) (*config.Config, error) {
t.Helper()
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
return cfg, app.Err()
}
func TestNewRejectsBadEnvValues(t *testing.T) {
for _, tt := range badEnvValueCases() {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
t.Setenv(tt.key, tt.value)
cfg, err := buildConfig(t)
if tt.expectError {
require.Error(t, err)
assert.Contains(t, err.Error(), tt.key)
assert.Contains(t, err.Error(), tt.value)
return
}
require.NoError(t, err)
require.NotNil(t, cfg)
tt.check(t, cfg)
})
}
}
// badEnvValueCase is one row of the config.New table: a variable, the
// value it is set to, and either the assertion that startup fails
// naming both, or a check on the Config that resulted.
type badEnvValueCase struct {
name string
key string
value string
expectError bool
check func(t *testing.T, cfg *config.Config)
}
// badEnvValueCases is the config.New table, kept out of the test body
// so the test itself stays readable. It is assembled from per-variable
// groups because one literal covering every variable outgrew the
// function-length budget.
func badEnvValueCases() []badEnvValueCase {
cases := listenerEnvValueCases()
cases = append(cases, flagEnvValueCases()...)
cases = append(cases, sentryEnvValueCases()...)
return cases
}
// listenerEnvValueCases covers the two variables that describe the
// HTTP listener.
func listenerEnvValueCases() []badEnvValueCase {
return []badEnvValueCase{
{
name: "valid PORT is used",
key: envKeyPort,
value: "9001",
check: func(t *testing.T, cfg *config.Config) {
t.Helper()
assert.Equal(t, 9001, cfg.Port)
},
},
{
name: "unparseable PORT aborts startup",
key: envKeyPort,
value: "eighty-eighty",
expectError: true,
},
{
name: "out-of-range PORT aborts startup",
key: envKeyPort,
value: "70000",
expectError: true,
},
{
name: "valid BIND_ADDRESS is used",
key: envKeyBindAddress,
value: bindAddressWildcard,
check: func(t *testing.T, cfg *config.Config) {
t.Helper()
assert.Equal(
t, bindAddressWildcard, cfg.BindAddress,
)
},
},
{
name: "unparseable BIND_ADDRESS aborts startup",
key: envKeyBindAddress,
value: "not-an-address",
expectError: true,
},
{
name: "hostname BIND_ADDRESS aborts startup",
key: envKeyBindAddress,
value: "localhost",
expectError: true,
},
{
name: "BIND_ADDRESS with a port aborts startup",
key: envKeyBindAddress,
value: bindAddressDefault + ":8080",
expectError: true,
},
}
}
// flagEnvValueCases covers the boolean variables.
func flagEnvValueCases() []badEnvValueCase {
return []badEnvValueCase{
{
name: "valid DEBUG is used",
key: envKeyDebug,
value: "true",
check: func(t *testing.T, cfg *config.Config) {
t.Helper()
assert.True(t, cfg.Debug)
},
},
{
name: "unparseable DEBUG aborts startup",
key: envKeyDebug,
value: "ture",
expectError: true,
},
}
}
// sentryEnvValueCases covers SENTRY_DSN. The three rejected values are
// the ones measured on the defect: each initialised the SDK with an
// error and left the process serving with error reporting off.
func sentryEnvValueCases() []badEnvValueCase {
return []badEnvValueCase{
{
name: "valid SENTRY_DSN is used",
key: envKeySentryDSN,
value: validSentryDSN,
check: func(t *testing.T, cfg *config.Config) {
t.Helper()
assert.Equal(t, validSentryDSN, cfg.SentryDSN)
assert.True(t, cfg.SentryEnabled())
},
},
{
name: "unparseable SENTRY_DSN aborts startup",
key: envKeySentryDSN,
value: "not-a-dsn",
expectError: true,
},
{
name: "SENTRY_DSN that is not a URL aborts startup",
key: envKeySentryDSN,
value: "%%%",
expectError: true,
},
{
name: "keyless SENTRY_DSN aborts startup",
key: envKeySentryDSN,
value: "https://example.invalid/1",
expectError: true,
},
}
}
// TestNewUsesDefaultsWhenUnset proves the fail-loud behaviour did not
// break the legitimate unset case: absent variables still get their
// documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) {
configtest.ClearEnv(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
cfg, err := buildConfig(t)
require.NoError(t, err)
require.NotNil(t, cfg)
assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug)
// Loopback, not the wildcard: the default must not publish the
// cleartext admin UI and the unauthenticated receiver on every
// interface of a host that configured nothing. The value is read
// from the package rather than repeated, so the README's
// documented default and the compiled-in one are pinned to the
// same constant.
assert.Equal(
t, config.DefaultBindAddressForTest, cfg.BindAddress,
)
assert.Equal(t, bindAddressDefault, cfg.BindAddress)
// An absent SENTRY_DSN is the common case and must stay a normal
// start with error reporting off, not a refusal.
assert.Empty(t, cfg.SentryDSN)
assert.False(t, cfg.SentryEnabled())
}
-59
View File
@@ -1,59 +0,0 @@
package config
import "log/slog"
// This file exposes the unexported environment parsing helpers to
// the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API.
// WarnEgressAllowlistForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to
// log, so a test can assert both that the warning fires only when
// the list is non-empty and that it names the blocks it opened.
func WarnEgressAllowlistForTest(log *slog.Logger) error {
c, err := loadFromEnv()
if err != nil {
return err
}
c.warnEgressAllowlist(log)
return nil
}
// EnvBoolForTest exposes envBool.
func EnvBoolForTest(key string, defaultValue bool) (bool, error) {
return envBool(key, defaultValue)
}
// EnvPositiveIntForTest exposes envPositiveInt.
func EnvPositiveIntForTest(key string, defaultValue int) (int, error) {
return envPositiveInt(key, defaultValue)
}
// EnvPortForTest exposes envPort.
func EnvPortForTest(key string, defaultValue int) (int, error) {
return envPort(key, defaultValue)
}
// EnvSentryDSNForTest exposes envSentryDSN.
func EnvSentryDSNForTest(key string) (string, error) {
return envSentryDSN(key)
}
// LoadDotEnvFileForTest exposes the loader LoadDotEnv runs, over a
// caller-named file rather than the process working directory, so
// each .env state can be covered without moving the test process.
func LoadDotEnvFileForTest(path string) error {
return loadDotEnvFile(path)
}
// EnvBindAddressForTest exposes envBindAddress.
func EnvBindAddressForTest(key, defaultValue string) (string, error) {
return envBindAddress(key, defaultValue)
}
// DefaultBindAddressForTest exposes the compiled-in BIND_ADDRESS
// default, so a test pins the documented value rather than repeating
// a literal that could drift from it.
const DefaultBindAddressForTest = defaultBindAddress
-141
View File
@@ -1,141 +0,0 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/config/configtest"
)
// envKeySentryDSN is the variable envSentryDSN reads in production.
const envKeySentryDSN = "SENTRY_DSN"
// validSentryDSN is a syntactically complete DSN. The host is under
// .invalid (RFC 2606), so nothing a test builds around it can reach a
// real Sentry installation.
const validSentryDSN = "https://abc123@sentry.invalid/42"
// envSentryDSNCase is one row of the envSentryDSN table.
type envSentryDSNCase struct {
name string
set bool
value string
expectError bool
expected string
}
// envSentryDSNCases is the envSentryDSN table. The three invalid
// values are the ones measured on the defect: each initialised the SDK
// with an error and left the process serving with reporting off.
func envSentryDSNCases() []envSentryDSNCase {
return []envSentryDSNCase{
{
name: "unset means reporting off",
expected: "",
},
{
name: "empty means reporting off",
set: true,
value: "",
expected: "",
},
{
name: "whitespace means reporting off",
set: true,
value: " ",
expected: "",
},
{
name: "a valid DSN is kept",
set: true,
value: validSentryDSN,
expected: validSentryDSN,
},
{
name: "surrounding whitespace is trimmed",
set: true,
value: " " + validSentryDSN + "\t",
expected: validSentryDSN,
},
{
name: "a value that is not a URL is rejected",
set: true,
value: "not-a-dsn",
expectError: true,
},
{
name: "an unparseable URL is rejected",
set: true,
value: "%%%",
expectError: true,
},
{
name: "a DSN without a public key is rejected",
set: true,
value: "https://example.invalid/1",
expectError: true,
},
{
name: "a DSN without a project id is rejected",
set: true,
value: "https://abc123@sentry.invalid/",
expectError: true,
},
{
name: "a non-HTTP scheme is rejected",
set: true,
value: "ftp://abc123@sentry.invalid/42",
expectError: true,
},
}
}
// TestEnvSentryDSN covers the helper directly. What it pins beyond the
// value is the failure shape: a set-but-unparseable DSN names the
// variable and the value, exactly as the other fail-loud helpers do,
// so an operator reads the fix off the message.
func TestEnvSentryDSN(t *testing.T) {
for _, tt := range envSentryDSNCases() {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
configtest.ClearEnv(t)
if tt.set {
t.Setenv(envKeySentryDSN, tt.value)
}
got, err := config.EnvSentryDSNForTest(envKeySentryDSN)
if tt.expectError {
require.Error(t, err)
require.ErrorIs(t, err, config.ErrInvalidSentryDSN)
assert.Contains(t, err.Error(), envKeySentryDSN)
assert.Contains(t, err.Error(), tt.value)
assert.Empty(t, got)
return
}
require.NoError(t, err)
assert.Equal(t, tt.expected, got)
})
}
}
// TestSentryEnabled_TracksTheDSN pins that the one method answering
// "is anything being reported" agrees with the DSN in every state. The
// startup log, the SDK initialisation and the sentryhttp middleware
// all read it, so a log field cannot report reporting as on while
// nothing is sending.
func TestSentryEnabled_TracksTheDSN(t *testing.T) {
t.Parallel()
assert.False(t, (&config.Config{}).SentryEnabled())
assert.True(
t,
(&config.Config{SentryDSN: validSentryDSN}).SentryEnabled(),
)
}
-145
View File
@@ -1,145 +0,0 @@
package database_test
import (
"bytes"
"context"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// passwordField is the banner line carrying the plaintext.
const passwordField = "password: "
// bannerPassword returns the password the banner printed.
func bannerPassword(t *testing.T, out string) string {
t.Helper()
for line := range strings.SplitSeq(out, "\n") {
_, value, found := strings.Cut(line, passwordField)
if found {
return strings.TrimSpace(value)
}
}
t.Fatalf("no %q line in the banner:\n%s", passwordField, out)
return ""
}
// TestFirstBoot_PrintsTheAdminPasswordAsABanner is the bootstrap half
// of https://git.eeqj.de/sneak/webhooker/issues/208.
//
// The password is shown exactly once, and it used to be shown as one
// slog record among the roughly 45 fx PROVIDE/RUN/HOOK lines a boot
// writes — which is how deployments lost it and, with no reset path,
// locked themselves out. It must be emitted as a block an operator can
// find by eye, it must carry the plaintext that actually opens the
// account, and it must name the command that recovers it.
func TestFirstBoot_PrintsTheAdminPasswordAsABanner(t *testing.T) {
t.Parallel()
db, lc := setupTestDB(t)
var out bytes.Buffer
db.ExportSetBannerOut(&out)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
printed := out.String()
require.Contains(
t, printed, strings.Repeat("=", 20),
"the banner must be ruled off, not read as one more log line",
)
require.Contains(t, printed, "username: admin")
assert.Contains(
t, printed, "resetpw",
"the banner must name the command that recovers the account",
)
password := bannerPassword(t, printed)
require.NotEmpty(t, password)
// The printed plaintext must be the one that opens the account:
// a banner showing a different string would be worse than none.
var user database.User
require.NoError(
t,
db.DB().Where("username = ?", "admin").First(&user).Error,
)
ok, err := database.VerifyPassword(password, user.Password)
require.NoError(t, err)
assert.True(
t, ok, "the printed password must open the seeded account",
)
}
// TestNewDatabase_IsLoggedWithItsPath is the log half of
// https://git.eeqj.de/sneak/webhooker/issues/359. A DATA_DIR that is
// unexpectedly empty boots exactly like a first start, so the start
// that creates the database must say so, and where. Opening that
// database again must not.
func TestNewDatabase_IsLoggedWithItsPath(t *testing.T) {
t.Parallel()
dir := t.TempDir()
open := func() string {
var out bytes.Buffer
db, err := database.Open(dir, slog.New(slog.NewTextHandler(&out, nil)))
require.NoError(t, err)
require.NoError(t, db.Close())
return out.String()
}
const created = `level=WARN msg="created a new, empty database"`
first := open()
second := open()
assert.Contains(
t, first,
created+" path="+filepath.Join(dir, database.MainDBFileName),
)
assert.NotContains(
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,
)
}
+27 -168
View File
@@ -4,10 +4,10 @@ package database
import (
"context"
"crypto/rand"
"database/sql"
"encoding/base64"
"errors"
"fmt"
"io"
"log/slog"
"os"
"path/filepath"
@@ -15,33 +15,17 @@ import (
"go.uber.org/fx"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/banner"
_ "modernc.org/sqlite" // Pure Go SQLite driver
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger"
)
const (
dataDirPerm = 0750
randomPasswordLen = 16
sessionKeyLen = 32
)
// MainDBFileName is the main application database inside DATA_DIR. It
// is exported so that an entry point acting on a data directory
// outside the fx graph can test for a deployment's existence without
// spelling the name a second time.
const MainDBFileName = "webhooker.db"
// BootstrapPasswordNote is what the first-boot banner tells the
// operator to do about the password it just printed. It names the
// recovery command, because the moment that line scrolls away is
// exactly when the operator needs to know one exists.
const BootstrapPasswordNote = "Save this password now: it is shown " +
"only here, and only once.\nIf it is lost, run `webhooker " +
"resetpw admin` on a stopped deployment."
//nolint:revive // DatabaseParams is a standard fx naming convention.
type DatabaseParams struct {
fx.In
@@ -55,39 +39,6 @@ type Database struct {
db *gorm.DB
log *slog.Logger
params *DatabaseParams
// bannerOut receives the first-boot credentials banner. Nil means
// os.Stdout, resolved at write time rather than at construction so
// that a caller which redirects the variable still captures it.
bannerOut io.Writer
}
// Open connects to the main database in dataDir and migrates it,
// without the fx lifecycle and without seeding an admin account.
//
// It is for entry points that act on an existing deployment's data
// directory from outside the server graph — `webhooker resetpw`. Such a
// caller must already hold the DATA_DIR lock (see internal/datadir),
// and must Close the result.
//
// It does not create the admin account: seeding belongs to a server
// start, and a maintenance command that silently invented an account
// would answer "no such user" by creating one.
func Open(dataDir string, log *slog.Logger) (*Database, error) {
d := &Database{log: log}
err := d.connectTo(dataDir)
if err != nil {
return nil, err
}
return d, nil
}
// Close closes the underlying connection. It is the exported form of
// the fx stop hook, for callers that built the Database with Open.
func (d *Database) Close() error {
return d.close()
}
// New creates a Database that connects on fx start and disconnects on stop.
@@ -170,25 +121,11 @@ func (d *Database) GetOrCreateSessionKey() (string, error) {
return encoded, nil
}
// connect opens the configured data directory and, this being a
// server start, seeds the admin account when the deployment has none.
func (d *Database) connect() error {
err := d.connectTo(d.params.Config.DataDir)
if err != nil {
return err
}
return d.ensureAdminUser()
}
// connectTo opens and migrates the main database in dataDir. It seeds
// nothing: whether an empty deployment gets an admin account is the
// caller's decision.
func (d *Database) connectTo(dataDir string) error {
// Ensure the data directory exists before opening the database.
// datadir.DirPerm is the single source of the directory mode; this
// package creates the directory too, since either may run first.
err := os.MkdirAll(dataDir, datadir.DirPerm)
dataDir := d.params.Config.DataDir
err := os.MkdirAll(dataDir, dataDirPerm)
if err != nil {
return fmt.Errorf(
"creating data directory %s: %w",
@@ -198,73 +135,46 @@ func (d *Database) connectTo(dataDir string) error {
}
// Construct the main application database path inside DATA_DIR.
dbPath := filepath.Join(dataDir, MainDBFileName)
dbPath := filepath.Join(dataDir, "webhooker.db")
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
dbPath,
)
// 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)
// Opened through OpenSQLite so this handle carries the same WAL
// journaling, busy timeout, immediate-transaction locking, and pool
// bounds as every other database file. See sqlite_open.go.
sqlDB, err := OpenSQLite(dbPath, SQLiteModeCreate)
// Open the database with the pure Go SQLite driver
sqlDB, err := sql.Open("sqlite", dbURL)
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{
// Never leave this at GORM's default. See internal/gormlog.
Logger: gormlog.New(d.log),
})
}, &gorm.Config{})
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
d.log.Info("connected to database", "path", dbPath)
if created {
d.log.Warn("created a new, empty database", "path", dbPath)
} else {
d.log.Info("connected to database", "path", dbPath)
}
err = d.migrate()
if err != nil {
return fmt.Errorf("migrating %s: %w", dbPath, err)
}
err = parseModels(
db, &Setting{}, &User{}, &APIKey{},
&Webhook{}, &Entrypoint{}, &Target{},
)
if err != nil {
return fmt.Errorf("connecting to %s: %w", dbPath, err)
}
return nil
// Run migrations
return d.migrate()
}
// migrate applies the main database's SQL files. See
// internal/db/migrations.
func (d *Database) migrate() error {
err := migrations.Apply(d.db, migrations.Main)
// Run GORM auto-migrations
err := d.Migrate()
if err != nil {
d.log.Error(
"failed to run database migrations",
@@ -276,31 +186,10 @@ func (d *Database) migrate() error {
d.log.Info("database migrations completed")
return nil
}
// parseModels has GORM parse each model for db before the handle is
// shared. GORM parses a model the first time a handle uses it, and two
// goroutines doing that at once for models that refer to each other,
// as Webhook and Target do, race inside GORM.
func parseModels(db *gorm.DB, models ...any) error {
for _, model := range models {
err := (&gorm.Statement{DB: db}).Parse(model)
if err != nil {
return fmt.Errorf("parsing model %T: %w", model, err)
}
}
return nil
}
// ensureAdminUser creates the bootstrap admin account when the
// deployment has no users at all.
func (d *Database) ensureAdminUser() error {
// Check if admin user exists
var userCount int64
err := d.db.Model(&User{}).Count(&userCount).Error
err = d.db.Model(&User{}).Count(&userCount).Error
if err != nil {
d.log.Error(
"failed to count users",
@@ -360,46 +249,16 @@ func (d *Database) createAdminUser() error {
return err
}
// The plaintext leaves this process here and nowhere else. It is
// deliberately not a log field: as one INFO record among the fx
// graph's own output it read as one more startup line, which is
// how deployments lost it. See internal/banner.
err = banner.Credentials(
d.banner(),
"WEBHOOKER FIRST BOOT: an admin account has been created.",
adminUser.Username,
password,
BootstrapPasswordNote,
d.log.Info("admin user created",
"username", "admin",
"password", password,
"message",
"SAVE THIS PASSWORD - it will not be shown again!",
)
if err != nil {
// Fail the start. The account is already committed, so the
// next boot seeds nothing and prints nothing: continuing here
// would hand the operator a running service whose only
// password was never shown. `webhooker resetpw` recovers it.
d.log.Error(
"failed to print the admin credentials banner",
"error", err,
)
return err
}
d.log.Info("admin user created", "username", adminUser.Username)
return nil
}
// banner returns where the credentials banner is written. os.Stdout is
// resolved here rather than stored, so that a test which redirects the
// variable captures the banner.
func (d *Database) banner() io.Writer {
if d.bannerOut != nil {
return d.bannerOut
}
return os.Stdout
}
func (d *Database) close() error {
if d.db != nil {
sqlDB, err := d.db.DB()
+2 -41
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"
@@ -17,20 +11,6 @@ import (
"sneak.berlin/go/webhooker/internal/logger"
)
const (
// testAppname is the Globals.Appname used in tests.
testAppname = "webhooker-test"
// testVersion is the Globals.Version used in tests.
testVersion = "test"
// testContentType is the event content type used in tests.
testContentType = "application/json"
// testWebhookName is the Webhook.Name used in tests.
testWebhookName = "test-webhook"
// testForeverLabel is Webhook.RetentionLabel for a retain-forever
// webhook.
testForeverLabel = "forever"
)
func setupTestDB(
t *testing.T,
) (*database.Database, *fxtest.Lifecycle) {
@@ -39,8 +19,8 @@ func setupTestDB(
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: testAppname,
Version: testVersion,
Appname: "webhooker-test",
Version: "test",
}
l, err := logger.New(
@@ -106,22 +86,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")
}
@@ -1,55 +0,0 @@
// Package databasetest builds a WebhookDBManager for tests in other
// packages.
package databasetest
import (
"log/slog"
"os"
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
)
// NewWebhookDBManager creates a WebhookDBManager backed by the given
// data directory, logging at DEBUG to standard error.
func NewWebhookDBManager(
t *testing.T, dataDir string,
) *database.WebhookDBManager {
t.Helper()
return NewWebhookDBManagerWithLogger(
t,
dataDir,
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
)
}
// NewWebhookDBManagerWithLogger is NewWebhookDBManager with the
// logger supplied by the caller. The per-webhook databases this manager
// opens hand that logger to gormlog, so a test that needs to see the SQL
// the service emits can capture it.
//
// It is built through database.NewWebhookDBManager on a lifecycle that
// is never started, so nothing closes its databases but the caller.
func NewWebhookDBManagerWithLogger(
t *testing.T, dataDir string, log *slog.Logger,
) *database.WebhookDBManager {
t.Helper()
mgr, err := database.NewWebhookDBManager(
fxtest.NewLifecycle(t),
database.WebhookDBManagerParams{
Config: &config.Config{DataDir: dataDir},
Logger: log,
},
)
require.NoError(t, err)
return mgr
}
-54
View File
@@ -1,54 +0,0 @@
package database
import (
"fmt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// omitAssociationsCallback is the name the association guard is
// registered under on a per-webhook database's create and update
// callback chains.
const omitAssociationsCallback = "webhooker:omit_associations"
// omitAssociations makes every create and update issued against a
// per-webhook database skip GORM's automatic association save.
//
// A per-webhook database holds the event tier only, but Delivery
// declares belongs-to Event and Target and the delivery engine fills
// both in memory before writing. Without this guard GORM upserts those
// parent rows here on the delivery and retry write paths, and the
// target, whose config holds destination URLs and bearer credentials,
// goes to a targets table this file does not have, which fails the
// write. Registering the guard on the connection
// covers every write path, including writes inside a transaction and
// write paths added later. Every event-tier row this file holds is
// written explicitly, so nothing depends on the automatic save.
func omitAssociations(db *gorm.DB) error {
omit := func(tx *gorm.DB) {
tx.Statement.Omits = append(
tx.Statement.Omits, clause.Associations,
)
}
err := db.Callback().Create().
Before("gorm:save_before_associations").
Register(omitAssociationsCallback, omit)
if err != nil {
return fmt.Errorf(
"registering create association guard: %w", err,
)
}
err = db.Callback().Update().
Before("gorm:save_before_associations").
Register(omitAssociationsCallback, omit)
if err != nil {
return fmt.Errorf(
"registering update association guard: %w", err,
)
}
return nil
}
@@ -1,63 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
)
// TestEventDBCreateOmitsAssociations covers the connection-level
// guard directly: a Delivery carrying its Event and Target in
// memory, written through the manager's handle, must store only the
// delivery row.
func TestEventDBCreateOmitsAssociations(t *testing.T) {
t.Parallel()
webhookID := uuid.New().String()
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
target := database.Target{
WebhookID: webhookID,
Name: "leaky-target",
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://hooks.example/secret"}`,
MaxAttempts: 1,
}
target.ID = uuid.New().String()
event := database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Headers: `{}`,
Body: `{}`,
}
event.ID = uuid.New().String()
d := &database.Delivery{
EventID: event.ID,
TargetID: target.ID,
Status: database.DeliveryStatusPending,
Event: event,
Target: target,
}
d.ID = uuid.New().String()
require.NoError(t, db.Create(d).Error)
require.NoError(t, db.Model(d).
Update("status", database.DeliveryStatusDelivered).
Error)
// The file has no targets table, so the create and update above
// would have failed had GORM tried to save the target.
assert.False(t, db.Migrator().HasTable(&database.Target{}))
require.NoError(t, mgr.CloseAll())
}
@@ -1,329 +0,0 @@
package database_test
import (
"context"
"fmt"
"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"
)
// TestWebhookDBManager_OpenAddsEventTierIndexes verifies that a new
// per-webhook database has these indexes.
func TestWebhookDBManager_OpenAddsEventTierIndexes(t *testing.T) {
t.Parallel()
indexes := []struct {
model any
name string
}{
{&database.Delivery{}, "idx_deliveries_status"},
{&database.Delivery{}, "idx_deliveries_event_id"},
{&database.DeliveryResult{}, "idx_delivery_results_delivery_id"},
{&database.Event{}, "idx_events_deleted_at_created_at"},
{&database.Event{}, "idx_events_created_at"},
}
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)
for _, ix := range indexes {
assert.True(t, db.Migrator().HasIndex(ix.model, ix.name),
"a new database should have %s", ix.name)
}
}
// TestEventTierQueriesUseTheirIndexes verifies that the statements the
// indexes are for use them. GORM builds each statement in a dry run as
// the code named above it does, soft-delete condition included, and
// SQLite, which keeps no statistics on these tables, must plan to seek
// on each index listed by the columns in parentheses.
func TestEventTierQueriesUseTheirIndexes(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})
ids := []string{
uuid.New().String(), uuid.New().String(), uuid.New().String(),
}
cutoff := time.Now()
var (
deliveries []database.Delivery
results []database.DeliveryResult
depths []struct{ Depth int }
removed []database.TargetTotals
)
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
byEvent := "idx_deliveries_event_id (event_id=? AND deleted_at=?)"
// The delivery engine: the recovery routine, which reads the
// retrying deliveries and, on a tick of the sweep, the pending ones
// untouched for a while; and the queue depth count.
assertPlanUses(t, db, dry.Where(
"status = ?", database.DeliveryStatusRetrying,
).Find(&deliveries), byStatus)
assertPlanUses(t, db, dry.Where(
"status = ? AND updated_at < ?",
database.DeliveryStatusPending, cutoff,
).Limit(500).Find(&deliveries), byStatus)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Select("target_id", "status", "count(*) as depth").
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).Group("target_id, status").Find(&depths), byStatus)
// The event log: each event's deliveries, then their attempts
// (loadEventsWithDeliveries, loadDeliveryResults).
assertPlanUses(t, db, dry.Where("event_id = ?", ids[0]).
Find(&deliveries), byEvent)
assertPlanUses(t, db, dry.Where("delivery_id IN ?", ids).
Order("attempt_num ASC").Find(&results),
"idx_delivery_results_delivery_id (delivery_id=? AND deleted_at=?)")
// Retention (reapExpired, deleteEvents): one batch of expired
// events, then their attempts, deliveries and the events.
var expired []string
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(
"delivery_id IN (?)", dry.Unscoped().Model(&database.Delivery{}).
Select("id").Where("event_id IN ?", ids),
).Delete(&database.DeliveryResult{}),
"idx_delivery_results_delivery_id (delivery_id=?)",
"idx_deliveries_event_id (event_id=?)")
assertPlanUses(t, db, dry.Unscoped().Model(&database.Delivery{}).
Select("target_id, count(*) AS deliveries_removed, "+
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
database.DeliveryStatusFailed).
Where("event_id IN ?", ids).Group("target_id").Find(&removed),
"idx_deliveries_event_id (event_id=?)")
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=?)")
}
// TestEventLogFiltersUseTheStatusIndex does the same for the event log's
// Failed and Pending lists, of the newest events with a delivery in
// given statuses, and for their counts (eventsWithStatus and
// countEventsWithStatus in the handlers). The lists must also reach
// the events table only by ID: from the matching deliveries, then from
// the newest of those events.
func TestEventLogFiltersUseTheStatusIndex(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)
dry := db.Session(&gorm.Session{DryRun: true})
byStatus := "idx_deliveries_status (status=? AND deleted_at=?)"
pending := []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}
var (
rows []struct{ ID string }
count int64
)
matching := dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending)
newest := dry.Table("(?) AS matching", matching).
Joins("CROSS JOIN events ON events.id = matching.event_id").
Where(
"events.webhook_id = ? AND events.deleted_at IS NULL",
webhookID,
).
Order("events.created_at DESC").Limit(50).
Select("events.id AS event_id")
// Each step of the plan is printed in braces, so these name the
// lookup that follows each scan.
byID := "{SEARCH events USING INDEX sqlite_autoindex_events_1 (id=?)}"
assertPlanUses(t, db, dry.Table("(?) AS newest", newest).
Joins("CROSS JOIN events ON events.id = newest.event_id").
Select("id").Order("created_at DESC").Limit(50).Find(&rows),
byStatus, "{SCAN matching} "+byID, "{SCAN newest} "+byID)
assertPlanUses(t, db, dry.Model(&database.Delivery{}).
Distinct("event_id").Where("status IN ?", pending).Count(&count),
byStatus)
}
// 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>?)")
}
// 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=?)")
}
// TestEntrypointEventsUseTheirIndex does the same for the webhook
// page's count, for each entrypoint, of the events that arrived on its
// URL since the retention cutoff (addEntrypointEvents in the
// handlers), which must come from the index alone. It passes 25
// entrypoints, as TestResubmitCountUsesItsIndex passes 25 events.
func TestEntrypointEventsUseTheirIndex(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})
entrypoints := make([]string, 25)
for i := range entrypoints {
entrypoints[i] = uuid.New().String()
}
var rows []struct{ Events int }
assertPlanUses(t, db, dry.Model(&database.Event{}).
Select("entrypoint_id, count(*) AS events").
Where("entrypoint_id IN ? AND resubmitted_from_id IS NULL",
entrypoints).
Where("created_at >= ?", time.Now()).
Group("entrypoint_id").Find(&rows),
"COVERING INDEX idx_events_entrypoint_id "+
"(entrypoint_id=? AND deleted_at=? AND "+
"resubmitted_from_id=? AND created_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.
func assertPlanUses(
t *testing.T, db, built *gorm.DB, indexes ...string,
) {
t.Helper()
var plan []struct{ Detail string }
require.NoError(t, db.Raw(
"EXPLAIN QUERY PLAN "+built.Statement.SQL.String(),
built.Statement.Vars...,
).Scan(&plan).Error)
for _, index := range indexes {
assert.Contains(t, fmt.Sprint(plan), index,
built.Statement.SQL.String())
}
}
-97
View File
@@ -1,97 +0,0 @@
package database
import (
"context"
"io"
"log/slog"
"os"
"testing"
"time"
"go.uber.org/fx"
)
// NewTestRetentionReaper builds a RetentionReaper backed by the given
// main database and per-webhook database manager, without the fx
// lifecycle. Intended for tests.
func NewTestRetentionReaper(
db *Database,
mgr *WebhookDBManager,
) *RetentionReaper {
return &RetentionReaper{
db: db,
dbManager: mgr,
log: slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
interval: time.Hour,
}
}
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx)
}
// ExportRegisterHooks registers the reaper's real fx lifecycle hooks
// on a lifecycle supplied by a test, so a test can drive the exact
// OnStart/OnStop functions the application runs and hand OnStart the
// kind of context fx actually supplies.
func (r *RetentionReaper) ExportRegisterHooks(lc fx.Lifecycle) {
r.registerHooks(lc)
}
// ExportStart starts the reaper's background loop for tests.
func (r *RetentionReaper) ExportStart() {
r.start()
}
// ExportStop stops the reaper's background loop for tests.
func (r *RetentionReaper) ExportStop(ctx context.Context) error {
return r.stop(ctx)
}
// ExportWedgeLoop adds a goroutine to the reaper's WaitGroup that
// never observes cancellation and returns only when release is
// closed. It stands in for a sweep stuck on a locked database.
func (r *RetentionReaper) ExportWedgeLoop(
release <-chan struct{},
) {
r.wg.Go(func() {
<-release
})
}
// ExportSetInterval overrides the sweep interval for tests.
func (r *RetentionReaper) ExportSetInterval(d time.Duration) {
r.interval = d
}
// ExportSetBannerOut redirects the first-boot credentials banner, so a
// test can read what the operator would have seen. It must be called
// before the fx start hook runs, which is where the account is seeded.
func (d *Database) ExportSetBannerOut(w io.Writer) {
d.bannerOut = w
}
// DummyPasswordHashForTest exposes the encoded hash that unknown
// usernames are verified against.
func DummyPasswordHashForTest() string {
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 })
}
+4 -9
View File
@@ -2,20 +2,15 @@ package database
import "time"
// APIKey represents an API key for a user.
//
// Key is a bearer credential, so it is never marshalled with the
// model. A creation handler that has to show it once returns it in its
// own response type.
// APIKey represents an API key for a user
type APIKey struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"userId"`
Key string `gorm:"uniqueIndex;not null" json:"-"`
Key string `gorm:"uniqueIndex;not null" json:"key"`
Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations. No model marshals the record it belongs to:
// User.APIKeys leads back here, and the JSON could loop.
User User `json:"-"`
// Relations
User User `json:"user,omitzero"`
}
+6 -49
View File
@@ -1,11 +1,5 @@
package database
import (
"time"
"gorm.io/gorm"
)
// DeliveryStatus represents the status of a delivery
type DeliveryStatus string
@@ -17,53 +11,16 @@ const (
DeliveryStatusRetrying DeliveryStatus = "retrying"
)
// Terminal reports whether a delivery in this status has finished, so
// the delivery engine will make no further attempt of its own.
//
// It is what decides which deliveries the event log offers to replay:
// a pending or retrying delivery is still the engine's, and replaying
// one would race it.
func (s DeliveryStatus) Terminal() bool {
switch s {
case DeliveryStatusDelivered, DeliveryStatusFailed:
return true
case DeliveryStatusPending, DeliveryStatusRetrying:
return false
default:
return false
}
}
// Delivery represents a delivery attempt for an event to a target
//
//nolint:lll // a struct tag cannot wrap
type Delivery struct {
BaseModel
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"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"`
EventID string `gorm:"type:uuid;not null" json:"eventId"`
TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column
// of the event_id and status indexes, for the reason DeliveryResult
// gives.
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"`
// Replay is set on a delivery created by the event log's Replay
// action, so the pages can tell it from the delivery it repeats.
Replay bool `gorm:"not null;default:false" json:"replay"`
// Relations. No model marshals the record it belongs to:
// Event.Deliveries and Target.Deliveries lead back here, and the
// JSON could loop.
Event Event `json:"-"`
Target Target `json:"-"`
// Relations
Event Event `json:"event,omitzero"`
Target Target `json:"target,omitzero"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
}
+3 -15
View File
@@ -1,21 +1,10 @@
package database
import "gorm.io/gorm"
// DeliveryResult represents the result of a delivery attempt
//
//nolint:lll // a struct tag cannot wrap
type DeliveryResult struct {
BaseModel
// DeliveryID and DeletedAt make up one index, in that order.
// DeletedAt repeats the BaseModel field only to join it: GORM adds
// "deleted_at IS NULL" to almost every query, and where a column is
// matched against several values SQLite otherwise reads through the
// deleted_at index, which every live row matches.
DeliveryID string `gorm:"type:uuid;not null;index:idx_delivery_results_delivery_id,priority:1" json:"deliveryId"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_delivery_results_delivery_id,priority:2" json:"deletedAt,omitzero"`
DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
AttemptNum int `gorm:"not null" json:"attemptNum"`
Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"`
@@ -23,7 +12,6 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations. No model marshals the record it belongs to:
// Delivery.DeliveryResults leads back here, and the JSON could loop.
Delivery Delivery `json:"-"`
// Relations
Delivery Delivery `json:"delivery,omitzero"`
}
+5 -12
View File
@@ -4,18 +4,11 @@ package database
type Entrypoint struct {
BaseModel
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
// Path is the URL path for this entrypoint. It is the
// entrypoint's only credential: possession of the UUID
// authorises submission, so the receiver checks nothing else
// about the sender.
Path string `gorm:"uniqueIndex;not null" json:"path"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
Path string `gorm:"uniqueIndex;not null" json:"path"` // URL path for this entrypoint
Description string `json:"description"`
Active bool `gorm:"default:true" json:"active"`
Active bool `gorm:"default:true" json:"active"`
// Relations. No model marshals the record it belongs to:
// Webhook.Entrypoints leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
}
+6 -44
View File
@@ -1,58 +1,20 @@
package database
import (
"time"
"gorm.io/gorm"
)
// Event represents a captured webhook event
//
//nolint:lll // a struct tag cannot wrap
type Event struct {
BaseModel
// CreatedAt and DeletedAt repeat the BaseModel fields only to index
// them for retention, which finds events by age. Its lookups carry
// GORM's "deleted_at IS NULL" (see DeliveryResult) and compare
// created_at with <, so their index has deleted_at first: SQLite
// 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.
// DeletedAt is also the second column of the resubmitted_from_id
// index, for the reason DeliveryResult gives. The entrypoint_id
// index, for the webhook page's entrypoint list, has it second too,
// resubmitted_from_id third, and created_at last, which the list
// compares with a range.
CreatedAt time.Time `gorm:"index;index:idx_events_deleted_at_created_at,priority:2;index:idx_events_entrypoint_id,priority:4" json:"createdAt"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_events_deleted_at_created_at,priority:1;index:idx_events_resubmitted_from_id,priority:2;index:idx_events_entrypoint_id,priority:2" json:"deletedAt,omitzero"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
EntrypointID string `gorm:"type:uuid;not null;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data. RawQuery is the receiving request's query string
// as sent, without the leading "?".
// Request data
Method string `gorm:"not null" json:"method"`
RawQuery string `gorm:"type:text" json:"rawQuery"`
Headers string `gorm:"type:text" json:"headers"` // JSON
Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event
// is stored, so that the recent events list, which reads only the
// start of each body, knows the whole body's size.
BodyBytes int64 `gorm:"not null" json:"bodyBytes"`
// ResubmittedFromID names the event this one was copied from by
// an operator resubmit. It is nil for an event that arrived on
// the receiver. 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:idx_events_resubmitted_from_id,priority:1;index:idx_events_entrypoint_id,priority:3" json:"resubmittedFromId,omitempty"`
// Relations. No model marshals the record it belongs to, so
// Webhook and Entrypoint are left out of the JSON.
Webhook Webhook `json:"-"`
Entrypoint Entrypoint `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
Entrypoint Entrypoint `json:"entrypoint,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}
-127
View File
@@ -1,127 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// TestPreloadedModelsMarshalWithoutTheirParent pins that a child's
// reference to the record it belongs to is left out of the JSON, so a
// webhook and its targets cannot marshal each other in a loop, and that
// GORM still preloads that reference, since it ignores json tags.
func TestPreloadedModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
stored := database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
Entrypoints: []database.Entrypoint{{Path: uuid.New().String()}},
Targets: []database.Target{{
Name: "log",
Type: database.TargetTypeLog,
MaxAttempts: 1,
}},
}
require.NoError(t, db.Create(&stored).Error)
entrypointID := stored.Entrypoints[0].ID
targetID := stored.Targets[0].ID
var webhook database.Webhook
require.NoError(t, db.
Preload("Entrypoints.Webhook").
Preload("Targets.Webhook").
First(&webhook, "id = ?", stored.ID).Error)
require.Len(t, webhook.Entrypoints, 1)
require.Len(t, webhook.Targets, 1)
assert.Equal(t, stored.ID, webhook.Entrypoints[0].Webhook.ID)
assert.Equal(t, stored.ID, webhook.Targets[0].Webhook.ID)
encoded := marshalModel(t, webhook)
assert.Contains(t, encoded, entrypointID)
assert.Contains(t, encoded, targetID)
// Each child holds the parent's id as its webhookId, so the parent
// is looked for by its own id field.
parentIDField := `"id":"` + stored.ID + `"`
assert.NotContains(t, marshalModel(t, webhook.Entrypoints[0]), parentIDField)
assert.NotContains(t, marshalModel(t, webhook.Targets[0]), parentIDField)
var target database.Target
require.NoError(t, db.
Preload("Webhook").
First(&target, "id = ?", targetID).Error)
assert.Equal(t, stored.ID, target.Webhook.ID)
encoded = marshalModel(t, target)
assert.Contains(t, encoded, stored.ID)
assert.NotContains(t, encoded, parentIDField)
}
// TestModelsMarshalWithoutTheirParent covers the other references to a
// parent: each model is built with its parent set, and the parent's id
// must not appear in the JSON.
func TestModelsMarshalWithoutTheirParent(t *testing.T) {
t.Parallel()
parent := database.BaseModel{ID: uuid.New().String()}
cases := []struct {
name string
model any
}{
{
name: "Webhook.User",
model: database.Webhook{User: database.User{BaseModel: parent}},
},
{
name: "APIKey.User",
model: database.APIKey{User: database.User{BaseModel: parent}},
},
{
name: "Delivery.Event",
model: database.Delivery{Event: database.Event{BaseModel: parent}},
},
{
name: "Delivery.Target",
model: database.Delivery{Target: database.Target{BaseModel: parent}},
},
{
name: "DeliveryResult.Delivery",
model: database.DeliveryResult{
Delivery: database.Delivery{BaseModel: parent},
},
},
{
name: "Event.Webhook",
model: database.Event{Webhook: database.Webhook{BaseModel: parent}},
},
{
name: "Event.Entrypoint",
model: database.Event{
Entrypoint: database.Entrypoint{BaseModel: parent},
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
assert.NotContains(t, marshalModel(t, tc.model), parent.ID)
})
}
}
-109
View File
@@ -1,109 +0,0 @@
package database_test
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// keptField is a non-secret value planted alongside each secret, so
// the assertions below cannot pass by the model marshalling to nothing.
const keptField = "keepme"
// marshalModel encodes a model the way a future JSON handler would.
func marshalModel(t *testing.T, v any) string {
t.Helper()
encoded, err := json.Marshal(v)
require.NoError(t, err)
return string(encoded)
}
// TestModelsDoNotMarshalTheirSecrets pins the barrier for the JSON
// path. The /api/v1 route group exists and is empty; delivery's
// TargetView masks the credential for the HTML path only, so without
// these tags the first handler that marshals a model serialises the
// secret with it. Each field below is a live credential:
//
// - Target.Config holds an incoming-webhook URL whose path segments
// are the bearer token.
// - APIKey.Key is a bearer token outright.
// - Setting.Value holds the session encryption key.
// - User.Password holds the Argon2 hash, and was already tagged.
func TestModelsDoNotMarshalTheirSecrets(t *testing.T) {
t.Parallel()
const marker = "QQMODELMARKERQQ"
cases := []struct {
name string
model any
}{
{
name: "target config",
model: database.Target{
Name: keptField,
Type: database.TargetTypeSlack,
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1,
},
},
{
name: "api key",
model: database.APIKey{
Description: keptField,
Key: marker,
},
},
{
name: "setting value",
model: database.Setting{
Key: keptField,
Value: marker,
},
},
{
name: "user password hash",
model: database.User{
Username: keptField,
Password: marker,
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
encoded := marshalModel(t, tc.model)
assert.NotContains(t, encoded, marker)
assert.Contains(t, encoded, keptField)
})
}
}
// TestWebhookMarshalsNoTargetConfig covers the nested case: a webhook
// marshalled with its targets preloaded must not carry the credential
// through the association either.
func TestWebhookMarshalsNoTargetConfig(t *testing.T) {
t.Parallel()
const marker = "QQNESTEDMARKERQQ"
encoded := marshalModel(t, database.Webhook{
Name: keptField,
Targets: []database.Target{{
Name: "slack",
Config: `{"webhookUrl":"https://h/s/` + marker + `"}`,
MaxAttempts: 1,
}},
})
assert.NotContains(t, encoded, marker)
assert.Contains(t, encoded, keptField)
}
+2 -5
View File
@@ -3,9 +3,6 @@ package database
// Setting stores application-level key-value configuration.
// Used for auto-generated values like the session encryption key.
type Setting struct {
Key string `gorm:"primaryKey" json:"key"`
// Value holds the session encryption key, so it is never
// marshalled with the model.
Value string `gorm:"type:text;not null" json:"-"`
Key string `gorm:"primaryKey" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
}
+7 -17
View File
@@ -20,24 +20,14 @@ type Target struct {
Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"`
// Configuration fields (JSON stored based on type).
//
// json:"-" because the blob holds the target's credential — a
// Slack incoming-webhook URL, or an http destination whose path
// segments are the secret. delivery.TargetView is the masking
// barrier for the HTML path; this tag is the barrier for any
// handler that marshals the model itself.
Config string `gorm:"type:text" json:"-"` // JSON configuration
// Configuration fields (JSON stored based on type)
Config string `gorm:"type:text" json:"config"` // JSON configuration
// MaxAttempts is how many times an HTTP or Slack target attempts
// each delivery in all, at least 1. 1 is a single attempt with no
// circuit breaker; above 1, a failed attempt is retried with
// backoff behind the breaker. The table's check constraint refuses
// a target saved with less than 1, whatever its type.
MaxAttempts int `gorm:"not null;check:max_attempts >= 1" json:"maxAttempts,omitempty"`
// For HTTP targets (max_retries=0 means fire-and-forget, >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations. No model marshals the record it belongs to:
// Webhook.Targets leads back here, and the JSON could loop.
Webhook Webhook `json:"-"`
// Relations
Webhook Webhook `json:"webhook,omitzero"`
Deliveries []Delivery `json:"deliveries,omitempty"`
}
-44
View File
@@ -1,44 +0,0 @@
package database_test
import (
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// newHTTPTarget returns an http target that makes maxAttempts attempts
// at each delivery.
func newHTTPTarget(maxAttempts int) *database.Target {
return &database.Target{
WebhookID: uuid.New().String(),
Name: "target",
Type: database.TargetTypeHTTP,
Config: `{"url":"https://example.com/"}`,
MaxAttempts: maxAttempts,
}
}
// TestTargetCreate_RefusesZeroAttempts relies on the targets table
// alone: nothing in the model checks the number, so the check
// constraint is what keeps a target with 0 attempts out of the
// database.
func TestTargetCreate_RefusesZeroAttempts(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(newHTTPTarget(0)).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
func TestTargetCreate_AcceptsOneAttempt(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(newHTTPTarget(1)).Error)
}
-136
View File
@@ -1,136 +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"
}
// EntrypointTotals is one row per entrypoint, created by the first
// event that arrives on its URL: when the newest such event arrived,
// which retention leaves as it is. A resubmitted copy did not arrive
// on the URL and does not change it.
type EntrypointTotals struct {
EntrypointID string `gorm:"type:uuid;primaryKey"`
LastEventAt time.Time `gorm:"not null"`
}
// TableName names the table AddEntrypointTotals updates.
func (EntrypointTotals) TableName() string {
return "entrypoint_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
}
// AddEntrypointTotals records add.LastEventAt as when the newest event
// arrived on the URL of the entrypoint add.EntrypointID names, creating
// its row the first time. Call it on the transaction that stores the
// event.
func AddEntrypointTotals(tx *gorm.DB, add EntrypointTotals) error {
err := tx.Exec(
`INSERT INTO entrypoint_totals (entrypoint_id, last_event_at)
VALUES (?, ?)
ON CONFLICT (entrypoint_id) DO UPDATE SET
last_event_at = excluded.last_event_at`,
add.EntrypointID, add.LastEventAt,
).Error
if err != nil {
return fmt.Errorf(
"adding to totals of entrypoint %s: %w",
add.EntrypointID, err,
)
}
return nil
}
+2 -47
View File
@@ -1,58 +1,13 @@
package database
import (
"errors"
"fmt"
"gorm.io/gorm"
)
// MaxUsernameBytes is the longest username, in bytes, that a user may
// have. The same number appears in the check constraint on
// User.Username, because a struct tag cannot reference a constant.
//
// A login stores the username in the session cookie, and both
// securecookie and browsers refuse a cookie value past about 4096
// bytes. That value is the session base64-encoded twice, so it holds
// 4096 × 3/4 × 3/4 = 2304 bytes of session, and the signature,
// timestamp and the session's other values take about 270 of those: a
// username longer than about 2030 bytes can never log in. The limit is
// about half that, so the session can carry more values later without
// locking out an account whose username is already at the limit.
const MaxUsernameBytes = 1024
// ErrUsernameTooLong is returned when a user is saved with a username
// longer than MaxUsernameBytes.
var ErrUsernameTooLong = errors.New("username is too long")
// User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct {
BaseModel
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations
Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,omitempty"`
}
// BeforeSave rejects a username longer than MaxUsernameBytes when a whole
// User is created or saved, so those calls get ErrUsernameTooLong rather
// than the database's constraint error. A column update such as
// Update("username", ...) is caught only by the check constraint, as is
// any path that writes the table without this model.
func (u *User) BeforeSave(_ *gorm.DB) error {
if len(u.Username) > MaxUsernameBytes {
return fmt.Errorf(
"%w: %d bytes, limit is %d",
ErrUsernameTooLong,
len(u.Username),
MaxUsernameBytes,
)
}
return nil
}
-65
View File
@@ -1,65 +0,0 @@
package database_test
import (
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
)
// usernameAtLimit is exactly MaxUsernameBytes long, built from a
// two-byte character. A check that counted characters rather than bytes
// would see half the length and let the one-byte-longer name through.
func usernameAtLimit() string {
return strings.Repeat("é", database.MaxUsernameBytes/2)
}
func TestUserCreate_RejectsOverlongUsername(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
err := db.Create(&database.User{
Username: usernameAtLimit() + "x",
Password: "hash",
}).Error
require.ErrorIs(t, err, database.ErrUsernameTooLong)
}
func TestUserCreate_AcceptsUsernameAtLimit(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
require.NoError(t, db.Create(&database.User{
Username: usernameAtLimit(),
Password: "hash",
}).Error)
}
// TestUsersTable_EnforcesUsernameLimitWithoutTheModel inserts with raw
// SQL, as a path that bypassed User.BeforeSave would, so only the
// table's check constraint stands between it and an over-long
// username. Accepting the name at the limit and refusing the next byte
// also pins the constraint's number to MaxUsernameBytes.
func TestUsersTable_EnforcesUsernameLimitWithoutTheModel(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
insert := "INSERT INTO users (id, username, password) VALUES (?, ?, ?)"
require.NoError(t, db.Exec(
insert, uuid.New().String(), usernameAtLimit(), "hash",
).Error)
err := db.Exec(
insert, uuid.New().String(), usernameAtLimit()+"x", "hash",
).Error
require.Error(t, err)
assert.Contains(t, err.Error(), "CHECK constraint failed")
}
+6 -86
View File
@@ -1,96 +1,16 @@
package database
import (
"math"
"strconv"
"time"
)
const (
// DefaultRetentionDays is the event retention period the new
// webhook form applies when its retention field is left blank.
DefaultRetentionDays = 30
// MaxFiniteRetentionDays is the largest finite retention period the
// reaper's cutoff arithmetic can represent, and therefore the
// largest one a caller may request. It is derived from that
// arithmetic rather than picked: retentionCutoff computes
// retentionDays * hoursPerDay * time.Hour, and a time.Duration is
// an int64 nanosecond count, so math.MaxInt64 nanoseconds divided
// by an hour and then by a day is the exact ceiling — 106751 days,
// a little over 292 years.
//
// One day more overflows int64, wraps the product negative, and
// turns the cutoff into a timestamp in the far future that matches
// every row in the webhook's database. That is why this bound is
// enforced on input and why retentionCutoff saturates underneath
// it.
MaxFiniteRetentionDays = int(
math.MaxInt64 / int64(time.Hour) / hoursPerDay,
)
)
// Webhook represents a webhook processing unit that groups entrypoints and targets
//
// Every method below takes a pointer receiver, so handlers put a
// *Webhook into template data: html/template cannot call a pointer
// method on a value held in a map, because a map element is not
// addressable.
type Webhook struct {
BaseModel
UserID string `gorm:"type:uuid;not null" json:"userId"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
UserID string `gorm:"type:uuid;not null" json:"userId"`
Name string `gorm:"not null" json:"name"`
Description string `json:"description"`
RetentionDays int `gorm:"default:30" json:"retentionDays"` // Days to retain events
// RetentionDays is the number of days to retain events, and 0
// means retain them forever.
RetentionDays int `json:"retentionDays"`
// Relations. No model marshals the record it belongs to:
// User.Webhooks leads back here, and the JSON could loop.
User User `json:"-"`
// Relations
User User `json:"user,omitzero"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"`
}
// retainsForever reports whether a RetentionDays value means "keep
// events indefinitely", which 0 does. It is the single definition of
// that question, shared by Webhook.RetainsForever and by the reaper's
// cutoff computation so the two cannot disagree about which webhooks
// are exempt from reaping.
//
// A negative value, which the forms refuse, is read the same way: as a
// day count it would put the cutoff in the future, where it matches
// every event the webhook has.
func retainsForever(retentionDays int) bool {
return retentionDays <= 0
}
// RetainsForever reports whether this webhook's events are kept
// indefinitely.
func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays)
}
// RetentionCutoff returns the time before which this webhook's events
// have expired, as the reaper computes it, and false when the webhook
// retains them forever.
func (w *Webhook) RetentionCutoff(now time.Time) (time.Time, bool) {
return retentionCutoff(now, w.RetentionDays)
}
// RetentionLabel returns the webhook's retention policy as display
// text: "forever" rather than "0 days" for a webhook that keeps its
// events.
func (w *Webhook) RetentionLabel() string {
if w.RetainsForever() {
return "forever"
}
if w.RetentionDays == 1 {
return "1 day"
}
return strconv.Itoa(w.RetentionDays) + " days"
}
-160
View File
@@ -1,160 +0,0 @@
package database_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
)
// startedTestDB returns a started main database for model-level tests.
func startedTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, lc := setupTestDB(t)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) })
return db.DB()
}
// storedRetention reads the retention_days column straight out of the
// row, so the assertion is about what was persisted rather than about
// whatever the in-memory struct happens to hold.
func storedRetention(t *testing.T, db *gorm.DB, id string) int {
t.Helper()
var got int
require.NoError(
t,
db.Model(&database.Webhook{}).
Where("id = ?", id).
Pluck("retention_days", &got).Error,
)
return got
}
// newWebhookWithRetention creates a webhook through the ordinary Create
// path, as the new webhook form does.
func newWebhookWithRetention(
t *testing.T,
db *gorm.DB,
wh *database.Webhook,
) string {
t.Helper()
wh.UserID = uuid.New().String()
wh.Name = testWebhookName
require.NoError(
t,
db.Omit(clause.Associations).Create(wh).Error,
)
return wh.ID
}
func TestWebhookRetentionDays_ZeroIsStoredAsZero(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
wh := &database.Webhook{RetentionDays: 0}
id := newWebhookWithRetention(t, db, wh)
assert.Equal(t, 0, storedRetention(t, db, id))
}
func TestWebhookRetentionDays_DayCountIsStored(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
wh := &database.Webhook{RetentionDays: 7}
id := newWebhookWithRetention(t, db, wh)
assert.Equal(t, 7, storedRetention(t, db, id))
}
// TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero changes a day
// count to 0 through the same Save call the edit handler makes.
func TestWebhookRetentionDays_UpdateToZeroIsStoredAsZero(t *testing.T) {
t.Parallel()
db := startedTestDB(t)
wh := &database.Webhook{RetentionDays: 30}
id := newWebhookWithRetention(t, db, wh)
require.Equal(t, 30, storedRetention(t, db, id))
wh.RetentionDays = 0
require.NoError(t, db.Omit(clause.Associations).Save(wh).Error)
assert.Equal(t, 0, storedRetention(t, db, id))
}
// TestMaxFiniteRetentionDaysIsTheOverflowCeiling asserts that the
// constant is exactly where the cutoff arithmetic stops working, which
// is what makes it a derived bound rather than a round number someone
// liked. One day more wraps the int64 nanosecond count negative, and a
// negative span is precisely what turned a cutoff into a future
// timestamp that matched — and deleted — every row.
//
// The multiplications are done through variables on purpose: as
// constant expressions the overflowing one would not compile.
func TestMaxFiniteRetentionDaysIsTheOverflowCeiling(t *testing.T) {
t.Parallel()
const hoursPerDay = 24
atCeiling := database.MaxFiniteRetentionDays
overCeiling := database.MaxFiniteRetentionDays + 1
assert.Positive(
t,
time.Duration(atCeiling*hoursPerDay)*time.Hour,
"the ceiling itself must still be representable",
)
assert.Negative(
t,
time.Duration(overCeiling*hoursPerDay)*time.Hour,
"one day past the ceiling must overflow",
)
}
func TestWebhookRetainsForeverAndLabel(t *testing.T) {
t.Parallel()
cases := []struct {
name string
days int
forever bool
label string
}{
{"zero", 0, true, testForeverLabel},
{"negative", -1, true, testForeverLabel},
{"default", database.DefaultRetentionDays, false, "30 days"},
{"one day", 1, false, "1 day"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
wh := database.Webhook{RetentionDays: tc.days}
assert.Equal(t, tc.forever, wh.RetainsForever())
assert.Equal(t, tc.label, wh.RetentionLabel())
})
}
}
+16
View File
@@ -0,0 +1,16 @@
package database
// Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult) live in
// per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error {
return d.db.AutoMigrate(
&Setting{},
&User{},
&APIKey{},
&Webhook{},
&Entrypoint{},
&Target{},
)
}
+1 -59
View File
@@ -8,8 +8,6 @@ import (
"fmt"
"math/big"
"strings"
"sync"
"testing"
"golang.org/x/crypto/argon2"
)
@@ -31,10 +29,6 @@ const hashParts = 6
// triggers per-character-class complexity enforcement.
const minPasswordComplexityLen = 4
// dummyPasswordLen is the length of the throwaway password behind
// dummyPasswordHash.
const dummyPasswordLen = 32
// Sentinel errors returned by decodeHash.
var (
errInvalidHashFormat = errors.New("invalid hash format")
@@ -64,30 +58,10 @@ func DefaultPasswordConfig() *PasswordConfig {
}
}
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test
// 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.
// HashPassword generates an Argon2id hash of the password
func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt
salt := make([]byte, config.SaltLen)
@@ -148,38 +122,6 @@ func VerifyPassword(
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil
}
// dummyPasswordHash is an encoded Argon2id hash of a random
// password, computed once on first use. Nothing can match it: the
// password it encodes is discarded as soon as it is hashed. It is
// process-wide because building it per request would add a second
// 64 MB Argon2id pass to every login for an unknown username.
//
//nolint:gochecknoglobals // computed once, see above
var dummyPasswordHash = sync.OnceValue(func() string {
password, err := GenerateRandomPassword(dummyPasswordLen)
if err != nil {
panic(fmt.Sprintf("generating the dummy password: %v", err))
}
hash, err := HashPassword(password)
if err != nil {
panic(fmt.Sprintf("hashing the dummy password: %v", err))
}
return hash
})
// VerifyDummyPassword performs a credential verification that cannot
// succeed, at the same cost as a real one.
//
// Login must charge an unknown username the same work as a known
// one. Returning early for an account that does not exist answers in
// microseconds where a real account takes tens of milliseconds, which
// is a username oracle any client can read off the response time.
func VerifyDummyPassword(password string) {
_, _ = VerifyPassword(password, dummyPasswordHash())
}
// decodeHash extracts parameters, salt, and hash from an
// encoded hash string.
func decodeHash(
-71
View File
@@ -191,74 +191,3 @@ 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
// path. Login charges an unknown username a verification against a
// dummy hash so that a nonexistent account is not answered in
// microseconds where a real one takes tens of milliseconds. That only
// works if the dummy hash is a real, decodable Argon2id hash: a
// malformed one would make VerifyPassword fail on the decode and
// return before hashing anything.
func TestVerifyDummyPassword_DoesRealWork(t *testing.T) {
t.Parallel()
// Runs the OnceValue that builds the dummy hash, so a panic in
// it surfaces here rather than on a live login.
database.VerifyDummyPassword("whatever was submitted")
dummy := database.DummyPasswordHashForTest()
// A hash the verifier cannot decode would make VerifyPassword
// return on the decode error, before hashing anything — the
// timing oracle this path exists to close.
valid, err := database.VerifyPassword("whatever", dummy)
if err != nil {
t.Fatalf(
"the dummy hash must decode like a real one: %v", err,
)
}
if valid {
t.Error("nothing may authenticate against the dummy hash")
}
if !strings.HasPrefix(dummy, "$argon2id$") {
t.Errorf(
"the dummy hash must use the same algorithm as real "+
"hashes, got %q", dummy,
)
}
}
-380
View File
@@ -1,380 +0,0 @@
package database
import (
"context"
"fmt"
"log/slog"
"sync"
"time"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/lifecycle"
"sneak.berlin/go/webhooker/internal/logger"
)
// hoursPerDay converts a RetentionDays count into hours for cutoff
// computation.
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
// RetentionReaper.
type RetentionReaperParams struct {
fx.In
Config *config.Config
Database *Database
DBManager *WebhookDBManager
Logger *logger.Logger
}
// RetentionReaper periodically deletes expired events (and their
// dependent deliveries and delivery results) from each per-webhook
// database, enforcing every webhook's RetentionDays. Rows are removed
// permanently so that per-webhook SQLite files do not grow without
// bound.
type RetentionReaper struct {
db *Database
dbManager *WebhookDBManager
log *slog.Logger
interval time.Duration
cancel context.CancelFunc
wg sync.WaitGroup
}
// NewRetentionReaper creates the retention reaper and registers its
// fx lifecycle hooks. The background sweep loop starts on OnStart and
// stops cleanly on OnStop via context cancellation.
func NewRetentionReaper(
lc fx.Lifecycle,
params RetentionReaperParams,
) *RetentionReaper {
r := &RetentionReaper{
db: params.Database,
dbManager: params.DBManager,
log: params.Logger.Get(),
interval: params.Config.RetentionSweepInterval,
}
r.registerHooks(lc)
return r
}
// registerHooks wires the reaper's start and stop into the fx
// lifecycle. The start hook's context is deliberately ignored (see
// start for why the sweep loop must not inherit it); the stop hook's
// context is honoured (see stop).
func (r *RetentionReaper) registerHooks(lc fx.Lifecycle) {
lc.Append(fx.Hook{
//nolint:contextcheck // Not inheriting the hook context is
// the point: see start.
OnStart: func(_ context.Context) error {
r.start()
return nil
},
OnStop: func(ctx context.Context) error {
return r.stop(ctx)
},
})
}
// start launches the background sweep loop.
//
// The loop's context is derived from context.Background(), NOT from
// the fx OnStart hook context. The hook context carries fx's start
// timeout (15s by default) and is cancelled once the start phase
// completes, so a loop derived from it dies 45 minutes before its
// first tick under the default one-hour sweep interval, leaving a
// reaper that never reaps. A long-lived goroutine must outlive the
// startup phase, so its lifetime is bounded by OnStop instead: stop
// cancels this context and waits on the WaitGroup.
func (r *RetentionReaper) start() {
ctx, cancel := context.WithCancel(context.Background())
r.cancel = cancel
r.wg.Add(1)
go r.run(ctx)
r.log.Info(
"retention reaper started",
"interval", r.interval.String(),
)
}
// stop cancels the sweep loop's context and waits for it to
// exit, bounded by the stop hook's context: a sweep wedged on a
// locked database must not hang the process past fx's stop
// timeout.
func (r *RetentionReaper) stop(ctx context.Context) error {
r.log.Info("retention reaper stopping")
if r.cancel != nil {
r.cancel()
}
err := lifecycle.WaitForShutdown(
ctx, r.log, "retention reaper", &r.wg,
)
if err != nil {
return err
}
r.log.Info("retention reaper stopped")
return nil
}
func (r *RetentionReaper) run(ctx context.Context) {
defer r.wg.Done()
ticker := time.NewTicker(r.interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
r.sweep(ctx)
}
}
}
// sweep lists every webhook from the main database and reaps expired
// rows from each per-webhook database that has a finite retention
// policy. Webhooks set to retain forever are skipped entirely.
func (r *RetentionReaper) sweep(ctx context.Context) {
var webhooks []Webhook
err := r.db.DB().
Model(&Webhook{}).
Find(&webhooks).Error
if err != nil {
r.log.Error(
"retention sweep: failed to list webhooks",
"error", err,
)
return
}
for i := range webhooks {
select {
case <-ctx.Done():
return
default:
}
wh := webhooks[i]
// A missing database has nothing to reap. The delivery
// engine's recovery routine reports a lost one at start (see
// WebhookDBManager.GetDB).
if !r.dbManager.DBExists(wh.ID) {
continue
}
r.reapWebhook(ctx, wh.ID, wh.RetentionDays)
}
}
// reapWebhook removes every expired event (and its dependents) from a
// single webhook's database, or as many as it reaches before ctx is
// cancelled.
func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string,
retentionDays int,
) {
// A retain-forever webhook has no cutoff, so its database is not
// even opened.
cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
db, err := r.dbManager.GetDB(webhookID)
if err != nil {
r.log.Error(
"retention sweep: failed to open webhook database",
"webhook_id", webhookID,
"error", err,
)
return
}
deleted, err := reapExpired(ctx, db, cutoff)
if err != nil {
r.log.Error(
"retention sweep: failed to reap expired events",
"webhook_id", webhookID,
"error", err,
)
return
}
if deleted > 0 {
r.log.Info(
"retention sweep: reaped expired events",
"webhook_id", webhookID,
"retention_days", retentionDays,
"events_deleted", deleted,
)
}
}
// retentionCutoff returns the timestamp before which a webhook's
// events have expired, and whether any cutoff applies at all. It
// reports false for a retention of 0, which means retain forever, so
// no DELETE is issued.
//
// The day count is clamped to MaxFiniteRetentionDays first. This is
// defense in depth rather than decoration: a time.Duration is an int64
// nanosecond count, so an unclamped multiplication overflows above
// that ceiling and wraps the span negative. Subtracting a negative
// span moves the cutoff into the far future, where it matches every
// row in the database: the sweep then deletes every event, delivery,
// and delivery result, including ones created seconds ago. Rejecting
// out-of-range input at the form is the primary guard; saturating here
// means a value that reaches the row by any other path cannot turn a
// too-large retention into total data loss.
func retentionCutoff(
now time.Time,
retentionDays int,
) (time.Time, bool) {
if retainsForever(retentionDays) {
return time.Time{}, false
}
if retentionDays > MaxFiniteRetentionDays {
retentionDays = MaxFiniteRetentionDays
}
return now.Add(
-time.Duration(retentionDays*hoursPerDay) * time.Hour,
), true
}
// reapExpired hard-deletes the events older than cutoff, with their
// deliveries and delivery results, reapBatchSize events per
// transaction with reapBatchPause between transactions, until none is
// left. Once ctx is cancelled it returns after the batch in hand,
// leaving the rest to the next sweep, so stopping the app does not
// wait for a long prune. It returns the number of events deleted.
func reapExpired(
ctx context.Context, db *gorm.DB, cutoff time.Time,
) (int64, error) {
var total int64
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").
Where("event_id IN ?", eventIDs)).
Delete(&DeliveryResult{}).Error
if err != nil {
return fmt.Errorf("deleting expired delivery results: %w", err)
}
// 2. The events' deliveries, after counting them, and the failed
// ones among them, per target. The status is tested in the select
// list rather than the WHERE clause: there, SQLite would read every
// failed delivery the webhook has through the status index,
// instead of only these through the event_id index.
var removed []TargetTotals
err = tx.Unscoped().Model(&Delivery{}).
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().
Where("event_id IN ?", eventIDs).
Delete(&Delivery{}).Error
if err != nil {
return fmt.Errorf("deleting expired deliveries: %w", err)
}
// 3. The events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{})
if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error)
}
for i := range removed {
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
}
@@ -1,272 +0,0 @@
package database_test
import (
"context"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
const (
// reaperTestInterval is the sweep interval a lifecycle test
// runs the reaper at, so a loop that survives startup produces
// an observable sweep quickly.
reaperTestInterval = 10 * time.Millisecond
// reaperStopTimeout bounds how long a lifecycle test waits for
// the reaper's OnStop hook to return before declaring the
// shutdown hung.
reaperStopTimeout = 10 * time.Second
// reaperTestRetentionDays is the retention policy the lifecycle
// tests give their webhook.
reaperTestRetentionDays = 30
// reaperWedgeStopTimeout is the stop timeout the wedged-shutdown
// test hands OnStop, standing in for fx's StopTimeout. The test
// asserts only that the hook returns at all, and allows it
// reaperStopTimeout — forty times this budget — to do so, so no
// assertion races the wall clock.
reaperWedgeStopTimeout = 250 * time.Millisecond
)
// recordingLifecycle is a minimal fx.Lifecycle that records the
// hooks a component registers, so a test can invoke the real
// OnStart/OnStop functions with a context of its choosing.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(h fx.Hook) {
l.hooks = append(l.hooks, h)
}
// startReaperViaHook drives the genuine fx hooks the application
// registers for the reaper, handing OnStart a context that is
// already done. It returns the recorded lifecycle so the caller
// can drive OnStop too.
func startReaperViaHook(
t *testing.T, r *database.RetentionReaper,
) *recordingLifecycle {
t.Helper()
lc := &recordingLifecycle{}
r.ExportRegisterHooks(lc)
require.Len(t, lc.hooks, 1)
// fx hands OnStart a context carrying the application start
// timeout, and cancels it when the start phase ends. An
// already-cancelled context is that same defect taken to its
// limit, and unlike a plain context.Background() it actually
// distinguishes a correctly rooted loop from a broken one.
hookCtx, cancel := context.WithCancel(context.Background())
cancel()
require.NoError(t, lc.hooks[0].OnStart(hookCtx))
return lc
}
// eventGone reports whether an event row has been removed. It
// takes no *testing.T because it is polled from an
// assert.Eventually condition, which runs off the test goroutine
// where testify assertions must not be used.
func eventGone(db *gorm.DB, eventID string) bool {
var n int64
err := db.Unscoped().Model(&database.Event{}).
Where("id = ?", eventID).Count(&n).Error
if err != nil {
return false
}
return n == 0
}
// seedExpiredWebhook creates a webhook with a finite retention
// policy plus one long-expired event chain, and returns the
// webhook's database and the chain's event ID.
func seedExpiredWebhook(
t *testing.T, env *retentionTestEnv,
) (*gorm.DB, string) {
t.Helper()
webhookID := createWebhook(
t, env.mainDB.DB(), reaperTestRetentionDays,
)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
chain := seedEventChain(
t, db, webhookID,
time.Now().Add(-365*24*time.Hour),
)
return db, chain.eventID
}
// TestRetentionReaper_LoopOutlivesStartHookContext is the
// regression test for a reaper that never reaped. fx calls
// OnStart with a context carrying the application's start timeout
// (15s by default) and cancels it when the start phase ends, so a
// sweep loop rooted in it is dead three quarters of an hour
// before its first tick under the default one-hour interval, and
// per-webhook event databases grow without bound exactly as they
// did before retention existed.
//
// Driving OnStart with an already-cancelled context is that
// defect taken to its limit: a loop that inherits the hook
// context never ticks once, while a correctly rooted loop keeps
// sweeping for as long as the process lives.
func TestRetentionReaper_LoopOutlivesStartHookContext(
t *testing.T,
) {
t.Parallel()
env := setupRetentionTest(t)
db, eventID := seedExpiredWebhook(t, env)
env.reaper.ExportSetInterval(reaperTestInterval)
lc := startReaperViaHook(t, env.reaper)
t.Cleanup(func() {
_ = lc.hooks[0].OnStop(context.Background())
})
assert.Eventually(
t,
func() bool { return eventGone(db, eventID) },
5*time.Second,
reaperTestInterval,
"the sweep loop must keep running after the start "+
"hook's context is done; it reaped nothing, so it "+
"inherited the hook context and died",
)
}
// TestRetentionReaper_StopHookStopsLoop proves the fix did not
// trade a startup bug for a shutdown hang: now that the sweep
// loop no longer observes the start hook's cancellation, OnStop
// is the only thing that can stop it, and it must both return
// promptly and actually leave the loop stopped.
func TestRetentionReaper_StopHookStopsLoop(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
db, eventID := seedExpiredWebhook(t, env)
env.reaper.ExportSetInterval(reaperTestInterval)
lc := startReaperViaHook(t, env.reaper)
// Let the loop prove it is running before stopping it, so a
// fast OnStop cannot pass by stopping something already dead.
require.Eventually(
t,
func() bool { return eventGone(db, eventID) },
5*time.Second,
reaperTestInterval,
)
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
// stop blocks on the loop's WaitGroup, so returning at all
// proves the goroutine observed the cancellation.
stopErr = lc.hooks[0].OnStop(context.Background())
}()
select {
case <-stopped:
case <-time.After(reaperStopTimeout):
t.Fatal(
"OnStop did not return: the retention reaper's " +
"WaitGroup is still waiting on a loop that never " +
"observed cancellation",
)
}
require.NoError(t, stopErr)
// With the loop gone, a newly expired chain must survive.
survivor := seedEventChain(
t, db, "stopped-webhook",
time.Now().Add(-365*24*time.Hour),
)
time.Sleep(20 * reaperTestInterval)
assert.False(
t,
eventGone(db, survivor.eventID),
"a stopped reaper must not sweep anything",
)
}
// TestRetentionReaper_StopHookHonoursStopTimeout is the
// regression test for a shutdown that could never complete. fx
// hands OnStop a context carrying the application's stop timeout;
// an OnStop that discards it and calls wg.Wait() bare hangs the
// process forever on a sweep blocked on a locked SQLite database
// — precisely when a bounded shutdown matters most.
//
// The wedged goroutine here never observes cancellation, so the
// hook can only return by honouring its context, and it must say
// so rather than reporting a clean stop.
func TestRetentionReaper_StopHookHonoursStopTimeout(
t *testing.T,
) {
t.Parallel()
env := setupRetentionTest(t)
env.reaper.ExportSetInterval(reaperTestInterval)
lc := startReaperViaHook(t, env.reaper)
release := make(chan struct{})
t.Cleanup(func() { close(release) })
env.reaper.ExportWedgeLoop(release)
stopCtx, cancel := context.WithTimeout(
context.Background(), reaperWedgeStopTimeout,
)
defer cancel()
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
stopErr = lc.hooks[0].OnStop(stopCtx)
}()
select {
case <-stopped:
case <-time.After(reaperStopTimeout):
t.Fatal(
"OnStop did not return: it discarded the stop " +
"context and is waiting on a wedged goroutine " +
"that will never observe cancellation",
)
}
require.ErrorIs(t, stopErr, context.DeadlineExceeded)
require.ErrorContains(t, stopErr, "retention reaper")
}
-356
View File
@@ -1,356 +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"
"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/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
// retentionTestEnv bundles the pieces a retention test drives.
type retentionTestEnv struct {
reaper *database.RetentionReaper
mainDB *database.Database
mgr *database.WebhookDBManager
}
func setupRetentionTest(t *testing.T) *retentionTestEnv {
t.Helper()
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: testAppname,
Version: testVersion,
}
l, err := logger.New(lc, logger.LoggerParams{Globals: g})
require.NoError(t, err)
cfg := &config.Config{
DataDir: t.TempDir(),
Environment: "dev",
}
mainDB, err := database.New(lc, database.DatabaseParams{
Config: cfg,
Logger: l,
})
require.NoError(t, err)
mgr, err := database.NewWebhookDBManager(
lc,
database.WebhookDBManagerParams{Config: cfg, Logger: l.Get()},
)
require.NoError(t, err)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
t.Cleanup(func() { require.NoError(t, lc.Stop(ctx)) })
return &retentionTestEnv{
reaper: database.NewTestRetentionReaper(mainDB, mgr),
mainDB: mainDB,
mgr: mgr,
}
}
// createWebhook inserts a webhook row into the main database with the
// given retention policy and returns its ID.
func createWebhook(
t *testing.T,
db *gorm.DB,
retentionDays int,
) string {
t.Helper()
wh := &database.Webhook{
UserID: uuid.New().String(),
Name: testWebhookName,
RetentionDays: retentionDays,
}
require.NoError(
t,
db.Omit(clause.Associations).Create(wh).Error,
)
return wh.ID
}
// eventChain is the set of row IDs seeded for a single event.
type eventChain struct {
eventID string
deliveryID string
resultID string
}
// seedEventChain creates an event with one delivery and one delivery
// result, all stamped with createdAt, and returns their IDs.
func seedEventChain(
t *testing.T,
db *gorm.DB,
webhookID string,
createdAt time.Time,
) eventChain {
t.Helper()
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Body: `{"seed": true}`,
ContentType: testContentType,
}
event.CreatedAt = createdAt
require.NoError(t, db.Create(event).Error)
delivery := &database.Delivery{
EventID: event.ID,
TargetID: uuid.New().String(),
Status: database.DeliveryStatusDelivered,
}
delivery.CreatedAt = createdAt
require.NoError(t, db.Create(delivery).Error)
result := &database.DeliveryResult{
DeliveryID: delivery.ID,
AttemptNum: 1,
Success: true,
StatusCode: 200,
Duration: 10,
}
result.CreatedAt = createdAt
require.NoError(t, db.Create(result).Error)
return eventChain{
eventID: event.ID,
deliveryID: delivery.ID,
resultID: result.ID,
}
}
// countByID returns how many rows of model match the given id,
// counting even hard-deletable rows via Unscoped.
func countByID(
t *testing.T,
db *gorm.DB,
model any,
id string,
) int64 {
t.Helper()
var n int64
require.NoError(
t,
db.Unscoped().Model(model).
Where("id = ?", id).Count(&n).Error,
)
return n
}
func assertChainGone(
t *testing.T,
db *gorm.DB,
chain eventChain,
) {
t.Helper()
assert.Zero(
t,
countByID(t, db, &database.Event{}, chain.eventID),
"expired event should be removed",
)
assert.Zero(
t,
countByID(t, db, &database.Delivery{}, chain.deliveryID),
"expired delivery should be removed",
)
assert.Zero(
t,
countByID(
t, db, &database.DeliveryResult{}, chain.resultID,
),
"expired delivery result should be removed",
)
}
func assertChainPresent(
t *testing.T,
db *gorm.DB,
chain eventChain,
) {
t.Helper()
assert.Equal(
t,
int64(1),
countByID(t, db, &database.Event{}, chain.eventID),
"recent event should be retained",
)
assert.Equal(
t,
int64(1),
countByID(t, db, &database.Delivery{}, chain.deliveryID),
"recent delivery should be retained",
)
assert.Equal(
t,
int64(1),
countByID(
t, db, &database.DeliveryResult{}, chain.resultID,
),
"recent delivery result should be retained",
)
}
func TestRetentionReaper_ReapsExpiredKeepsRecent(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
const retentionDays = 30
webhookID := createWebhook(
t, env.mainDB.DB(), retentionDays,
)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
now := time.Now()
old := seedEventChain(
t, db, webhookID,
now.Add(-40*24*time.Hour),
)
recent := seedEventChain(
t, db, webhookID,
now.Add(-1*24*time.Hour),
)
env.reaper.ExportSweep(context.Background())
assertChainGone(t, db, old)
assertChainPresent(t, db, recent)
}
// TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep covers the
// end-to-end retain-forever path: a webhook created with a retention
// of 0 reads back 0, and the reaper leaves its ancient events alone
// while still reaping a finite-retention webhook in the very same
// sweep.
func TestRetentionReaper_SkipsZeroReapsFiniteInSameSweep(
t *testing.T,
) {
t.Parallel()
env := setupRetentionTest(t)
foreverID := createWebhook(t, env.mainDB.DB(), 0)
var stored database.Webhook
require.NoError(
t,
env.mainDB.DB().Where("id = ?", foreverID).
First(&stored).Error,
)
require.Zero(
t, stored.RetentionDays,
"a retention of 0 must be stored as 0",
)
finiteID := createWebhook(t, env.mainDB.DB(), 30)
foreverDB, err := env.mgr.GetDB(foreverID)
require.NoError(t, err)
finiteDB, err := env.mgr.GetDB(finiteID)
require.NoError(t, err)
ancient := time.Now().Add(-365 * 24 * time.Hour)
kept := seedEventChain(t, foreverDB, foreverID, ancient)
doomed := seedEventChain(t, finiteDB, finiteID, ancient)
env.reaper.ExportSweep(context.Background())
assertChainPresent(t, foreverDB, kept)
assertChainGone(t, finiteDB, doomed)
}
// TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents pins the
// overflow that made a large finite retention destroy everything.
//
// The cutoff is a time.Duration, an int64 nanosecond count. A day
// count above MaxFiniteRetentionDays multiplied out unclamped wraps
// negative, so subtracting it moves the cutoff into the far future,
// where "created_at < cutoff" matches every row: an event created a
// moment ago, and its delivery and delivery result, were all deleted
// on the first sweep. 200000 is inside that band.
//
// The form refuses such a value; the point of the test is that a row
// written by any other path still cannot trigger the wipe.
func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t *testing.T,
) {
t.Parallel()
env := setupRetentionTest(t)
const overflowingRetentionDays = 200000
require.Greater(
t,
overflowingRetentionDays,
database.MaxFiniteRetentionDays,
"the test value must exceed what the cutoff can represent",
)
webhookID := createWebhook(
t, env.mainDB.DB(), overflowingRetentionDays,
)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
fresh := seedEventChain(t, db, webhookID, time.Now())
env.reaper.ExportSweep(context.Background())
assertChainPresent(t, db, fresh)
}
// TestRetentionReaper_RetainsForeverWhenNegative checks that a negative
// retention, which the forms refuse, keeps every event rather than
// putting the cutoff in the future, where it would match them all.
func TestRetentionReaper_RetainsForeverWhenNegative(t *testing.T) {
t.Parallel()
env := setupRetentionTest(t)
webhookID := createWebhook(t, env.mainDB.DB(), -1)
db, err := env.mgr.GetDB(webhookID)
require.NoError(t, err)
ancient := seedEventChain(
t, db, webhookID,
time.Now().Add(-365*24*time.Hour),
)
env.reaper.ExportSweep(context.Background())
assertChainPresent(t, db, ancient)
}
-168
View File
@@ -1,168 +0,0 @@
package database_test
import (
"log/slog"
"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"
"sneak.berlin/go/webhooker/internal/database/databasetest"
)
// withFixedNow returns db with GORM's clock stopped at a time in UTC,
// so that the created and updated times it writes come back from
// SQLite equal to the row written, which holds them in UTC.
func withFixedNow(db *gorm.DB) (*gorm.DB, time.Time) {
at := time.Date(2026, 10, 13, 12, 30, 45, 123456789, time.UTC)
return db.Session(&gorm.Session{
NowFunc: func() time.Time { return at },
}), at
}
// newBaseModel returns a BaseModel with a new ID.
func newBaseModel() database.BaseModel {
return database.BaseModel{ID: uuid.New().String()}
}
// assertRoundTrip writes row, which must be the only row of its
// table, and reads back the same row.
func assertRoundTrip[T any](t *testing.T, db *gorm.DB, row *T) {
t.Helper()
require.NoError(t, db.Create(row).Error)
var got T
require.NoError(t, db.First(&got).Error)
assert.Equal(t, *row, got)
}
// TestMainSchemaHoldsEveryModel writes a row of each main database
// model, every field set, into a database made from its SQL files.
func TestMainSchemaHoldsEveryModel(t *testing.T) {
t.Parallel()
mainDB, err := database.Open(
t.TempDir(), slog.New(slog.DiscardHandler),
)
require.NoError(t, err)
t.Cleanup(func() { _ = mainDB.Close() })
db, at := withFixedNow(mainDB.DB())
assertRoundTrip(t, db, &database.Setting{
Key: "session_key", Value: "value",
})
assertRoundTrip(t, db, &database.User{
BaseModel: newBaseModel(),
Username: "user",
Password: "password hash",
})
assertRoundTrip(t, db, &database.APIKey{
BaseModel: newBaseModel(),
UserID: uuid.New().String(),
Key: "key",
Description: "API key description",
LastUsedAt: &at,
})
assertRoundTrip(t, db, &database.Webhook{
BaseModel: newBaseModel(),
UserID: uuid.New().String(),
Name: "webhook",
Description: "webhook description",
RetentionDays: 7,
})
assertRoundTrip(t, db, &database.Entrypoint{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
Path: uuid.New().String(),
Description: "entrypoint description",
Active: true,
})
assertRoundTrip(t, db, &database.Target{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
Name: "target",
Type: database.TargetTypeHTTP,
Active: true,
Config: `{"url":"https://example.com/"}`,
MaxAttempts: 3,
})
}
// TestEventSchemaHoldsEveryModel does the same for a webhook's event
// database, which the manager makes from its SQL files.
func TestEventSchemaHoldsEveryModel(t *testing.T) {
t.Parallel()
mgr := databasetest.NewWebhookDBManager(t, t.TempDir())
t.Cleanup(func() { _ = mgr.CloseAll() })
events, err := mgr.GetDB(uuid.New().String())
require.NoError(t, err)
db, at := withFixedNow(events)
source := uuid.New().String()
assertRoundTrip(t, db, &database.Event{
BaseModel: newBaseModel(),
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
RawQuery: "a=1",
Headers: `{"X":"Y"}`,
Body: "body",
ContentType: "text/plain",
BodyBytes: 4,
ResubmittedFromID: &source,
})
assertRoundTrip(t, db, &database.Delivery{
BaseModel: newBaseModel(),
EventID: uuid.New().String(),
TargetID: uuid.New().String(),
Status: database.DeliveryStatusDelivered,
FinishedAt: &at,
Replay: true,
})
assertRoundTrip(t, db, &database.DeliveryResult{
BaseModel: newBaseModel(),
DeliveryID: uuid.New().String(),
AttemptNum: 2,
Success: true,
StatusCode: 200,
ResponseBody: "ok",
Error: "error",
Duration: 15,
})
assertRoundTrip(t, db, &database.TargetTotals{
TargetID: uuid.New().String(),
Deliveries: 5,
Delivered: 3,
Failed: 1,
DeliveriesRemoved: 2,
FailedRemoved: 1,
})
assertRoundTrip(t, db, &database.EntrypointTotals{
EntrypointID: uuid.New().String(), LastEventAt: at,
})
// A new event database has its one row of event totals already.
require.NoError(t, database.AddEventTotals(db, database.EventTotals{
Events: 3, EventsRemoved: 1, LastEventAt: &at,
}))
var totals []database.EventTotals
require.NoError(t, db.Find(&totals).Error)
assert.Equal(t, []database.EventTotals{{
ID: 1, Events: 3, EventsRemoved: 1, LastEventAt: &at,
}}, totals)
}
-263
View File
@@ -1,263 +0,0 @@
package database_test
import (
"context"
"io/fs"
"net/http"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"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"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
// ownerOnly is the mode every SQLite file the service owns must have.
// Spelled out rather than referencing database.SQLiteFilePerm so the
// test fails if the constant itself is loosened.
const ownerOnly fs.FileMode = 0o600
// requireOwnerOnly asserts that path exists and is readable and
// writable by its owner and by nobody else.
func requireOwnerOnly(t *testing.T, path string) {
t.Helper()
info, err := os.Stat(path)
require.NoError(t, err, "%s must exist", path)
assert.Equal(
t,
ownerOnly,
info.Mode().Perm(),
"%s holds credentials and must not be readable by "+
"anyone but its owner",
path,
)
}
// requireDatabaseSetOwnerOnly asserts the mode of a database file and
// of both WAL sidecars. The sidecars carry the same rows as the
// database, so tightening only the main file fixes nothing.
func requireDatabaseSetOwnerOnly(t *testing.T, dbPath string) {
t.Helper()
requireOwnerOnly(t, dbPath)
requireOwnerOnly(t, dbPath+"-wal")
requireOwnerOnly(t, dbPath+"-shm")
}
// TestMainDatabaseFilesAreOwnerOnly covers the tier the defect was
// reported against: webhooker.db holds targets.config in plaintext —
// bearer tokens, API keys, Slack webhook URLs — and the session
// encryption key.
func TestMainDatabaseFilesAreOwnerOnly(t *testing.T) {
t.Parallel()
lc := fxtest.NewLifecycle(t)
l, err := logger.New(lc, logger.LoggerParams{
Globals: &globals.Globals{
Appname: testAppname,
Version: testVersion,
},
})
require.NoError(t, err)
// A directory the application creates itself, not one t.TempDir
// made at 0700, so the mode below is the application's.
dataDir := filepath.Join(t.TempDir(), "data")
db, err := database.New(lc, database.DatabaseParams{
Config: &config.Config{DataDir: dataDir},
Logger: l,
})
require.NoError(t, err)
ctx := context.Background()
require.NoError(t, lc.Start(ctx))
defer func() { require.NoError(t, lc.Stop(ctx)) }()
// Write through the real model so the WAL is populated and both
// sidecars are on disk while the handle is open.
require.NoError(t, db.DB().Create(&database.Webhook{
Name: testWebhookName,
}).Error)
requireDatabaseSetOwnerOnly(
t, filepath.Join(dataDir, database.MainDBFileName),
)
// The data directory grants nothing to `other`. Asserted as a
// property rather than as an exact 0750, because MkdirAll applies
// the ambient umask: the exact mode is the developer's umask as
// much as the application's request, and pinning it would make
// `make check` pass or fail on where it is run. The group bits are
// deliberately left unasserted — deployments may rely on them.
info, err := os.Stat(dataDir)
require.NoError(t, err)
assert.Zero(
t,
info.Mode().Perm()&0o007,
"the data directory must not be world-accessible",
)
}
// TestPerWebhookEventDatabaseFilesAreOwnerOnly covers the events-*.db
// tier. These carry no credential canaries since
// https://git.eeqj.de/sneak/webhooker/issues/206, but they hold every
// received request body and header.
func TestPerWebhookEventDatabaseFilesAreOwnerOnly(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)
require.NoError(t, db.Create(&database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Body: "{}",
}).Error)
requireDatabaseSetOwnerOnly(t, mgr.DBPath(webhookID))
}
// TestArchiveDatabaseFilesAreOwnerOnly covers the archive-*.db tier.
// internal/archive builds that path and opens it through OpenSQLite,
// the same single open path exercised here, so the mode is settled for
// all three tiers in one place.
func TestArchiveDatabaseFilesAreOwnerOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
path := filepath.Join(
t.TempDir(), "archive-"+uuid.New().String()+".db",
)
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
defer func() { require.NoError(t, sqlDB.Close()) }()
_, err = sqlDB.ExecContext(ctx, "create table t (id integer)")
require.NoError(t, err)
requireDatabaseSetOwnerOnly(t, path)
}
// TestOpenSQLiteTightensFilesLeftWorldReadable is the upgrade case: a
// data directory an earlier build left at 0644, including a
// developer's own scratch directory, is fixed when it is opened rather
// than staying exposed until it is recreated.
func TestOpenSQLiteTightensFilesLeftWorldReadable(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
// A database and both sidecars as the pre-fix build left them.
for _, p := range []string{path, path + "-wal", path + "-shm"} {
require.NoError(t, os.WriteFile(p, nil, 0o644)) //nolint:gosec // the mode under test
}
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
require.NoError(t, sqlDB.Close())
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
// empty file materialized here would turn a missing-database error
// into a silently empty one.
func TestOpenSQLiteExistingModeDoesNotCreateTheFile(t *testing.T) {
t.Parallel()
ctx := context.Background()
path := filepath.Join(t.TempDir(), "absent.db")
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeExisting)
if err == nil {
// sql.Open is lazy: force the connection that fails.
require.Error(t, sqlDB.PingContext(ctx))
require.NoError(t, sqlDB.Close())
}
_, statErr := os.Stat(path)
assert.ErrorIs(t, statErr, fs.ErrNotExist)
}
// TestReopenAfterRestartKeepsFilesOwnerOnly is the restart case: a
// process that closed its files must be able to open them again at
// 0600, including through a gorm handle, and the sidecars must come
// back at 0600 too rather than at SQLite's own default.
func TestReopenAfterRestartKeepsFilesOwnerOnly(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
first, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
_, err = first.ExecContext(ctx, "create table t (id integer)")
require.NoError(t, err)
require.NoError(t, first.Close())
second, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
defer func() { require.NoError(t, second.Close()) }()
_, err = second.ExecContext(ctx, "insert into t (id) values (1)")
require.NoError(t, err)
requireDatabaseSetOwnerOnly(t, path)
var got int
require.NoError(t,
second.QueryRowContext(ctx, "select id from t").Scan(&got))
assert.Equal(t, 1, got)
}
-287
View File
@@ -1,287 +0,0 @@
package database
import (
"database/sql"
"errors"
"fmt"
"io/fs"
"net/url"
"os"
"syscall"
"time"
_ "modernc.org/sqlite" // Pure Go SQLite driver
)
// Every SQLite file this service opens — the main database, the
// per-webhook event databases, and the archive databases — is opened
// through OpenSQLite, so the durability settings below are properties
// of the service rather than of one call site.
//
// modernc.org/sqlite installs no busy handler and issues no pragmas of
// its own: it executes only the pragmas named in explicit `_pragma=`
// DSN parameters, and gorm.io/driver/sqlite adds none when it is
// handed an existing *sql.DB. Every setting therefore has to be
// spelled out here or it is simply not in effect.
// SQLite URI open modes.
const (
// SQLiteModeCreate creates the database file when it is missing.
SQLiteModeCreate = "rwc"
// SQLiteModeExisting requires the file to exist already.
SQLiteModeExisting = "rw"
)
const (
// SQLiteBusyTimeout is how long SQLite retries a lock conflict
// before returning SQLITE_BUSY.
//
// Under WAL a reader never blocks a writer, so the only conflict
// left is writer against writer: this process's delivery workers
// against each other, or against another process holding the write
// lock. Those clear in milliseconds. Ten seconds is far above that
// and still well inside the receiver's request budget, so an
// inbound webhook waits rather than being rejected with a 500.
SQLiteBusyTimeout = 10 * time.Second
// sqliteMaxOpenConns bounds the connection pool for one database
// file.
//
// The pool needs a bound at all because database/sql cannot detect
// a connection left mid-transaction: modernc.org/sqlite implements
// neither driver.Validator nor driver.SessionResetter, so a
// connection whose COMMIT failed is returned to the pool with its
// transaction still open and handed out again indefinitely. That is
// what turned four `database is locked` errors into 593
// `cannot start a transaction within a transaction` in
// https://git.eeqj.de/sneak/webhooker/issues/256.
//
// Four is above the one writer SQLite allows at a time, so reads
// still proceed while a write is in flight, and low enough that
// contention is resolved by the busy handler rather than by piling
// up connections against a lock only one of them can hold.
sqliteMaxOpenConns = 4
// sqliteMaxIdleConns keeps the pool warm without holding every
// connection open through an idle period.
sqliteMaxIdleConns = 2
// sqliteConnMaxLifetime and sqliteConnMaxIdleTime retire pooled
// connections on a schedule. With _txlock=immediate a failed
// COMMIT should no longer be reachable, but these bound the damage
// if one happens anyway: a poisoned connection is closed and
// replaced within the lifetime instead of wedging the file until
// the process restarts.
sqliteConnMaxLifetime = 5 * time.Minute
sqliteConnMaxIdleTime = time.Minute
)
// SQLiteFilePerm is the mode every SQLite file this service owns is
// created with and held at: owner read/write, nothing for group or
// other.
//
// These files hold credentials in plaintext. The main database stores
// `targets.config` — bearer tokens, API keys, Slack webhook URLs — and
// the session encryption key. SQLite left to itself creates them 0644
// (see reserveSQLiteFile), which made the 0750 data directory the only
// barrier; a bind-mounted directory supplied at 0755 removes it and
// every local user on the host can read every stored credential.
//
// This is a file-mode fix and not encryption at rest. An unattended
// process needs a key it can read without a human, so the key lands
// beside the data and an attacker who can read the database can read
// it too. See https://git.eeqj.de/sneak/webhooker/issues/212.
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.
//
// The mode has to be settled here rather than by a chmod after opening,
// because SQLite picks it: robust_open substitutes
// SQLITE_DEFAULT_FILE_PERMISSIONS (0644) whenever it is handed mode 0,
// and findCreateFileMode yields 0 for a main database opened by URI
// with no `modeof` parameter. A chmod afterwards would leave a window
// in which the credentials are on disk world-readable.
//
// Creating the file ourselves also settles the sidecars, which is the
// half that could quietly not work. SQLite does not create those at a
// mode we choose — it derives both from the main database file:
// `-wal` through findCreateFileMode, which stats the path with the
// suffix stripped, and `-shm` in unixOpenSharedMemory from an fstat of
// the already-open database descriptor. A main file at 0600 therefore
// produces sidecars at 0600. A zero-length file is a valid empty
// database, so reserving it changes nothing else.
//
// create says whether the caller is opening in a mode that may create
// the database. When it is false a missing file is left missing, so
// SQLite still reports the absence rather than this function
// materializing an empty database the caller asked not to create.
//
// The mode is set on every open, whoever created the file, so a
// database or sidecar found at a wider mode is tightened too.
func reserveSQLiteFile(path string, create bool) error {
if create {
// gosec G304: the path is the database file the caller asked
// to open, and the driver is about to open the same path
// anyway. Creating it here is what fixes its mode.
f, err := os.OpenFile( //nolint:gosec // see above
path, os.O_RDWR|os.O_CREATE, SQLiteFilePerm,
)
if err != nil {
return fmt.Errorf("creating %s: %w", path, err)
}
err = f.Close()
if err != nil {
return fmt.Errorf("closing %s: %w", path, err)
}
}
// O_CREATE leaves an existing file's mode alone, and umask can only
// have narrowed a new one. Chmod settles both cases at exactly
// SQLiteFilePerm.
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)
if err != nil && !errors.Is(err, fs.ErrNotExist) {
return fmt.Errorf("securing %s: %w", p, err)
}
}
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.
func sqliteSidecarPaths(path string) []string {
return []string{path + "-wal", path + "-shm"}
}
// SQLiteDSN builds the connection string for one database file.
//
// mode is the SQLite URI open mode: "rwc" to create the file when it
// is missing, "rw" to require that it already exists.
//
// Three settings carry the fix for
// https://git.eeqj.de/sneak/webhooker/issues/256 and none of them is
// optional:
//
// - journal_mode=WAL, so a reader — an operator running
// `sqlite3 <db> .dump` over their own data — takes a snapshot
// instead of blocking every writer behind it.
//
// - busy_timeout, so a writer that does meet a lock waits for it.
// Without one SQLite gives up immediately; nothing above it
// retries.
//
// - _txlock=immediate, so every transaction takes the write lock at
// BEGIN. A deferred transaction acquires it lazily on its first
// write, and that upgrade returns SQLITE_BUSY *without* consulting
// the busy handler, because SQLite cannot block a transaction that
// may already hold a read snapshot. Such a COMMIT then fails while
// the transaction stays open on the connection. A busy timeout
// alone does not prevent this; BEGIN IMMEDIATE does, by putting
// the wait somewhere the handler applies.
//
// Note what is absent: `cache=shared`. Under a shared cache an
// in-process conflict is reported as SQLITE_LOCKED rather than
// SQLITE_BUSY, and the busy handler does not retry SQLITE_LOCKED — so
// leaving it in would have defeated the busy timeout for exactly the
// contention this service generates. Dropping it is part of the fix,
// not housekeeping.
//
// synchronous is deliberately left at SQLite's default of FULL: this
// is a webhook receiver whose one promise is that an event it answered
// 200 for is durable.
// The order of the _pragma parameters is load-bearing.
// modernc.org/sqlite executes them in the order they appear, on every
// new connection, before the connection is handed to the pool. Setting
// journal_mode first means that pragma itself runs with no busy
// handler installed: the pool opens connections lazily, so the moment
// a new one is created is a moment the database is under load, and
// PRAGMA journal_mode takes a lock. It would fail immediately with
// SQLITE_BUSY and fail the query that caused the connection to be
// opened. busy_timeout is therefore set first, so every pragma after
// it — and the whole life of the connection — is covered.
func SQLiteDSN(path, mode string) string {
q := url.Values{}
q.Set("mode", mode)
q.Set("_txlock", "immediate")
q.Add(
"_pragma",
fmt.Sprintf(
"busy_timeout(%d)",
SQLiteBusyTimeout.Milliseconds(),
),
)
q.Add("_pragma", "journal_mode(WAL)")
// SQLite reads a file: URI's path only up to the first ? or #, and
// decodes %XX escapes in it, so url.URL escapes the path. OmitHost
// keeps a relative path as "file:data/x.db"; written
// "file://data/x.db", SQLite would reject "data" as the URI's
// authority.
dsn := url.URL{
Scheme: "file",
Path: path,
OmitHost: true,
RawQuery: q.Encode(),
}
return dsn.String()
}
// OpenSQLite opens the SQLite file at path with the service's
// durability settings and pool bounds applied. mode is the SQLite URI
// open mode ("rwc" or "rw").
//
// The file and its WAL sidecars are settled at SQLiteFilePerm before
// the driver sees the path; see reserveSQLiteFile.
//
// The handle is returned rather than a *gorm.DB because the callers
// wrap it in gorm themselves with their own logger.
func OpenSQLite(path, mode string) (*sql.DB, error) {
err := reserveSQLiteFile(path, mode == SQLiteModeCreate)
if err != nil {
return nil, err
}
sqlDB, err := sql.Open("sqlite", SQLiteDSN(path, mode))
if err != nil {
return nil, fmt.Errorf(
"opening sqlite database %s: %w", path, err,
)
}
sqlDB.SetMaxOpenConns(sqliteMaxOpenConns)
sqlDB.SetMaxIdleConns(sqliteMaxIdleConns)
sqlDB.SetConnMaxLifetime(sqliteConnMaxLifetime)
sqlDB.SetConnMaxIdleTime(sqliteConnMaxIdleTime)
return sqlDB, nil
}
-253
View File
@@ -1,253 +0,0 @@
package database_test
import (
"context"
"database/sql"
"os"
"path/filepath"
"strings"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// livePragma reads a pragma off a live handle. Reading the DSN back
// would prove only that the string was built; these tests assert that
// SQLite actually applied it.
func livePragma(t *testing.T, db *gorm.DB, name string) string {
t.Helper()
var v string
row := db.Raw("pragma " + name).Row()
require.NoError(t, row.Scan(&v))
return v
}
func TestSQLiteDSNCarriesTheDurabilitySettings(t *testing.T) {
t.Parallel()
dsn := database.SQLiteDSN(
"/var/lib/webhooker/webhooker.db",
database.SQLiteModeCreate,
)
assert.Contains(t, dsn, "journal_mode%28WAL%29")
assert.Contains(t, dsn, "busy_timeout%2810000%29")
assert.Contains(t, dsn, "_txlock=immediate")
assert.Contains(t, dsn, "mode=rwc")
// busy_timeout must come first. The driver runs these in order on
// every new connection, and PRAGMA journal_mode takes a lock — a
// connection opened while the database is busy would fail on that
// pragma, with no busy handler yet installed to wait it out.
assert.Less(
t,
strings.Index(dsn, "busy_timeout"),
strings.Index(dsn, "journal_mode"),
"busy_timeout must be applied before journal_mode",
)
// cache=shared turns an in-process conflict into SQLITE_LOCKED,
// which the busy handler does not retry. It must never come back.
// See https://git.eeqj.de/sneak/webhooker/issues/256.
assert.NotContains(t, strings.ToLower(dsn), "cache=shared")
}
// TestPerWebhookDBAppliesPragmasOnALiveHandle is the check the issue
// asks for by name: the settings are confirmed by querying the running
// database, not by inspecting the connection string.
func TestPerWebhookDBAppliesPragmasOnALiveHandle(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)
assert.Equal(
t, "wal",
strings.ToLower(livePragma(t, db, "journal_mode")),
)
assert.Equal(
t, "10000", livePragma(t, db, "busy_timeout"),
)
}
func TestMainDBAppliesPragmasOnALiveHandle(t *testing.T) {
t.Parallel()
ctx := context.Background()
dir := t.TempDir()
sqlDB, err := database.OpenSQLite(
filepath.Join(dir, database.MainDBFileName),
database.SQLiteModeCreate,
)
require.NoError(t, err)
defer func() { require.NoError(t, sqlDB.Close()) }()
var journal string
require.NoError(t, sqlDB.
QueryRowContext(ctx, "pragma journal_mode").
Scan(&journal))
assert.Equal(t, "wal", strings.ToLower(journal))
var busy string
require.NoError(t, sqlDB.
QueryRowContext(ctx, "pragma busy_timeout").
Scan(&busy))
assert.Equal(t, "10000", busy)
}
// TestOpenSQLiteOpensThePathAskedFor uses a data directory whose name
// holds ?, # and %. SQLite reads a file: URI's path only up to a ? or
// #, and decodes %41 to A, so an unescaped path sent every database in
// that directory to one file outside it.
func TestOpenSQLiteOpensThePathAskedFor(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "hooks#1?x%41")
require.NoError(t, os.Mkdir(dir, 0o700))
for _, name := range []string{
database.MainDBFileName,
"events-" + uuid.New().String() + ".db",
} {
path := filepath.Join(dir, name)
sqlDB, err := database.OpenSQLite(
path, database.SQLiteModeCreate,
)
require.NoError(t, err)
t.Cleanup(func() { assert.NoError(t, sqlDB.Close()) })
assertOpenedFile(t, sqlDB, path)
}
}
// TestOpenSQLiteOpensARelativePath opens a database at a path relative
// to the working directory, which is what a relative DATA_DIR such as
// the README's DATA_DIR=../webhooker-data gives OpenSQLite.
//
//nolint:paralleltest // t.Chdir moves the whole process.
func TestOpenSQLiteOpensARelativePath(t *testing.T) {
t.Chdir(t.TempDir())
require.NoError(t, os.Mkdir("data", 0o700))
path := filepath.Join("data", database.MainDBFileName)
sqlDB, err := database.OpenSQLite(path, database.SQLiteModeCreate)
require.NoError(t, err)
defer func() { require.NoError(t, sqlDB.Close()) }()
assertOpenedFile(t, sqlDB, path)
}
// assertOpenedFile checks that sqlDB is the SQLite file at path.
// database_list names the file SQLite actually opened, as an absolute
// path with symlinks resolved, so the files are compared, not the names.
func assertOpenedFile(t *testing.T, sqlDB *sql.DB, path string) {
t.Helper()
var (
seq int
schema, file string
)
require.NoError(t, sqlDB.
QueryRowContext(t.Context(), "pragma database_list").
Scan(&seq, &schema, &file))
asked, err := os.Stat(path)
require.NoError(t, err)
opened, err := os.Stat(file)
require.NoError(t, err)
assert.True(
t, os.SameFile(asked, opened),
"asked for %s, SQLite opened %s", path, file,
)
}
// TestConcurrentReaderDoesNotBlockWrites is the unit-scale form of the
// reproduction in
// https://git.eeqj.de/sneak/webhooker/issues/256: an operator's
// long-held read of their own data used to make every concurrent write
// fail. Under WAL the reader takes a snapshot and the writes proceed.
func TestConcurrentReaderDoesNotBlockWrites(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)
// A second handle on the same file, holding a read transaction
// open across every write below — what `sqlite3 <db> .dump` is.
readerSQL, err := database.OpenSQLite(
mgr.DBPath(webhookID), database.SQLiteModeExisting,
)
require.NoError(t, err)
defer func() { require.NoError(t, readerSQL.Close()) }()
readerConn, err := readerSQL.Conn(ctx)
require.NoError(t, err)
defer func() { require.NoError(t, readerConn.Close()) }()
_, err = readerConn.ExecContext(ctx, "begin deferred")
require.NoError(t, err)
_, err = readerConn.ExecContext(
ctx, "select count(*) from events",
)
require.NoError(t, err)
for range 25 {
err = db.Transaction(func(tx *gorm.DB) error {
return tx.Create(&database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: "POST",
Body: "{}",
}).Error
})
require.NoError(t, err)
}
_, err = readerConn.ExecContext(ctx, "commit")
require.NoError(t, err)
var count int64
require.NoError(
t,
db.Model(&database.Event{}).Count(&count).Error,
)
assert.Equal(t, int64(25), count)
}
+28
View File
@@ -0,0 +1,28 @@
package database
import (
"log/slog"
"os"
"gorm.io/gorm"
)
// NewTestDatabase creates a Database wrapper around a pre-opened *gorm.DB.
// Intended for use in tests that need a *database.Database without the
// full fx lifecycle. The caller is responsible for closing the underlying
// sql.DB connection.
func NewTestDatabase(db *gorm.DB) *Database {
return &Database{
db: db,
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
}
}
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given
// data directory. Intended for use in tests without the fx lifecycle.
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
return &WebhookDBManager{
dataDir: dataDir,
log: slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelDebug})),
}
}
-417
View File
@@ -1,417 +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.
//
// It and seedBareEvents insert 50 rows per statement, not more. The
// SQLite driver looks up each parameter's value by scanning the
// statement's arguments from the first until it reaches that
// parameter's, so the time to bind a statement grows with the square of
// its parameter count: at 500 rows, several thousand parameters, the
// seeding took most of these tests' time under -race.
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, 50).Error)
require.NoError(t, db.CreateInBatches(deliveries, 50).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, 50).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, 50).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)
}
}
+65 -188
View File
@@ -2,6 +2,7 @@ package database
import (
"context"
"database/sql"
"errors"
"fmt"
"log/slog"
@@ -13,9 +14,7 @@ import (
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger"
)
// WebhookDBManagerParams holds the fx dependencies for
@@ -24,7 +23,7 @@ type WebhookDBManagerParams struct {
fx.In
Config *config.Config
Logger *slog.Logger
Logger *logger.Logger
}
// errInvalidCachedDBType indicates a type assertion failure
@@ -33,33 +32,14 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type",
)
// ErrEventDBNotRemoved is in DeleteDB's error when the event
// database file itself could not be removed: it is still on disk.
var ErrEventDBNotRemoved = errors.New(
"event database file not removed",
)
// ErrSidecarNotRemoved is in DeleteDB's error when the event
// database file was removed, so its events are gone, but its -wal
// or -shm sidecar could not be.
var ErrSidecarNotRemoved = errors.New(
"event database file removed, but a -wal or -shm sidecar was not",
)
// WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// database containing Events, Deliveries, and DeliveryResults.
// Database connections are opened lazily and cached.
type WebhookDBManager struct {
dataDir string
dbs sync.Map // map[webhookID]*gorm.DB
log *slog.Logger
// mu is held while a database is opened, deleted, or closed, so
// each file has at most one open handle. Reading an already cached
// handle does not take it.
mu sync.Mutex
}
// NewWebhookDBManager creates a new WebhookDBManager and
@@ -70,12 +50,11 @@ func NewWebhookDBManager(
) (*WebhookDBManager, error) {
m := &WebhookDBManager{
dataDir: params.Config.DataDir,
log: params.Logger,
log: params.Logger.Get(),
}
// Create data directory if it doesn't exist. datadir.DirPerm is the
// single source of the directory mode; either package may run first.
err := os.MkdirAll(m.dataDir, datadir.DirPerm)
// Create data directory if it doesn't exist
err := os.MkdirAll(m.dataDir, dataDirPerm)
if err != nil {
return nil, fmt.Errorf(
"creating data directory %s: %w",
@@ -98,60 +77,61 @@ 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 {
cachedDB, castOK := val.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf(
"%w for webhook %s",
errInvalidCachedDBType,
webhookID,
)
}
// GetDBIf is GetDB, done only when check reports true. check runs under
// the lock DeleteDB holds while it removes the files, so a caller can
// confirm the webhook still exists and open its database with no delete
// in between. The handle is nil when check reports false. check must
// not call the manager.
func (m *WebhookDBManager) GetDBIf(
webhookID string, check func() (bool, error),
) (*gorm.DB, error) {
m.mu.Lock()
defer m.mu.Unlock()
return cachedDB, nil
}
ok, err := check()
if err != nil || !ok {
// Slow path: open/create the database
db, err := m.openDB(webhookID)
if err != nil {
return nil, err
}
return m.getDBLocked(webhookID, false)
}
// Store it; if another goroutine beat us, close ours
actual, loaded := m.dbs.LoadOrStore(webhookID, db)
if loaded {
// Another goroutine created it first; close our duplicate
sqlDB, closeErr := db.DB()
if closeErr == nil {
_ = sqlDB.Close()
}
// asGormDB returns a value read from the cache as the database
// handle it is.
func asGormDB(val any, webhookID string) (*gorm.DB, error) {
db, ok := val.(*gorm.DB)
if !ok {
return nil, fmt.Errorf(
"%w for webhook %s",
errInvalidCachedDBType,
webhookID,
)
existingDB, castOK := actual.(*gorm.DB)
if !castOK {
return nil, fmt.Errorf(
"%w for webhook %s",
errInvalidCachedDBType,
webhookID,
)
}
return existingDB, nil
}
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
}
@@ -167,18 +147,10 @@ func (m *WebhookDBManager) DBExists(
}
// DeleteDB closes the connection and deletes the database file
// for a webhook, with its -wal and -shm sidecars. The files are
// permanently removed. Each file is tried even when another could
// not be removed, and the error wraps ErrEventDBNotRemoved or
// ErrSidecarNotRemoved to say which was left, naming each file.
// for a webhook. The file is permanently removed.
func (m *WebhookDBManager) DeleteDB(
webhookID string,
) error {
// Held until the files are gone, so GetDB cannot open the file
// again between the close and the removal.
m.mu.Lock()
defer m.mu.Unlock()
// Close and remove from cache
if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
if gormDB, castOK := val.(*gorm.DB); castOK {
@@ -189,23 +161,16 @@ func (m *WebhookDBManager) DeleteDB(
}
}
// Delete the main DB file and WAL/SHM files
path := m.dbPath(webhookID)
dbErr := removeFile(path)
sidecarErr := errors.Join(
removeFile(path+"-wal"),
removeFile(path+"-shm"),
)
if dbErr != nil {
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
for _, suffix := range []string{"", "-wal", "-shm"} {
err := os.Remove(path + suffix)
if err != nil && !os.IsNotExist(err) {
return fmt.Errorf(
"deleting webhook database file %s%s: %w",
path, suffix, err,
)
}
}
m.log.Info(
@@ -216,25 +181,9 @@ func (m *WebhookDBManager) DeleteDB(
return nil
}
// removeFile removes path. A file that is already gone counts as
// removed; the error from any other failure names the file.
func removeFile(path string) error {
err := os.Remove(path)
if errors.Is(err, os.ErrNotExist) {
return nil
}
return err
}
// CloseAll closes all open per-webhook database connections.
// Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error {
// An open already under way finishes and is cached first, so it
// is closed here rather than cached after this loop has passed.
m.mu.Lock()
defer m.mu.Unlock()
var lastErr error
m.dbs.Range(func(key, value any) bool {
@@ -269,54 +218,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)
}
m.mu.Lock()
defer m.mu.Unlock()
return m.getDBLocked(webhookID, isNew)
}
// getDBLocked is getDB's slow path, run with m.mu held. It looks in the
// cache again first: a caller that raced another one to the lock then
// gets its handle instead of opening a second one.
func (m *WebhookDBManager) getDBLocked(
webhookID string, isNew bool,
) (*gorm.DB, error) {
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 {
@@ -332,11 +233,12 @@ func (m *WebhookDBManager) openDB(
webhookID string,
) (*gorm.DB, error) {
path := m.dbPath(webhookID)
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
path,
)
// See sqlite_open.go: WAL, a busy timeout, immediate-transaction
// locking, and a bounded pool, all of which this file needs most —
// it is the one every delivery worker writes to concurrently.
sqlDB, err := OpenSQLite(path, SQLiteModeCreate)
sqlDB, err := sql.Open("sqlite", dbURL)
if err != nil {
return nil, fmt.Errorf(
"opening webhook database %s: %w",
@@ -346,10 +248,7 @@ func (m *WebhookDBManager) openDB(
db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB,
}, &gorm.Config{
// Never leave this at GORM's default. See internal/gormlog.
Logger: gormlog.New(m.log),
})
}, &gorm.Config{})
if err != nil {
_ = sqlDB.Close()
@@ -359,37 +258,15 @@ func (m *WebhookDBManager) openDB(
)
}
// Keep main-database rows out of this file. See
// event_db_isolation.go.
err = omitAssociations(db)
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"guarding webhook database %s: %w",
webhookID, err,
)
}
err = migrations.Apply(db, migrations.Events)
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"migrating webhook database %s: %w",
webhookID, err,
)
}
err = parseModels(
db, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
// Run migrations for event-tier models only
err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{},
)
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"connecting to webhook database %s: %w",
"migrating webhook database %s: %w",
webhookID, err,
)
}
+19 -223
View File
@@ -1,14 +1,9 @@
package database_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"github.com/google/uuid"
@@ -18,7 +13,6 @@ import (
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger"
)
@@ -31,8 +25,8 @@ func setupTestWebhookDBManager(
lc := fxtest.NewLifecycle(t)
g := &globals.Globals{
Appname: testAppname,
Version: testVersion,
Appname: "webhooker-test",
Version: "test",
}
l, err := logger.New(
@@ -51,7 +45,7 @@ func setupTestWebhookDBManager(
lc,
database.WebhookDBManagerParams{
Config: cfg,
Logger: l.Get(),
Logger: l,
},
)
require.NoError(t, err)
@@ -89,10 +83,10 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Headers: `{"Content-Type":["application/json"]}`,
Body: `{"test": true}`,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db.Create(event).Error)
assert.NotEmpty(t, event.ID)
@@ -105,59 +99,10 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
db.First(&readEvent, "id = ?", event.ID).Error,
)
assert.Equal(t, webhookID, readEvent.WebhookID)
assert.Equal(t, http.MethodPost, readEvent.Method)
assert.Equal(t, "POST", readEvent.Method)
assert.Equal(t, `{"test": true}`, readEvent.Body)
}
// Many callers ask for one webhook's database at the same moment,
// before it is cached. Only one of them may open the file; the others
// must wait for its handle. openDB logs one "opened per-webhook
// database" line per open, and those lines are what is counted.
func TestWebhookDBManager_ConcurrentFirstTouchOpensOnce(t *testing.T) {
t.Parallel()
var logs bytes.Buffer
mgr := databasetest.NewWebhookDBManagerWithLogger(
t,
t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { assert.NoError(t, mgr.CloseAll()) })
webhookID := uuid.New().String()
const callers = 16
start := make(chan struct{})
handles := make([]*gorm.DB, callers)
errs := make([]error, callers)
var wg sync.WaitGroup
for i := range callers {
wg.Go(func() {
<-start
handles[i], errs[i] = mgr.GetDB(webhookID)
})
}
close(start)
wg.Wait()
for i := range callers {
require.NoError(t, errs[i])
assert.Same(t, handles[0], handles[i])
}
assert.Equal(
t, 1,
strings.Count(logs.String(), "opened per-webhook database"),
)
}
func TestWebhookDBManager_DeleteDB(t *testing.T) {
t.Parallel()
@@ -178,97 +123,23 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Body: `{"test": true}`,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db.Create(event).Error)
// Under WAL, an open database that has been written to has both
// sidecars beside it.
dbPath := mgr.DBPath(webhookID)
require.FileExists(t, dbPath+"-wal")
require.FileExists(t, dbPath+"-shm")
// Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist
assert.False(t, mgr.DBExists(webhookID))
// Verify the files are actually gone from disk
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// blockRemoval puts a non-empty directory at path, which os.Remove
// cannot remove whoever runs the test, root included.
func blockRemoval(t *testing.T, path string) {
t.Helper()
require.NoError(t, os.MkdirAll(filepath.Join(path, "keep"), 0o700))
}
// TestWebhookDBManager_DeleteDBKeepsDatabaseFile proves that when the
// event database file cannot be removed, the error says so, and both
// sidecars are still removed.
func TestWebhookDBManager_DeleteDBKeepsDatabaseFile(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()
// Verify the file is actually gone from disk
dbPath := mgr.DBPath(webhookID)
blockRemoval(t, dbPath)
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600))
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrEventDBNotRemoved)
require.NotErrorIs(t, err, database.ErrSidecarNotRemoved)
assert.Contains(t, err.Error(), dbPath)
assert.NoFileExists(t, dbPath+"-wal")
assert.NoFileExists(t, dbPath+"-shm")
}
// TestWebhookDBManager_DeleteDBKeepsSidecar proves that when the
// event database file is removed but a sidecar is not, the error
// says the database file is gone, and the other sidecar is still
// removed.
func TestWebhookDBManager_DeleteDBKeepsSidecar(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()
dbPath := mgr.DBPath(webhookID)
require.NoError(t, mgr.CreateDB(webhookID))
// Closing removes the sidecars, so the ones below are the only
// ones there.
require.NoError(t, mgr.CloseAll())
blockRemoval(t, dbPath+"-wal")
require.NoError(t, os.WriteFile(dbPath+"-shm", nil, 0o600))
err := mgr.DeleteDB(webhookID)
require.ErrorIs(t, err, database.ErrSidecarNotRemoved)
require.NotErrorIs(t, err, database.ErrEventDBNotRemoved)
assert.Contains(t, err.Error(), dbPath+"-wal")
assert.NoFileExists(t, dbPath)
assert.NoFileExists(t, dbPath+"-shm")
_, err = os.Stat(dbPath)
assert.True(t, os.IsNotExist(err))
}
func TestWebhookDBManager_LazyCreation(t *testing.T) {
@@ -291,76 +162,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 := databasetest.NewWebhookDBManagerWithLogger(
t,
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()
@@ -395,10 +196,10 @@ func seedDeliveryWorkflow(
event := &database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Headers: `{"Content-Type":["application/json"]}`,
Body: `{"payload": "test"}`,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db.Create(event).Error)
@@ -430,7 +231,7 @@ func verifyPendingDeliveries(
)
require.Len(t, pending, 1)
assert.Equal(t, event.ID, pending[0].EventID)
assert.Equal(t, http.MethodPost, pending[0].Event.Method)
assert.Equal(t, "POST", pending[0].Event.Method)
}
func completeDelivery(
@@ -502,16 +303,16 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
event1 := &database.Event{
WebhookID: webhook1,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Body: `{"webhook": 1}`,
ContentType: testContentType,
ContentType: "application/json",
}
event2 := &database.Event{
WebhookID: webhook2,
EntrypointID: uuid.New().String(),
Method: http.MethodPut,
Method: "PUT",
Body: `{"webhook": 2}`,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db1.Create(event1).Error)
@@ -537,12 +338,7 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
var events []database.Event
require.NoError(t, db2.Find(&events).Error)
// require, not assert: this is exactly the regression the test
// guards, so the empty slice is the expected failure, and a
// non-fatal length check would index into it on the next line and
// panic the whole package test binary instead of failing here.
require.Len(t, events, 1)
assert.Len(t, events, 1)
assert.Equal(t, "PUT", events[0].Method)
}
-128
View File
@@ -1,128 +0,0 @@
// Package datadir guards exclusive access to the directory holding
// every SQLite database webhooker writes.
//
// Two processes sharing a DATA_DIR each open the same per-webhook
// event databases and each run delivery recovery over the same rows,
// so every pending delivery goes out twice. SQLite's own locking does
// not prevent that: both writers are serialised correctly and both
// deliver. The only thing that prevents it is refusing to be the
// second process.
//
// The lock lives here rather than in the server's fx graph so that any
// entry point which touches DATA_DIR — the server, or a CLI
// subcommand that must not operate on a live deployment's data — takes
// it the same way.
package datadir
import (
"errors"
"fmt"
"os"
"path/filepath"
"github.com/gofrs/flock"
)
// LockFileName is the advisory lock file created inside DATA_DIR. Its
// contents are never read: the lock is the flock(2) held on the open
// descriptor, not the file's existence, so a leftover file from a
// process that was killed with SIGKILL blocks nothing.
const LockFileName = "webhooker.lock"
// DirPerm is the mode DATA_DIR is created with. It is the single
// source of that mode: internal/database consumes it rather than
// keeping its own copy, so the two packages that both create the
// directory cannot drift into disagreeing about its permissions.
const DirPerm = 0o750
// ErrLocked reports that another live process holds the data
// directory. Callers that need to know whether a deployment is running
// — rather than merely failing to start — test for this with
// errors.Is.
var ErrLocked = errors.New(
"data directory is already in use by another instance",
)
// ErrNoDir reports that Acquire was given an empty directory.
var ErrNoDir = errors.New("no data directory given")
// Lock is a held exclusive advisory lock on a data directory. It is
// valid only while the process that took it lives: the kernel drops it
// when the descriptor closes, whether that is Release, a normal exit,
// or a SIGKILL.
type Lock struct {
dir string
file *flock.Flock
}
// Acquire takes the exclusive advisory lock on dir, creating dir if it
// does not exist. It never waits: if another process holds the lock it
// returns an error wrapping ErrLocked and naming dir.
//
// The returned Lock must be held for as long as the caller intends to
// use dir.
func Acquire(dir string) (*Lock, error) {
if dir == "" {
return nil, ErrNoDir
}
err := os.MkdirAll(dir, DirPerm)
if err != nil {
return nil, fmt.Errorf(
"creating data directory %s: %w", dir, err,
)
}
path := filepath.Join(dir, LockFileName)
fl := flock.New(path)
held, err := fl.TryLock()
if err != nil {
return nil, fmt.Errorf(
"locking data directory %s: %w", dir, err,
)
}
if !held {
// A no-op on flock v0.13.0, which closes its own descriptor on
// a failed TryLock; kept so no version can leak one.
_ = fl.Close()
return nil, fmt.Errorf(
"%w: %s (%s). Only one webhooker may use a data "+
"directory: two both run delivery recovery over the "+
"same rows and both deliver",
ErrLocked, dir, path,
)
}
return &Lock{dir: dir, file: fl}, nil
}
// Dir returns the locked directory.
func (l *Lock) Dir() string {
return l.dir
}
// Path returns the lock file backing the lock.
func (l *Lock) Path() string {
return l.file.Path()
}
// Release drops the lock and closes the descriptor. It is safe to call
// more than once.
//
// The lock file is deliberately left on disk. Unlinking it would let
// the next process create and lock a fresh inode while a third still
// holds the old one, which is the one outcome this package exists to
// prevent.
func (l *Lock) Release() error {
err := l.file.Unlock()
if err != nil {
return fmt.Errorf(
"releasing lock on data directory %s: %w", l.dir, err,
)
}
return nil
}
-250
View File
@@ -1,250 +0,0 @@
package datadir_test
import (
"bufio"
"fmt"
"io"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/datadir"
)
// holderEnv names the directory the re-executed test binary should
// lock and hold. When it is unset the child test does nothing, so an
// ordinary run is unaffected.
const holderEnv = "WEBHOOKER_DATADIR_LOCK_HOLDER"
// holderReadyPrefix labels the child's one-line report that it holds
// the lock, so the parent can find it among the testing package's own
// output on the same descriptor.
const holderReadyPrefix = "DATADIR-LOCK-HELD "
// holderReadyTimeout bounds the wait for the child to take the lock.
// It only has to cover process start on a loaded shared host.
const holderReadyTimeout = 60 * time.Second
// holderHold is how long the child keeps the lock if nothing kills it.
// A sleep rather than a bare block, so the runtime's deadlock detector
// has a pending timer and the child cannot outlive a killed test run
// by more than this.
const holderHold = 10 * time.Minute
// TestLockHolder is the child half of the two-process tests below. It
// takes the lock on the directory named by holderEnv, reports the lock
// file on standard output, and then holds it until it is killed.
func TestLockHolder(t *testing.T) {
t.Parallel()
dir := os.Getenv(holderEnv)
if dir == "" {
return
}
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
// Written to the descriptor directly: the parent reads fd 1, not
// the testing package's buffered report.
_, err = fmt.Fprintf(
os.Stdout, "%s%s\n", holderReadyPrefix, lock.Path(),
)
require.NoError(t, err)
time.Sleep(holderHold)
}
// startHolder re-executes this test binary as a separate process that
// takes and holds the lock on dir, and returns once that process
// actually holds it. The child is killed when the test ends.
func startHolder(t *testing.T, dir string) *exec.Cmd {
t.Helper()
//nolint:gosec // Re-executing this test binary, with a fixed arg.
cmd := exec.CommandContext(
t.Context(), os.Args[0], "-test.run", "^TestLockHolder$",
)
cmd.Env = append(os.Environ(), holderEnv+"="+dir)
cmd.Stderr = os.Stderr
stdout, err := cmd.StdoutPipe()
require.NoError(t, err)
require.NoError(t, cmd.Start())
t.Cleanup(func() {
_ = cmd.Process.Kill()
_ = cmd.Wait()
})
ready := make(chan string, 1)
go func() {
scanner := bufio.NewScanner(stdout)
for scanner.Scan() {
after, found := strings.CutPrefix(
scanner.Text(), holderReadyPrefix,
)
if found {
ready <- after
break
}
}
close(ready)
// Keep draining so the child never blocks on a full pipe.
_, _ = io.Copy(io.Discard, stdout)
}()
select {
case path, ok := <-ready:
require.True(
t, ok, "holder exited without taking the lock",
)
require.Equal(t, filepath.Join(dir, datadir.LockFileName), path)
case <-time.After(holderReadyTimeout):
t.Fatal("timed out waiting for the holder to take the lock")
}
return cmd
}
// TestSecondInstanceRefused is the regression test for the duplicate
// delivery this package exists to prevent: a real second process
// pointed at a data directory a live process already holds must be
// refused, with an error that names the directory.
func TestSecondInstanceRefused(t *testing.T) {
t.Parallel()
dir := t.TempDir()
startHolder(t, dir)
lock, err := datadir.Acquire(dir)
require.Error(t, err, "the second instance took the lock too")
require.Nil(t, lock)
require.ErrorIs(
t, err, datadir.ErrLocked,
"the refusal must be distinguishable from any other failure",
)
assert.Contains(
t, err.Error(), dir,
"the refusal must name the directory it is about",
)
}
// TestRestartAfterHardKill is the other half of the regression: a
// process killed with SIGKILL runs no cleanup and leaves its lock file
// behind, and the next start must not be blocked by it. This is what a
// pidfile would get wrong; the kernel drops a flock when the
// descriptor closes, however the process died.
func TestRestartAfterHardKill(t *testing.T) {
t.Parallel()
dir := t.TempDir()
holder := startHolder(t, dir)
require.NoError(t, holder.Process.Kill())
// Wait for the kill to have actually happened. Re-acquiring while
// the corpse still holds a descriptor would be a race, and would
// make this test pass or fail on scheduling.
_ = holder.Wait()
require.FileExists(
t, filepath.Join(dir, datadir.LockFileName),
"the stale lock file is what must not block the restart",
)
lock, err := datadir.Acquire(dir)
require.NoError(
t, err, "a hard-killed instance must not block the next start",
)
require.NoError(t, lock.Release())
}
// TestSecondFdInSameProcessRefused pins the flock(2) property the
// tests in cmd/webhooker rely on: descriptors are locked
// independently, so a second acquisition is denied even when it comes
// from the process that already holds the lock.
func TestSecondFdInSameProcessRefused(t *testing.T) {
t.Parallel()
dir := t.TempDir()
first, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { _ = first.Release() }()
_, err = datadir.Acquire(dir)
require.ErrorIs(t, err, datadir.ErrLocked)
}
// TestReleaseAllowsReacquire covers the clean-shutdown path: the lock
// is released on exit, so a restart is not blocked by the previous
// run.
func TestReleaseAllowsReacquire(t *testing.T) {
t.Parallel()
dir := t.TempDir()
first, err := datadir.Acquire(dir)
require.NoError(t, err)
require.NoError(t, first.Release())
second, err := datadir.Acquire(dir)
require.NoError(t, err)
require.NoError(t, second.Release())
}
// TestAcquireCreatesDataDir covers a first start against a DATA_DIR
// that does not exist yet, which is the normal case for a fresh
// deployment: the lock is taken before anything else creates it.
func TestAcquireCreatesDataDir(t *testing.T) {
t.Parallel()
dir := filepath.Join(t.TempDir(), "nested", "data")
lock, err := datadir.Acquire(dir)
require.NoError(t, err)
defer func() { _ = lock.Release() }()
assert.Equal(t, dir, lock.Dir())
assert.FileExists(t, filepath.Join(dir, datadir.LockFileName))
}
// TestAcquireEmptyDir rejects an empty directory rather than locking
// the process's working directory.
func TestAcquireEmptyDir(t *testing.T) {
t.Parallel()
_, err := datadir.Acquire("")
require.ErrorIs(t, err, datadir.ErrNoDir)
}
// TestAcquireUnusableDir reports an unusable DATA_DIR clearly, naming
// it, instead of failing later and deeper.
func TestAcquireUnusableDir(t *testing.T) {
t.Parallel()
file := filepath.Join(t.TempDir(), "not-a-directory")
require.NoError(t, os.WriteFile(file, nil, 0o600))
_, err := datadir.Acquire(file)
require.Error(t, err)
assert.Contains(t, err.Error(), file)
}
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,18 +0,0 @@
-- Migration 001: the schema of a database target's archive file. The
-- archivedEvent model in internal/archive must agree with it.
CREATE TABLE archived_events (
id integer PRIMARY KEY AUTOINCREMENT,
event_id text,
webhook_id text,
entrypoint_id text,
method text,
raw_query text,
headers text,
body text,
content_type text,
archived_at datetime
);
CREATE INDEX idx_archived_events_event_id ON archived_events (event_id);
CREATE INDEX idx_archived_events_archived_at ON archived_events (archived_at);
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,98 +0,0 @@
-- Migration 001: the schema of a webhook's event database. The models
-- in internal/database must agree with it. The webhook, entrypoint and
-- target ids here name rows in the main database, which has those
-- tables; this file does not.
CREATE TABLE events (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
entrypoint_id uuid NOT NULL,
method text NOT NULL,
raw_query text,
headers text,
body text,
content_type text,
body_bytes integer NOT NULL,
resubmitted_from_id uuid,
PRIMARY KEY (id)
);
CREATE INDEX idx_events_created_at ON events (created_at);
CREATE INDEX idx_events_deleted_at ON events (deleted_at);
CREATE INDEX idx_events_deleted_at_created_at
ON events (deleted_at, created_at);
CREATE INDEX idx_events_resubmitted_from_id
ON events (resubmitted_from_id, deleted_at);
CREATE INDEX idx_events_entrypoint_id
ON events (entrypoint_id, deleted_at, resubmitted_from_id, created_at);
CREATE TABLE deliveries (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
event_id uuid NOT NULL,
target_id uuid NOT NULL,
status text NOT NULL DEFAULT 'pending',
finished_at datetime,
replay numeric NOT NULL DEFAULT false,
PRIMARY KEY (id),
CONSTRAINT fk_events_deliveries
FOREIGN KEY (event_id) REFERENCES events (id)
);
CREATE INDEX idx_deliveries_deleted_at ON deliveries (deleted_at);
CREATE INDEX idx_deliveries_event_id ON deliveries (event_id, deleted_at);
CREATE INDEX idx_deliveries_status
ON deliveries (status, deleted_at, finished_at, target_id);
CREATE TABLE delivery_results (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
delivery_id uuid NOT NULL,
attempt_num integer NOT NULL,
success numeric,
status_code integer,
response_body text,
error text,
duration integer,
PRIMARY KEY (id),
CONSTRAINT fk_deliveries_delivery_results
FOREIGN KEY (delivery_id) REFERENCES deliveries (id)
);
CREATE INDEX idx_delivery_results_deleted_at
ON delivery_results (deleted_at);
CREATE INDEX idx_delivery_results_delivery_id
ON delivery_results (delivery_id, deleted_at);
CREATE TABLE event_totals (
id integer PRIMARY KEY AUTOINCREMENT,
events integer NOT NULL,
events_removed integer NOT NULL,
last_event_at datetime
);
-- The one row of event totals, all zero.
INSERT INTO event_totals (events, events_removed) VALUES (0, 0);
CREATE TABLE target_totals (
target_id uuid,
deliveries integer NOT NULL,
delivered integer NOT NULL,
failed integer NOT NULL,
deliveries_removed integer NOT NULL,
failed_removed integer NOT NULL,
PRIMARY KEY (target_id)
);
CREATE TABLE entrypoint_totals (
entrypoint_id uuid,
last_event_at datetime NOT NULL,
PRIMARY KEY (entrypoint_id)
);
@@ -1,9 +0,0 @@
-- Migration 000: the table recording which migrations this file has
-- had. Applied before the others; it records itself.
CREATE TABLE IF NOT EXISTS schema_migrations (
version INTEGER PRIMARY KEY,
applied_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
INSERT OR IGNORE INTO schema_migrations (version) VALUES (0);
@@ -1,90 +0,0 @@
-- Migration 001: the main database's schema. The models in
-- internal/database must agree with it.
CREATE TABLE settings (
"key" text,
value text NOT NULL,
PRIMARY KEY ("key")
);
CREATE TABLE users (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
username text NOT NULL,
password text NOT NULL,
PRIMARY KEY (id),
CONSTRAINT chk_users_username
CHECK (length(CAST(username AS BLOB)) <= 1024)
);
CREATE INDEX idx_users_deleted_at ON users (deleted_at);
CREATE UNIQUE INDEX idx_users_username ON users (username);
CREATE TABLE api_keys (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
user_id uuid NOT NULL,
"key" text NOT NULL,
description text,
last_used_at datetime,
PRIMARY KEY (id),
CONSTRAINT fk_users_api_keys FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX idx_api_keys_deleted_at ON api_keys (deleted_at);
CREATE UNIQUE INDEX idx_api_keys_key ON api_keys ("key");
CREATE TABLE webhooks (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
user_id uuid NOT NULL,
name text NOT NULL,
description text,
retention_days integer,
PRIMARY KEY (id),
CONSTRAINT fk_users_webhooks FOREIGN KEY (user_id) REFERENCES users (id)
);
CREATE INDEX idx_webhooks_deleted_at ON webhooks (deleted_at);
CREATE TABLE entrypoints (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
path text NOT NULL,
description text,
active numeric DEFAULT true,
PRIMARY KEY (id),
CONSTRAINT fk_webhooks_entrypoints
FOREIGN KEY (webhook_id) REFERENCES webhooks (id)
);
CREATE INDEX idx_entrypoints_deleted_at ON entrypoints (deleted_at);
CREATE UNIQUE INDEX idx_entrypoints_path ON entrypoints (path);
CREATE TABLE targets (
id uuid,
created_at datetime,
updated_at datetime,
deleted_at datetime,
webhook_id uuid NOT NULL,
name text NOT NULL,
type text NOT NULL,
active numeric DEFAULT true,
config text,
max_attempts integer NOT NULL,
PRIMARY KEY (id),
CONSTRAINT fk_webhooks_targets
FOREIGN KEY (webhook_id) REFERENCES webhooks (id),
CONSTRAINT chk_targets_max_attempts CHECK (max_attempts >= 1)
);
CREATE INDEX idx_targets_deleted_at ON targets (deleted_at);
-135
View File
@@ -1,135 +0,0 @@
// Package migrations holds the schema of each kind of SQLite file
// webhooker writes, as numbered SQL files embedded in the binary, and
// applies it when a file is opened.
//
// Each kind of file has its own directory. In each, 000_migration.sql
// creates only the schema_migrations table, and 001_schema.sql holds
// the whole schema. Before 1.0 a schema change is an edit to
// 001_schema.sql, never a new file.
package migrations
import (
"embed"
"fmt"
"io/fs"
"path"
"strconv"
"strings"
"gorm.io/gorm"
)
// The directories, one for each kind of database file.
const (
// Main is the main database, webhooker.db.
Main = "main"
// Events is a webhook's event database, events-{uuid}.db.
Events = "events"
// Archive is a database target's archive file.
Archive = "archive"
)
//go:embed main/*.sql events/*.sql archive/*.sql
var files embed.FS
// Apply runs each SQL file in dir, which is Main, Events or Archive,
// that the database has not had yet, in the order of the number its
// name starts with, and records that number in schema_migrations.
// 000_migration.sql creates that table and records itself, so it runs
// first, and only when the table is missing.
//
// It all runs in one transaction, so a failure leaves the file as it
// was.
func Apply(db *gorm.DB, dir string) error {
return db.Transaction(func(tx *gorm.DB) error {
var tables int
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
err := tx.Raw(
"SELECT count(*) FROM sqlite_master "+
"WHERE type = 'table' AND name = ?",
"schema_migrations",
).Row().Scan(&tables)
if err != nil {
return fmt.Errorf("looking for schema_migrations: %w", err)
}
if tables == 0 {
err = run(tx, path.Join(dir, "000_migration.sql"))
if err != nil {
return err
}
}
// fs.Glob lists the files in name order, which is number order.
names, err := fs.Glob(files, path.Join(dir, "*.sql"))
if err != nil {
return fmt.Errorf("listing migrations: %w", err)
}
for _, name := range names {
err = applyOnce(tx, name)
if err != nil {
return err
}
}
return nil
})
}
// applyOnce runs the migration file name and records its number,
// unless schema_migrations already has that number.
func applyOnce(tx *gorm.DB, name string) error {
number, _, _ := strings.Cut(path.Base(name), "_")
version, err := strconv.Atoi(number)
if err != nil {
return fmt.Errorf("numbering migration %s: %w", name, err)
}
var applied int
err = tx.Raw(
"SELECT count(*) FROM schema_migrations WHERE version = ?",
version,
).Row().Scan(&applied)
if err != nil {
return fmt.Errorf("checking migration %s: %w", name, err)
}
if applied > 0 {
return nil
}
err = run(tx, name)
if err != nil {
return err
}
err = tx.Exec(
"INSERT INTO schema_migrations (version) VALUES (?)", version,
).Error
if err != nil {
return fmt.Errorf("recording migration %s: %w", name, err)
}
return nil
}
// run executes every statement in the migration file name.
func run(tx *gorm.DB, name string) error {
script, err := files.ReadFile(name)
if err != nil {
return fmt.Errorf("reading migration %s: %w", name, err)
}
err = tx.Exec(string(script)).Error
if err != nil {
return fmt.Errorf("applying migration %s: %w", name, err)
}
return nil
}
-57
View File
@@ -1,57 +0,0 @@
package migrations_test
import (
"database/sql"
"log/slog"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/db/migrations"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// TestApplyRunsEachFileOnce applies each kind of database file's
// migrations to a new file twice. The first time records every file.
// The second finds them all recorded and runs none, which is the only
// way it can succeed: 001_schema.sql creates tables that then exist.
func TestApplyRunsEachFileOnce(t *testing.T) {
t.Parallel()
for _, dir := range []string{
migrations.Main, migrations.Events, migrations.Archive,
} {
t.Run(dir, func(t *testing.T) {
t.Parallel()
sqlDB, err := sql.Open(
"sqlite", filepath.Join(t.TempDir(), "test.db"),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{
Logger: gormlog.New(slog.New(slog.DiscardHandler)),
},
)
require.NoError(t, err)
require.NoError(t, migrations.Apply(db, dir))
require.NoError(t, migrations.Apply(db, dir))
var versions string
require.NoError(t, sqlDB.QueryRowContext(t.Context(),
"SELECT group_concat(version) FROM schema_migrations",
).Scan(&versions))
assert.Equal(t, "0,1", versions)
})
}
}
+2 -24
View File
@@ -76,20 +76,12 @@ func (cb *CircuitBreaker) Allow() bool {
}
}
// CooldownRemaining returns how long a delivery that Allow refused
// should wait before it is tried again. Closed, it returns zero.
// Open, it returns what is left of the cooldown, or zero once that
// has passed. Half-open, it returns the whole cooldown: the one
// probe delivery is still in flight, and if it fails the circuit
// reopens for that long.
// CooldownRemaining returns how much time is left before
// an open circuit transitions to half-open.
func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state == CircuitHalfOpen {
return cb.cooldown
}
if cb.state != CircuitOpen {
return 0
}
@@ -102,20 +94,6 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
return remaining
}
// StateAndCooldown returns the circuit state and, while the circuit is
// open, what is left of the cooldown, or zero once that has passed.
// Both are read under one lock, so they always agree.
func (cb *CircuitBreaker) StateAndCooldown() (CircuitState, time.Duration) {
cb.mu.Lock()
defer cb.mu.Unlock()
if cb.state != CircuitOpen {
return cb.state, 0
}
return cb.state, max(cb.cooldown-time.Since(cb.lastFailure), 0)
}
// RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() {
+3 -5
View File
@@ -267,7 +267,7 @@ func TestCircuitBreaker_CooldownRemaining_ClosedReturnsZero(
)
}
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsCooldown(
func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsZero(
t *testing.T,
) {
t.Parallel()
@@ -282,11 +282,9 @@ func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsCooldown(
require.True(t, cb.Allow())
// The cooldown newShortCooldownCB gives the breaker.
assert.Equal(t, 50*time.Millisecond,
assert.Equal(t, time.Duration(0),
cb.CooldownRemaining(),
"a delivery refused while half-open should wait "+
"a whole cooldown",
"half-open circuit should have zero cooldown remaining",
)
}
+13 -51
View File
@@ -18,27 +18,26 @@ func newSSRFTestEngine() *delivery.Engine {
log := slog.New(slog.DiscardHandler)
client := &http.Client{
Timeout: 30 * time.Second,
Transport: delivery.NewTestGuard().
NewSSRFSafeTransport(),
Timeout: 30 * time.Second,
Transport: delivery.NewSSRFSafeTransport(),
}
return delivery.NewTestEngine(log, client, 1)
}
// TestClientForRequest_TimeoutKeepsSSRFGuard asserts that a
// client returned by clientForRequest for a config with a
// TestClientForConfig_TimeoutKeepsSSRFGuard asserts that a
// client returned by clientForConfig for a config with a
// per-target timeout still refuses connections to
// private/reserved addresses (the timeout must not drop the
// SSRF-safe transport).
func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
func TestClientForConfig_TimeoutKeepsSSRFGuard(t *testing.T) {
t.Parallel()
engine := newSSRFTestEngine()
blocked := []string{
loopbackHookURL,
metadataURL,
"http://127.0.0.1/hook",
"http://169.254.169.254/latest/meta-data/",
"http://[fe80::1]/hook",
}
@@ -51,7 +50,7 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
Timeout: 5,
}
client := engine.ExportClientForRequest(cfg, nil)
client := engine.ExportClientForConfig(cfg)
require.NotSame(t, engine.ExportClient(), client,
"a per-target timeout must yield a "+
@@ -92,11 +91,10 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
}
}
// TestClientForRequest_NoTimeoutUnchanged asserts that a
// request with neither a per-target timeout nor an origin-scoped
// header gets the shared SSRF-safe client unchanged: there is then
// nothing for a redirect policy to strip.
func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
// TestClientForConfig_NoTimeoutUnchanged asserts that with
// no per-target timeout the shared SSRF-safe client is
// returned unchanged.
func TestClientForConfig_NoTimeoutUnchanged(t *testing.T) {
t.Parallel()
engine := newSSRFTestEngine()
@@ -105,46 +103,10 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
URL: "https://example.com/hook",
}
client := engine.ExportClientForRequest(cfg, nil)
client := engine.ExportClientForConfig(cfg)
assert.Same(t, engine.ExportClient(), client,
"without a per-target timeout the shared client "+
"must be returned unchanged",
)
}
// TestClientForRequest_HeadersKeepSSRFGuard asserts that the
// redirect policy an origin-scoped header installs is added to a
// client that still carries the SSRF-safe transport. The guard is
// a dial hook, so keeping it is what makes each redirect hop pass
// the private-IP check too.
func TestClientForRequest_HeadersKeepSSRFGuard(t *testing.T) {
t.Parallel()
engine := newSSRFTestEngine()
cfg := &delivery.HTTPTargetConfig{
URL: "https://example.com/with-headers",
Headers: map[string]string{
"X-Api-Key": "configured",
},
}
client := engine.ExportClientForRequest(
cfg, []string{"X-Api-Key"},
)
require.NotNil(t, client.CheckRedirect,
"an origin-scoped header must install a redirect policy",
)
assert.Same(t,
engine.ExportClient().Transport, client.Transport,
"the SSRF-safe transport must be reused, not dropped",
)
assert.Equal(t,
engine.ExportClient().Timeout, client.Timeout,
"the shared client's timeout must be inherited",
)
}
-188
View File
@@ -1,188 +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 stored in
// the same form as created_at, 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))
var createdAt, finishedAt string
require.NoError(t, db.Raw(
`SELECT CAST(created_at AS TEXT),
CAST(finished_at AS TEXT)
FROM deliveries WHERE id = ?`, d.ID,
).Row().Scan(&createdAt, &finishedAt))
assertStoredTime(t, createdAt)
assertStoredTime(t, finishedAt)
} 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))
})
}
}
+1022 -1263
View File
File diff suppressed because it is too large Load Diff
+117 -536
View File
@@ -1,8 +1,8 @@
package delivery_test
import (
"bytes"
"context"
"database/sql"
"encoding/json"
"fmt"
"io"
@@ -10,6 +10,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
@@ -18,10 +19,10 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/database/databasetest"
"sneak.berlin/go/webhooker/internal/delivery"
)
@@ -43,12 +44,12 @@ func newISetup(t *testing.T) iSetup {
wDB := iSeedWebhookDB(t, dbMgr, wID)
return iSetup{
MainDB: mainDB.DB(),
MainDB: mainDB,
DBMgr: dbMgr,
WebhookID: wID,
WebhookDB: wDB,
Engine: delivery.NewTestEngineWithDB(
mainDB,
database.NewTestDatabase(mainDB),
dbMgr,
slog.New(slog.NewTextHandler(
os.Stderr,
@@ -62,16 +63,33 @@ func newISetup(t *testing.T) iSetup {
}
}
// iMainDB opens a main database through database.Open, the way the
// service opens it, so these tests cannot pass against journal and
// locking settings production does not use.
func iMainDB(t *testing.T) *database.Database {
func iMainDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := database.Open(t.TempDir(), slog.New(slog.DiscardHandler))
dbPath := filepath.Join(
t.TempDir(), "main-test.db",
)
dsn := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc", dbPath,
)
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = db.Close() })
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Webhook{},
&database.Target{},
&database.User{},
&database.Setting{},
))
return db
}
@@ -81,7 +99,7 @@ func iDBManager(
) *database.WebhookDBManager {
t.Helper()
return databasetest.NewWebhookDBManager(t, t.TempDir())
return database.NewTestWebhookDBManager(t.TempDir())
}
func iSeedWebhookDB(
@@ -108,6 +126,36 @@ func iHTTPConfig(url string) string {
return string(data)
}
func iWebhookDB(t *testing.T) *gorm.DB {
t.Helper()
dbPath := filepath.Join(
t.TempDir(), "events-test.db",
)
dsn := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc", dbPath,
)
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Event{},
&database.Delivery{},
&database.DeliveryResult{},
))
return db
}
func iEngine(
t *testing.T, workers int,
) *delivery.Engine {
@@ -134,10 +182,10 @@ func iSeedEvent(
event := database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Headers: `{}`,
Body: body,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db.Create(&event).Error)
@@ -170,7 +218,7 @@ func iTask(
d database.Delivery,
event database.Event,
webhookID, targetID, name, config string,
maxAttempts, attemptNum int,
maxRetries, attemptNum int,
body *string,
) delivery.Task {
return delivery.Task{
@@ -181,7 +229,7 @@ func iTask(
TargetName: name,
TargetType: database.TargetTypeHTTP,
TargetConfig: config,
MaxAttempts: maxAttempts,
MaxRetries: maxRetries,
Method: event.Method,
Headers: event.Headers,
ContentType: event.ContentType,
@@ -242,7 +290,7 @@ func TestProcessNewTask_InlineBody(t *testing.T) {
cfg := iHTTPConfig(ts.URL)
task := iTask(
d, event, s.WebhookID, targetID,
"test-target", cfg, 1, 1, &bodyStr,
"test-target", cfg, 0, 1, &bodyStr,
)
s.Engine.ExportProcessNewTask(
@@ -293,7 +341,7 @@ func TestProcessNewTask_LargeBody_FetchFromDB(
cfg := iHTTPConfig(ts.URL)
task := iTask(
d, event, s.WebhookID, targetID,
"test-large", cfg, 1, 1, nil,
"test-large", cfg, 0, 1, nil,
)
s.Engine.ExportProcessNewTask(
@@ -320,7 +368,7 @@ func TestProcessNewTask_InvalidWebhookID(t *testing.T) {
TargetName: "test",
TargetType: database.TargetTypeHTTP,
TargetConfig: iHTTPConfig("http://localhost:9999"),
MaxAttempts: 1,
MaxRetries: 0,
Body: nil,
AttemptNum: 1,
}
@@ -337,14 +385,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
),
@@ -364,17 +407,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
// The target row exists because the engine confirms a scheduled
// retry's target has not been deleted before it runs it. A retry
// task whose target id names no row at all is a state the service
// does not produce: the handler read that target to build the
// task. See https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-target",
database.TargetTypeHTTP, cfg, 5,
)
task := iTask(
d, event, s.WebhookID, targetID,
"retry-target", cfg, 5, 2, &bodyStr,
@@ -384,8 +416,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task,
)
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -432,14 +462,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t)
var receivedBody string
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
),
@@ -461,12 +486,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
)
cfg := iHTTPConfig(ts.URL)
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-large",
database.TargetTypeHTTP, cfg, 5,
)
task := iTask(
d, event, s.WebhookID, targetID,
"retry-large", cfg, 5, 2, nil,
@@ -476,8 +495,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task,
)
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
@@ -489,7 +506,7 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
t.Parallel()
s := newISetup(t)
s.Engine.ExportStart()
s.Engine.ExportStart(context.Background())
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID,
@@ -506,23 +523,27 @@ func TestWorkerLifecycle_StartStop(t *testing.T) {
task := iTask(
d, event, s.WebhookID, targetID,
"lifecycle-test", "",
1, 1, &bodyStr,
0, 1, &bodyStr,
)
task.TargetType = database.TargetTypeLog
s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, d.ID)
iWaitForStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
require.NoError(t, s.Engine.ExportStop(context.Background()))
s.Engine.ExportStop()
}
// iWaitForDelivered polls until the delivery reaches the
// delivered status.
func iWaitForDelivered(
// iWaitForStatus polls until the delivery reaches the
// expected status.
func iWaitForStatus(
t *testing.T,
db *gorm.DB,
deliveryID string,
expected database.DeliveryStatus,
) {
t.Helper()
@@ -536,7 +557,7 @@ func iWaitForDelivered(
return false
}
return d.Status == database.DeliveryStatusDelivered
return d.Status == expected
}, 5*time.Second, 50*time.Millisecond)
}
@@ -567,16 +588,10 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
database.DeliveryStatusRetrying,
)
s.Engine.ExportStart()
s.Engine.ExportStart(context.Background())
bodyStr := event.Body
cfg := iHTTPConfig(ts.URL)
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
database.TargetTypeHTTP, cfg, 5,
)
task := iTask(
d, event, s.WebhookID, targetID,
"retry-chan-test", cfg, 5, 2, &bodyStr,
@@ -584,9 +599,12 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
s.Engine.ExportRetryCh() <- task
iWaitForDelivered(t, s.WebhookDB, d.ID)
iWaitForStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
require.NoError(t, s.Engine.ExportStop(context.Background()))
s.Engine.ExportStop()
}
// --- processDelivery: unknown target type ---
@@ -645,21 +663,16 @@ func TestRecoverPendingDeliveries(t *testing.T) {
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recovery-target",
database.TargetTypeLog, "", 1,
database.TargetTypeLog, "", 0,
)
iSeedPendingDeliveries(
t, s.WebhookDB, s.WebhookID, targetID, 3,
)
// A recovered delivery still carries its event's query string.
require.NoError(t, s.WebhookDB.Model(&database.Event{}).
Where("webhook_id = ?", s.WebhookID).
Update("raw_query", eventQuery).Error)
s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB,
s.WebhookID, atStart,
s.WebhookID,
)
for i := range 3 {
@@ -671,8 +684,6 @@ func TestRecoverPendingDeliveries(t *testing.T) {
database.TargetTypeLog,
task.TargetType,
)
assert.Equal(t, eventQuery, task.RawQuery)
case <-time.After(2 * time.Second):
t.Fatalf("expected task %d", i)
}
@@ -686,17 +697,17 @@ func iCreateTarget(
targetID, webhookID, name string,
targetType database.TargetType,
config string,
maxAttempts int,
maxRetries int,
) {
t.Helper()
target := database.Target{
WebhookID: webhookID,
Name: name,
Type: targetType,
Active: true,
Config: config,
MaxAttempts: maxAttempts,
WebhookID: webhookID,
Name: name,
Type: targetType,
Active: true,
Config: config,
MaxRetries: maxRetries,
}
target.ID = targetID
@@ -756,7 +767,7 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart,
context.Background(), s.WebhookID,
)
select {
@@ -767,252 +778,6 @@ func TestRecoverWebhookDeliveries_RetryingDeliveries(
case <-time.After(5 * time.Second):
t.Fatal("expected retry task from recovery")
}
// Regression guard: a target that still supports retries
// must be rescheduled, never terminally failed, and must
// not gain a synthetic result row.
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusRetrying,
)
assert.Len(t, iResults(t, s.WebhookDB, d.ID), 1)
}
// --- Retrying deliveries whose target type changed ---
// iSeedRetryingWithType seeds a retrying delivery with one
// recorded failed attempt against a target of the given type,
// standing in for a target whose type was edited in the main
// database while the delivery was still retrying.
func iSeedRetryingWithType(
t *testing.T,
s iSetup,
targetType database.TargetType,
) string {
t.Helper()
targetID := uuid.New().String()
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "mutated-target", targetType,
iHTTPConfig("http://example.com/hook"), 5,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID,
`{"orphaned":"retry"}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
iSeedFailedResult(t, s.WebhookDB, d.ID)
return d.ID
}
// iResults loads a delivery's results in attempt order.
func iResults(
t *testing.T, db *gorm.DB, deliveryID string,
) []database.DeliveryResult {
t.Helper()
var results []database.DeliveryResult
require.NoError(t, db.
Where("delivery_id = ?", deliveryID).
Order("attempt_num").
Find(&results).Error)
return results
}
// iAssertTerminallyFailed asserts the delivery ended failed
// with a result row recording why, and was not rescheduled.
func iAssertTerminallyFailed(
t *testing.T,
s iSetup,
deliveryID string,
targetType database.TargetType,
) {
t.Helper()
iAssertStatus(
t, s.WebhookDB, deliveryID,
database.DeliveryStatusFailed,
)
results := iResults(t, s.WebhookDB, deliveryID)
require.Len(t, results, 2)
last := results[1]
assert.False(t, last.Success)
assert.Equal(t, 2, last.AttemptNum)
assert.Contains(
t, last.Error, string(targetType),
)
assert.Contains(
t, last.Error, "does not support retries",
)
assert.Empty(t, s.Engine.ExportRetryCh())
}
func TestRecoverSingleRetry_TypeNoLongerRetries(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "mutated-type",
)
deliveryID := iSeedRetryingWithType(
t, s, database.TargetTypeLog,
)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart,
)
iAssertTerminallyFailed(
t, s, deliveryID, database.TargetTypeLog,
)
}
func TestRecoverSingleRetry_OnTick_TypeNoLongerRetries(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "mutated-type-sweep",
)
deliveryID := iSeedRetryingWithType(
t, s, database.TargetTypeDatabase,
)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, onTick,
)
iAssertTerminallyFailed(
t, s, deliveryID, database.TargetTypeDatabase,
)
}
// TestFailUnretryableRetry_WritesNoTargetRow proves the
// orphaned-retry terminal path leaves no target row — and so no
// plaintext target config — in the per-webhook event database.
//
// That path loads the delivery without its Target relation on
// purpose. Populating d.Target makes GORM's SaveBeforeAssociations
// upsert the whole target row on the status UPDATE, which for a slack
// target writes the incoming-webhook credential into events-*.db.
// See https://git.eeqj.de/sneak/webhooker/issues/206.
func TestFailUnretryableRetry_WritesNoTargetRow(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "no-target-row",
)
targetID := uuid.New().String()
// A Slack incoming-webhook URL: the target config IS the
// credential, which is what makes a leaked target row a
// disclosure rather than a curiosity.
hookURL := "https://hooks.slack.com/services/T00/B00/x"
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "credential-bearing",
database.TargetTypeLog, iHTTPConfig(hookURL), 5,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"orphaned":"retry"}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
iSeedFailedResult(t, s.WebhookDB, d.ID)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, onTick,
)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed,
)
// The per-webhook database has no targets table, so writing a
// target row there would have failed the status write above.
assert.False(t, s.WebhookDB.Migrator().HasTable("targets"),
"orphaned-retry terminal failure wrote a target row "+
"into the per-webhook event database",
)
}
func TestRecoverSingleRetry_UnknownTargetType(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "unknown-type",
)
unknown := database.TargetType("not-a-target-type")
deliveryID := iSeedRetryingWithType(t, s, unknown)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart,
)
iAssertTerminallyFailed(t, s, deliveryID, unknown)
}
func TestRecoverSingleRetry_OnTick_UnknownTargetType(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "unknown-type-sweep",
)
unknown := database.TargetType("not-a-target-type")
deliveryID := iSeedRetryingWithType(t, s, unknown)
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, onTick,
)
iAssertTerminallyFailed(t, s, deliveryID, unknown)
}
// iSeedFailedResult creates a failed delivery result.
@@ -1051,19 +816,19 @@ func iCreateWebhook(
require.NoError(t, mainDB.Create(&webhook).Error)
}
// --- recoverDeliveries Tests ---
// --- recoverInFlight Tests ---
func TestRecoverDeliveries_NoWebhooks(t *testing.T) {
func TestRecoverInFlight_NoWebhooks(t *testing.T) {
t.Parallel()
s := newISetup(t)
s.Engine.ExportRecoverDeliveries(
context.Background(), atStart,
s.Engine.ExportRecoverInFlight(
context.Background(),
)
}
func TestRecoverDeliveries_WithPendingDeliveries(
func TestRecoverInFlight_WithPendingDeliveries(
t *testing.T,
) {
t.Parallel()
@@ -1077,7 +842,7 @@ func TestRecoverDeliveries_WithPendingDeliveries(
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "recover-target",
database.TargetTypeLog, "", 1,
database.TargetTypeLog, "", 0,
)
event := iSeedEvent(
@@ -1090,8 +855,8 @@ func TestRecoverDeliveries_WithPendingDeliveries(
database.DeliveryStatusPending,
)
s.Engine.ExportRecoverDeliveries(
context.Background(), atStart,
s.Engine.ExportRecoverInFlight(
context.Background(),
)
select {
@@ -1102,90 +867,10 @@ func TestRecoverDeliveries_WithPendingDeliveries(
database.TargetTypeLog, task.TargetType,
)
case <-time.After(2 * time.Second):
t.Fatal("expected task from recovery at start")
t.Fatal("expected task from recoverInFlight")
}
}
// TestRecoverDeliveries_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Recovery at
// start 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 TestRecoverDeliveries_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB.DB(), webhookID, "lost-database")
var logs bytes.Buffer
dbMgr := databasetest.NewWebhookDBManagerWithLogger(
t, t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
mainDB, dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverDeliveries(context.Background(), atStart)
assert.Contains(
t, logs.String(),
`level=WARN msg="created a new, empty database" webhook_id=`+
webhookID+" path="+dbMgr.DBPath(webhookID),
)
}
// TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while the recovery routine
// runs. Its database file is gone, and recovery must not create it
// again.
func TestRecoverDeliveries_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T,
) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB.DB(), 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.DB().Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) {
if deleted {
return
}
deleted = true
require.NoError(t, mainDB.DB().Delete(
&database.Webhook{}, "id = ?", webhookID,
).Error)
}))
dbMgr := databasetest.NewWebhookDBManager(t, t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
mainDB, dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverDeliveries(context.Background(), atStart)
require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID))
}
// --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
@@ -1233,7 +918,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
task := iTask(
d, event, s.WebhookID, targetID,
"auth-target", string(cfgJSON),
1, 1, &bodyStr,
0, 1, &bodyStr,
)
s.Engine.ExportProcessNewTask(
@@ -1250,7 +935,7 @@ func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
func TestDeliverHTTP_TargetTimeout(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
db := iWebhookDB(t)
e := iEngine(t, 1)
ts := httptest.NewServer(
@@ -1278,7 +963,7 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
task, d := iHTTPTaskAndDelivery(
event, del, "timeout-target",
string(cfgJSON), 1, 1,
string(cfgJSON), 0, 1,
)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1290,83 +975,6 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
iAssertResultFailed(t, db, del.ID)
}
// TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog pins the size
// this engine stores for an oversized response, because the
// event log's redaction is written against it: the row holds
// exactly maxBodyLog bytes and records nothing about how much
// more the remote sent, so a credential echoed across that
// boundary reaches the database already severed and no reader
// of the row can tell the cut happened.
func TestDeliverHTTP_CutsStoredResponseAtMaxBodyLog(
t *testing.T,
) {
t.Parallel()
// Padded so the cut falls five bytes before the end of the
// echoed webhook URL.
const (
severedTail = 5
overshoot = 100000
)
sent := strings.Repeat(
"A",
delivery.ExportMaxBodyLog-len(slackWebhookURL)+
severedTail,
) + slackWebhookURL + strings.Repeat("Z", overshoot)
s := newISetup(t)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusBadGateway)
_, _ = io.WriteString(w, sent)
},
))
defer ts.Close()
cfgJSON := iHTTPConfig(ts.URL)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"cut":"test"}`,
)
targetID := uuid.New().String()
del := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
bodyStr := event.Body
task := iTask(
del, event, s.WebhookID, targetID,
"cut-target", cfgJSON, 1, 1, &bodyStr,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
results := iResults(t, s.WebhookDB, del.ID)
require.Len(t, results, 1)
stored := results[0].ResponseBody
assert.Len(
t, stored, delivery.ExportMaxBodyLog,
"an oversized response is stored at exactly the cap",
)
assert.Equal(
t, sent[:delivery.ExportMaxBodyLog], stored,
)
assert.NotContains(
t, stored, slackWebhookURL,
"the echoed URL is severed by the cut",
)
assert.Contains(
t, stored, "T00000000",
"the severed prefix still carries the credential",
)
}
// iSeedEventAndDelivery creates event + delivery
// for standalone tests.
func iSeedEventAndDelivery(
@@ -1379,10 +987,10 @@ func iSeedEventAndDelivery(
event := database.Event{
WebhookID: uuid.New().String(),
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Method: "POST",
Headers: `{"Content-Type":["application/json"]}`,
Body: body,
ContentType: testContentType,
ContentType: "application/json",
}
require.NoError(t, db.Create(&event).Error)
@@ -1404,7 +1012,7 @@ func iHTTPTaskAndDelivery(
event database.Event,
del database.Delivery,
name, config string,
maxAttempts, attemptNum int,
maxRetries, attemptNum int,
) (*delivery.Task, *database.Delivery) {
task := &delivery.Task{
DeliveryID: del.ID,
@@ -1414,7 +1022,7 @@ func iHTTPTaskAndDelivery(
TargetName: name,
TargetType: database.TargetTypeHTTP,
TargetConfig: config,
MaxAttempts: maxAttempts,
MaxRetries: maxRetries,
AttemptNum: attemptNum,
}
@@ -1424,10 +1032,9 @@ func iHTTPTaskAndDelivery(
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: name,
Type: database.TargetTypeHTTP,
Config: config,
MaxAttempts: maxAttempts,
Name: name,
Type: database.TargetTypeHTTP,
Config: config,
},
}
d.ID = del.ID
@@ -1460,7 +1067,7 @@ func iAssertResultFailed(
func TestDeliverHTTP_InvalidConfig(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
db := iWebhookDB(t)
e := iEngine(t, 1)
event, del := iSeedEventAndDelivery(
@@ -1468,7 +1075,7 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1,
event, del, "bad-config", `not-json`, 0, 1,
)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
@@ -1478,32 +1085,6 @@ func TestDeliverHTTP_InvalidConfig(t *testing.T) {
)
}
// TestDeliverHTTP_InvalidConfigUnrecordedStaysPending: a delivery is
// failed for an invalid config only once the reason is recorded.
// Unrecorded, it stays pending, where the sweep finds it again.
func TestDeliverHTTP_InvalidConfigUnrecordedStaysPending(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event, del := iSeedEventAndDelivery(
t, db, `{"config":"invalid"}`, "",
)
task, d := iHTTPTaskAndDelivery(
event, del, "bad-config", `not-json`, 1, 1,
)
require.NoError(t, db.Exec("drop table delivery_results").Error)
e.ExportDeliverHTTP(context.TODO(), db, d, task)
iAssertStatus(t, db, del.ID,
database.DeliveryStatusPending,
)
}
// --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) {
-528
View File
@@ -1,528 +0,0 @@
package delivery_test
import (
"context"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
const (
// hookStopTimeout bounds how long a lifecycle test waits for
// the engine's OnStop hook to return before declaring the
// shutdown hung.
hookStopTimeout = 10 * time.Second
// hookSettleDelay is how long startEngineViaHook waits after
// OnStart before the caller may enqueue work. A worker pool
// wrongly rooted in the already-done hook context has nothing
// but ctx.Done() ready in its select, so it is deterministically
// gone by the end of this window. Without the wait, Notify would
// race the pool's very first select, in which a ready ctx.Done()
// and a ready deliveryCh are chosen between at random and a
// doomed pool still delivers.
hookSettleDelay = 250 * time.Millisecond
// wedgeStopTimeout is the stop timeout a wedged-shutdown test
// hands OnStop, standing in for fx's StopTimeout. The test
// asserts only that the hook returns at all, and allows it
// hookStopTimeout — forty times this budget — to do so, so no
// assertion here races the wall clock.
wedgeStopTimeout = 250 * time.Millisecond
)
// recordingLifecycle is a minimal fx.Lifecycle that records the
// hooks a component registers, so a test can invoke the real
// OnStart/OnStop functions with a context of its choosing.
type recordingLifecycle struct {
hooks []fx.Hook
}
func (l *recordingLifecycle) Append(h fx.Hook) {
l.hooks = append(l.hooks, h)
}
// requireStopHookExpires drives hook.OnStop with a stop context
// that expires while a wedged goroutine is still running, and
// requires the hook to return the deadline error naming
// component instead of blocking on the WaitGroup forever.
func requireStopHookExpires(
t *testing.T, hook fx.Hook, component string,
) {
t.Helper()
stopCtx, cancel := context.WithTimeout(
context.Background(), wedgeStopTimeout,
)
defer cancel()
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
stopErr = hook.OnStop(stopCtx)
}()
select {
case <-stopped:
case <-time.After(hookStopTimeout):
t.Fatal(
"OnStop did not return: it discarded the stop " +
"context and is waiting on a wedged goroutine " +
"that will never observe cancellation",
)
}
require.ErrorIs(t, stopErr, context.DeadlineExceeded)
require.ErrorContains(t, stopErr, component)
}
// startEngineViaHook drives the genuine fx hooks the application
// registers for the engine, handing OnStart a context that is
// already done, and returns only once a pool that inherited that
// context would have exited. It returns the recorded lifecycle so
// the caller can drive OnStop too.
//
// Callers must not seed pending or retrying deliveries before
// calling this: the recovery routine's run at start enqueues those,
// which would put work in the queue while the pool is still
// racing its first select.
func startEngineViaHook(
t *testing.T, eng *delivery.Engine,
) *recordingLifecycle {
t.Helper()
lc := &recordingLifecycle{}
eng.ExportRegisterHooks(lc)
require.Len(t, lc.hooks, 1)
// fx hands OnStart a context carrying the application start
// timeout, and cancels it when the start phase ends. An
// already-cancelled context is that same defect taken to its
// limit, and unlike a plain context.Background() it actually
// distinguishes a correctly rooted loop from a broken one.
hookCtx, cancel := context.WithCancel(context.Background())
cancel()
require.NoError(t, lc.hooks[0].OnStart(hookCtx))
time.Sleep(hookSettleDelay)
return lc
}
// seedLogTask seeds a pending delivery for a log target and
// returns its ID together with the task that drives it. The log
// target needs no network, so a delivery completing proves only
// that a worker picked the task up.
func seedLogTask(
t *testing.T, s iSetup,
) (string, delivery.Task) {
t.Helper()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID,
`{"lifecycle":"hook-context"}`,
)
targetID := uuid.New().String()
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
bodyStr := event.Body
task := iTask(
d, event, s.WebhookID, targetID,
"hook-context-test", "", 1, 1, &bodyStr,
)
task.TargetType = database.TargetTypeLog
return d.ID, task
}
// TestEngine_WorkersOutliveStartHookContext is the regression
// test for a delivery engine that stopped delivering roughly
// fifteen seconds after boot. fx calls OnStart with a context
// carrying the application's start timeout (15s by default) and
// cancels it when the start phase ends, so a worker pool rooted
// in it exits shortly after startup: the process keeps accepting
// and persisting events while nothing at all forwards them.
//
// Driving OnStart with an already-cancelled context is that
// defect taken to its limit. A pool that inherits the hook
// context is gone before the task is even enqueued; a correctly
// rooted pool keeps working for as long as the process lives.
func TestEngine_WorkersOutliveStartHookContext(t *testing.T) {
t.Parallel()
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
t.Cleanup(func() {
_ = lc.hooks[0].OnStop(context.Background())
})
// Seeded only after the pool has settled, so the recovery
// routine's run at start cannot enqueue it.
deliveryID, task := seedLogTask(t, s)
s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID)
}
// TestEngine_StopHookStopsWorkers proves the fix did not trade a
// startup bug for a shutdown hang: now that the worker pool no
// longer observes the start hook's cancellation, OnStop is the
// only thing that can stop it, and it must both return promptly
// and actually leave the pool drained.
func TestEngine_StopHookStopsWorkers(t *testing.T) {
t.Parallel()
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
// Let the pool prove it is running before stopping it, so a
// fast OnStop cannot pass by stopping something already dead.
firstID, firstTask := seedLogTask(t, s)
s.Engine.Notify([]delivery.Task{firstTask})
iWaitForDelivered(t, s.WebhookDB, firstID)
var stopErr error
stopped := make(chan struct{})
go func() {
defer close(stopped)
// stop blocks on the workers' WaitGroup, so returning at
// all proves every goroutine observed the cancellation.
stopErr = lc.hooks[0].OnStop(context.Background())
}()
select {
case <-stopped:
case <-time.After(hookStopTimeout):
t.Fatal(
"OnStop did not return: the delivery engine's " +
"WaitGroup is still waiting on a goroutine that " +
"never observed cancellation",
)
}
require.NoError(t, stopErr)
// With every worker gone, a freshly notified task must sit
// untouched in the queue rather than being delivered.
secondID, secondTask := seedLogTask(t, s)
s.Engine.Notify([]delivery.Task{secondTask})
time.Sleep(200 * time.Millisecond)
var after database.Delivery
require.NoError(
t,
s.WebhookDB.First(&after, "id = ?", secondID).Error,
)
require.Equal(
t,
database.DeliveryStatusPending,
after.Status,
"a stopped engine must not deliver anything",
)
}
// TestEngine_StopHookHonoursStopTimeout is the regression test
// for a shutdown that could never complete. fx hands OnStop a
// context carrying the application's stop timeout; an OnStop
// that discards it and calls wg.Wait() bare hangs the process
// forever on a single worker stuck inside a delivery target that
// never returns — precisely when a bounded shutdown matters
// most.
//
// The wedged goroutine here never observes cancellation, so the
// hook can only return by honouring its context, and it must say
// so rather than reporting a clean stop.
func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
t.Parallel()
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
release := make(chan struct{})
t.Cleanup(func() { close(release) })
s.Engine.ExportWedgeWorker(release)
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
}
// seedHeldDelivery gives the setup's webhook an http target with
// maxAttempts, pointed at a receiver that keeps every request open until
// the client gives up on it, and seeds one delivery to that target at
// status. It returns the task that sends attempt attemptNum of the
// delivery, and a channel that is closed when a request reaches the
// receiver.
func seedHeldDelivery(
t *testing.T,
s iSetup,
maxAttempts int,
status database.DeliveryStatus,
attemptNum int,
) (delivery.Task, <-chan struct{}) {
t.Helper()
arrived := make(chan struct{})
var once sync.Once
ts := httptest.NewServer(http.HandlerFunc(
func(_ http.ResponseWriter, r *http.Request) {
// The server notices the client hanging up only once the
// body has been read; until then r.Context() never ends.
_, _ = io.Copy(io.Discard, r.Body)
once.Do(func() { close(arrived) })
<-r.Context().Done()
},
))
t.Cleanup(ts.Close)
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "held",
database.TargetTypeHTTP, cfg, maxAttempts,
)
event := iSeedEvent(t, s.WebhookDB, s.WebhookID, `{"held":true}`)
d := iSeedDelivery(t, s.WebhookDB, event.ID, targetID, status)
bodyStr := event.Body
return iTask(
d, event, s.WebhookID, targetID, "held", cfg,
maxAttempts, attemptNum, &bodyStr,
), arrived
}
// stopDuringAttempt waits until the receiver holds an attempt open, then
// stops the engine through its real stop hook.
func stopDuringAttempt(
t *testing.T, lc *recordingLifecycle, arrived <-chan struct{},
) {
t.Helper()
select {
case <-arrived:
case <-time.After(5 * time.Second):
t.Fatal("the delivery attempt never reached the receiver")
}
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
}
// requireRequeued runs the recovery routine as at start on the stopped
// engine and requires it to queue task's attempt again on ch.
func requireRequeued(
t *testing.T, s iSetup, ch <-chan delivery.Task, task delivery.Task,
) {
t.Helper()
s.Engine.ExportRecoverWebhookDeliveries(
context.Background(), s.WebhookID, atStart,
)
select {
case got := <-ch:
assert.Equal(t, task.DeliveryID, got.DeliveryID)
assert.Equal(t, task.AttemptNum, got.AttemptNum)
case <-time.After(5 * time.Second):
t.Fatal("recovery at start did not queue the delivery again")
}
}
// TestEngine_StopLeavesFireAndForgetAttemptPending is the regression
// test for a stop that turned an attempt in flight into a terminal
// failure. Stopping the engine cancels the request; for a target with
// no retries that was the delivery's only attempt, recorded as failed
// with "context canceled", and nothing ever sent it again. The
// delivery must instead stay pending, with no result and no count in
// the attempt metrics, for the recovery routine to send at the next
// start.
func TestEngine_StopLeavesFireAndForgetAttemptPending(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, 1, database.DeliveryStatusPending, 1,
)
s.Engine.Notify([]delivery.Task{task})
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusPending,
)
assert.Empty(t, iResults(t, s.WebhookDB, task.DeliveryID))
assert.Zero(t, mCounter(t, reg, mAttempts, mTypeHTTP))
assert.Zero(t, mHTTPDurations(t, reg))
requireRequeued(t, s, s.Engine.ExportDeliveryCh(), task)
}
// TestEngine_StopLeavesLastRetryAttemptRetrying is the same regression
// for a target with retries, on its last attempt: the cancelled attempt
// used to exhaust the retries and end the delivery failed. It must stay
// retrying with only its earlier attempt recorded, and the recovery
// routine at the next start must send that last attempt again.
func TestEngine_StopLeavesLastRetryAttemptRetrying(t *testing.T) {
t.Parallel()
const maxAttempts = 2
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
task, arrived := seedHeldDelivery(
t, s, maxAttempts, database.DeliveryStatusRetrying, maxAttempts,
)
iSeedFailedResult(t, s.WebhookDB, task.DeliveryID)
s.Engine.ExportScheduleRetry(task, 0)
stopDuringAttempt(t, lc, arrived)
iAssertStatus(
t, s.WebhookDB, task.DeliveryID, database.DeliveryStatusRetrying,
)
assert.Len(t, iResults(t, s.WebhookDB, task.DeliveryID), 1)
requireRequeued(t, s, s.Engine.ExportRetryCh(), task)
}
// deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns
// the target's archive file path. The archive writer holds the file
// open afterwards.
func deliverToArchive(t *testing.T, s iSetup) string {
t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
tgt := &database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
MaxAttempts: 1,
}
require.NoError(
t, s.MainDB.Omit(clause.Associations).Create(tgt).Error,
)
deliveryID, task := seedLogTask(t, s)
task.TargetID = tgt.ID
task.TargetType = database.TargetTypeDatabase
s.Engine.Notify([]delivery.Task{task})
iWaitForDelivered(t, s.WebhookDB, deliveryID)
return filepath.Join(
filepath.Dir(s.DBMgr.DBPath(s.WebhookID)),
"archive-hook-archive-"+tgt.ID+".db",
)
}
// TestEngine_StopHookClosesArchives is the regression test for an
// archive split across two files by a clean stop. The engine never
// closed its archive writers, so after a stop the archived rows
// could sit in archive-{id}.db-wal while archive-{id}.db held no
// table at all, and copying the .db on its own gave an empty
// database.
func TestEngine_StopHookClosesArchives(t *testing.T) {
t.Parallel()
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s)
require.FileExists(
t, path+"-wal",
"an open archive should have a -wal for the stop to remove",
)
require.NoError(t, lc.hooks[0].OnStop(context.Background()))
wals, err := filepath.Glob(
filepath.Join(filepath.Dir(path), "archive-*.db-wal"),
)
require.NoError(t, err)
require.Empty(
t, wals, "a clean stop must leave no archive -wal behind",
)
// With no -wal beside it, the row can only be in the .db.
count, err := countArchivedRows(path)
require.NoError(t, err)
require.Equal(t, int64(1), count)
}
// TestEngine_StopHookTimeoutLeavesArchivesOpen covers a stop whose
// budget runs out while a worker is still running. The archive
// writers are left open, as a kill would leave them: closing them
// would wait for any write in progress, and that worker would then
// open new writers that nothing closes.
func TestEngine_StopHookTimeoutLeavesArchivesOpen(t *testing.T) {
t.Parallel()
s := newISetup(t)
lc := startEngineViaHook(t, s.Engine)
path := deliverToArchive(t, s)
release := make(chan struct{})
t.Cleanup(func() {
close(release)
s.Engine.ExportArchives().EvictWebhook(s.WebhookID)
})
s.Engine.ExportWedgeWorker(release)
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
// Closing the last handle on an archive removes its -wal, so a
// -wal still there is a writer still open.
require.FileExists(
t, path+"-wal",
"a stop that timed out must not close archive writers",
)
}
File diff suppressed because it is too large Load Diff
@@ -1,165 +0,0 @@
package delivery_test
import (
"context"
"database/sql"
"fmt"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
)
// assertNoTargetRows opens the per-webhook database file directly,
// outside GORM, and fails if its targets table holds any rows.
// Target config is the credential for slack and http targets, and
// event databases are the files that get backed up and handed
// around.
func assertNoTargetRows(t *testing.T, dbPath string) {
t.Helper()
sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=ro", dbPath),
)
require.NoError(t, err)
defer func() { _ = sqlDB.Close() }()
var tables int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(),
"SELECT count(*) FROM sqlite_master "+
"WHERE type = 'table' AND name = 'targets'",
).Scan(&tables))
if tables == 0 {
return
}
var rows int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(), "SELECT count(*) FROM targets",
).Scan(&rows))
assert.Zero(
t, rows,
"per-webhook event database must hold no target rows",
)
}
// TestEventDBHoldsNoTargetRows drives a delivery and then a retry
// through the real engine write paths and asserts neither leaves a
// target row behind in events-*.db.
func TestEventDBHoldsNoTargetRows(t *testing.T) {
t.Parallel()
s := newISetup(t)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String()
dbPath := s.DBMgr.DBPath(s.WebhookID)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"leak":"none"}`,
)
body := event.Body
// A new delivery.
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
task := iTask(
d, event, s.WebhookID, targetID,
"leaky-target", cfg, 5, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
assertNoTargetRows(t, dbPath)
// A retry. Its target exists in the main database, because the
// engine confirms a scheduled retry's target has not been
// deleted before running it; see
// https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "leaky-target",
database.TargetTypeHTTP, cfg, 5,
)
rd := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
rTask := iTask(
rd, event, s.WebhookID, targetID,
"leaky-target", cfg, 5, 2, &body,
)
s.Engine.ExportProcessRetryTask(context.TODO(), &rTask)
iAssertStatus(
t, s.WebhookDB, rd.ID,
database.DeliveryStatusDelivered,
)
assertNoTargetRows(t, dbPath)
}
// TestEventDBHoldsNoTargetRowsOnFailedDelivery covers the failure
// write path, which updates the delivery to failed and records a
// result, rather than the success path above.
func TestEventDBHoldsNoTargetRowsOnFailedDelivery(t *testing.T) {
t.Parallel()
s := newISetup(t)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer ts.Close()
cfg := iHTTPConfig(ts.URL)
targetID := uuid.New().String()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"leak":"none"}`,
)
body := event.Body
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
task := iTask(
d, event, s.WebhookID, targetID,
"leaky-target", cfg, 1, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed,
)
assertNoTargetRows(t, s.DBMgr.DBPath(s.WebhookID))
}

Some files were not shown because too many files have changed in this diff Show More