1 Commits
Author SHA1 Message Date
clawbot d333572592 Mark superseded commits honestly instead of skipped (closes #152)
check / check (push) Successful in 3m5s
Gitea cancels an in-flight run when a newer commit lands on the same
branch and records the cancellation as `failure` / "Has been
cancelled". The workflow rewrote that to `skipped`, but Gitea's
Combine() folds `skipped` into `success`, so the combined-status API
returned green for a commit nothing had ever tested. Rewrite it to
`failure` / "Superseded by a newer commit; never tested" instead:
red-but-honest, and never `pending`, which would block the commit
forever.

Re-running the superseded commit would have been better still, but is
not reachable on this Gitea (1.25.4): its API exposes no rerun
endpoint, workflow dispatch takes a ref rather than a SHA, and every
replay would be a full uncached build with no bound on how many pile
up behind a burst of merges.

The step also stops hardcoding its status context: the logic moves into
script/ci-mark-superseded, which derives the context from the workflow
name, job id and event, and fails loudly when no status on the commit
being built carries that context, so renaming the workflow or the job
cannot silently disable the rewrite. The derivation is not byte-exact
with Gitea's own rule -- Gitea uses the job's display `name:` where the
runner exports the job id -- so adding a `name:` to the job turns every
push red rather than quietly doing nothing; the script header says so,
because that loud failure is the point. That is item 2 of
#147; item 1 there is
untouched.

Nothing about the walk may fail quietly, since the script exists to
stop CI lying quietly. An ANCESTOR_LIMIT that is set but not a positive
integer aborts instead of passing an unusable value to git and
discarding the error. A shallow clone aborts on
`git rev-parse --is-shallow-repository`: the graft makes the parent
unresolvable, so a shallow checkout is indistinguishable from a root
commit and the walk would exit 0 having marked nothing -- one dropped
`fetch-depth: 0` away, which the workflow comment now records. A
genuine root commit still exits 0, an unknown SHA has already been
rejected by the context read's 404, and the walk carries no `|| true`,
so a rev-list failure aborts. Per-ancestor status reads carry the same
`--retry 3 --max-time 30` as the head-commit read and abort on failure
rather than losing curl's exit status through a pipe.

Tests drive the script against a fake Gitea covering the cancelled,
laundered-skipped, genuinely-failed, passing and renamed cases, an
unparseable ANCESTOR_LIMIT, an ancestor whose status read answers HTTP
500, and a depth-1 clone, so jq joins the builder image to run them.
2026-08-17 22:16:17 +00:00
299 changed files with 5280 additions and 59333 deletions
+8 -15
View File
@@ -1,23 +1,16 @@
# .git is sent so the build can derive the version it stamps into the binary
# (script/version). Its config, which can hold a remote URL carrying a
# credential and which `git describe` does not need, is left out of a
# directory context. A context sent as a tar is not filtered by this file, so
# it carries .git/config unless its sender leaves it out.
.git/config
# No tracked file may be listed here: git in the build would see it as
# deleted and mark the version -dirty.
#
# .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier # .ci-fingerprint is deliberately NOT excluded: it is the CI cache barrier
# that keeps the check stages from replaying a cached pass. See the lint # that keeps the check stages from replaying a cached pass. See the lint
# stage of the Dockerfile. # stage of the Dockerfile.
.git/
bin/ bin/
# Extracted from 3p/ by `make assets` inside the build; a host copy is not # Third-party browser assets are fetched and hash-verified inside the build by
# needed. The tarball in 3p/ must stay in the context. # script/fetch-assets. Excluding any host copy keeps a developer's working tree
# from supplying the bytes that get shipped. The script and its
# static/vendor.sha256 manifest stay in the context.
static/js/alpine.min.js static/js/alpine.min.js
# The js-deps stage installs ESLint and prettier; a host copy would overwrite *.md
# them at the `COPY . .` of the stages built on it. LICENSE
node_modules/ .editorconfig
.env .env
.env.* .env.*
*.db *.db
+14 -8
View File
@@ -12,8 +12,9 @@ jobs:
- name: Checkout - name: Checkout
uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23 uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 2024-10-23
with: with:
# The superseded-status step needs history to walk ancestors (it # The fingerprint step below needs history to find the last commit
# aborts on a shallow clone). # that touched the Docker build context, and the superseded-status
# step needs it to walk ancestors (it aborts on a shallow clone).
fetch-depth: 0 fetch-depth: 0
- name: Mark superseded run statuses - name: Mark superseded run statuses
@@ -27,11 +28,16 @@ jobs:
run: script/ci-mark-superseded run: script/ci-mark-superseded
- name: Fingerprint the build context - name: Fingerprint the build context
# Writes the hash of the commit being checked into the context, which # `.dockerignore` keeps docs out of the build context, so a docs-only
# invalidates the `COPY . .` layer of every check stage: a commit # commit legitimately replays the whole image from cache and stays
# that was never linted, format-checked, stylesheet-checked, tested # cheap. Every other commit writes a new fingerprint into the context,
# and built cannot report success from cache. # which invalidates the `COPY . .` layer of both check stages: a
run: git rev-parse HEAD > .ci-fingerprint # commit that was never linted, formatted-checked, tested and built
# cannot report success from cache.
run: |
set -eu
fp="$(git log -1 --format=%H -- . ':!*.md' ':!LICENSE' ':!.editorconfig')"
printf '%s\n' "${fp:-$GITHUB_SHA}" > .ci-fingerprint
- name: Build Docker image (runs the gofmt check, golangci-lint, the stylesheet check, ESLint, the Markdown check, make test, make build) - name: Build Docker image (runs make check)
run: script/cibuild run: script/cibuild
+4 -6
View File
@@ -15,9 +15,6 @@ bin/
# Go vendor directory # Go vendor directory
vendor/ vendor/
# ESLint, prettier and their dependencies, installed from yarn.lock
node_modules/
# IDE specific files # IDE specific files
.idea/ .idea/
*.swp *.swp
@@ -49,6 +46,7 @@ temp/
# CI cache barrier, written into the build context by the check workflow # CI cache barrier, written into the build context by the check workflow
.ci-fingerprint .ci-fingerprint
# Alpine.js, extracted by `make assets` from its tarball in 3p/, which is # Third-party browser assets, fetched and hash-verified by
# what is committed. # script/fetch-assets against static/vendor.sha256. Not committed:
/static/js/alpine.min.js # REPO_POLICIES.md forbids minified bundles in version control.
/static/js/alpine.min.js
-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.
+23 -147
View File
@@ -4,105 +4,24 @@
# compile on Alpine musl (off64_t is a glibc type). # compile on Alpine musl (off64_t is a glibc type).
FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint FROM golangci/golangci-lint:v2.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS lint
RUN apt-get update && apt-get install -y --no-install-recommends make && rm -rf /var/lib/apt/lists/*
WORKDIR /src WORKDIR /src
# Copy go mod files first for better layer caching # Copy go mod files first for better layer caching
COPY go.mod go.sum ./ COPY go.mod go.sum ./
RUN go mod download RUN go mod download
# Copy source code. In CI the context also carries .ci-fingerprint, which # Copy source code. In CI the context also carries .ci-fingerprint, whose
# holds the hash of the commit being checked (see # value changes with every commit that touches the build context (see
# .gitea/workflows/check.yml). That invalidates this layer, so the checks # .gitea/workflows/check.yml). That invalidates this layer, so the checks
# below cannot report success by replaying a cached pass. Do not add it to # below cannot report success by replaying a cached pass. Do not add it to
# .dockerignore. # .dockerignore.
COPY . . COPY . .
# Run the Go formatting check and the linter. gofmt and golangci-lint are # Run formatting check and linter
# invoked directly rather than through `make fmt-check` and `make lint`: this RUN make fmt-check
# stage is already the pinned linter image, and both scripts build docker RUN make lint
# stages, so calling them here would need a docker daemon inside the build.
# The Markdown half of `make fmt-check` is the markdown-check stage below.
# Keep the golangci-lint steps in step with Dockerfile.lint, including
# --network=none (see its header for why).
RUN if [ -n "$(gofmt -s -l .)" ]; then echo "gofmt needed on:"; gofmt -s -l .; exit 1; fi
RUN script/assets
RUN --network=none golangci-lint config verify --config .golangci.yml
RUN --network=none golangci-lint run --config .golangci.yml --build-tags browser ./...
# 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
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, and stays cached until package.json,
# yarn.lock or .yarnrc.yml changes. script/lint forces only js-lint to re-run,
# and the build stage below runs it too. COPY . . brings in the CI cache
# barrier described in the lint stage above.
#
# 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'
# Build stage # Build stage
# golang:1.26.1-bookworm (Debian-based), 2026-03-17 # golang:1.26.1-bookworm (Debian-based), 2026-03-17
@@ -110,21 +29,12 @@ RUN --network=none node_modules/.bin/prettier --check '**/*.md'
# mattn/go-sqlite3 (CGO), which does not compile on Alpine musl. # mattn/go-sqlite3 (CGO), which does not compile on Alpine musl.
FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder FROM golang:1.26.1-bookworm@sha256:4465644228bc2857a954b092167e12aa59c006a3492282a6c820bf4755fd64a4 AS builder
# Depend on the lint, stylesheet check, JavaScript lint and Markdown check # Depend on lint stage passing
# stages passing
COPY --from=lint /src/go.sum /dev/null COPY --from=lint /src/go.sum /dev/null
COPY --from=css-check /out/tailwind.css /dev/null
COPY --from=js-lint /src/yarn.lock /dev/null
COPY --from=markdown-check /src/yarn.lock /dev/null
# jq is a runtime dependency of script/ci-mark-superseded, which the test # jq is a runtime dependency of script/ci-mark-superseded, which the test
# suite executes. git is what script/version derives the version with. # suite executes.
RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq git && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y --no-install-recommends make curl ca-certificates jq && rm -rf /var/lib/apt/lists/*
# 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
WORKDIR /build WORKDIR /build
@@ -136,47 +46,30 @@ RUN go mod download
# the lint stage above. # the lint stage above.
COPY . . COPY . .
# Run tests and build. Both first run script/assets, which extracts Alpine.js # Fetch the third-party browser assets the UI serves. They are not committed
# from its tarball in 3p/. # (REPO_POLICIES.md forbids minified bundles in version control) and
# .dockerignore keeps any host copy out of the build context, so this step is
# the only way they enter the image. Each download is checked against a
# hardcoded sha256 and the build fails on mismatch; make test re-checks the
# hashes against the bytes go:embed actually put in the binary.
RUN script/fetch-assets
# Run tests and build
RUN make test RUN make test
RUN make build
# 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".
#
# Declared here, below the test step, so a changed version does not
# invalidate its cached layer.
ARG VERSION
# A context that carries .git must not stamp "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 if [ -d .git ] && [ "$(make version VERSION="$VERSION")" = unknown ]; then \
echo "version is unknown although the build context carries .git" >&2; \
exit 1; \
fi
RUN make build VERSION="$VERSION"
# Rebuild with static linking for Alpine runtime. # Rebuild with static linking for Alpine runtime.
# make build already verified compilation. # make build already verified compilation.
# The CGO binary from `make build` is dynamically linked against glibc, # The CGO binary from `make build` is dynamically linked against glibc,
# which doesn't exist on Alpine (musl). Rebuild with static linking so # which doesn't exist on Alpine (musl). Rebuild with static linking so
# the binary runs on Alpine without glibc. # the binary runs on Alpine without glibc.
# RUN CGO_ENABLED=1 go build -ldflags '-extldflags "-static"' -o bin/webhooker ./cmd/webhooker
# The static flags go in through GO_LDFLAGS rather than a -ldflags of
# their own: the build target composes them with the -X that stamps the
# version, so this relink cannot silently drop the stamp.
RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static"'
# Runtime stage # Runtime stage
# alpine:3.21, 2026-03-17 # alpine:3.21, 2026-03-17
FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709
# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app RUN apk --no-cache add ca-certificates
# as webhooker with it.
RUN apk --no-cache add ca-certificates su-exec=0.2-r3
# Create non-root user # Create non-root user
RUN addgroup -g 1000 -S webhooker && \ RUN addgroup -g 1000 -S webhooker && \
@@ -187,34 +80,17 @@ WORKDIR /app
# Copy binary from builder # Copy binary from builder
COPY --from=builder /build/bin/webhooker /app/webhooker 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 + # Create data directory for all SQLite databases (main app DB +
# per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker. # per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker.
RUN mkdir -p /var/lib/webhooker RUN mkdir -p /var/lib/webhooker
RUN chown -R webhooker:webhooker /app /var/lib/webhooker RUN chown -R webhooker:webhooker /app /var/lib/webhooker
# No USER: the entrypoint starts as root to make the data directory USER webhooker
# webhooker's, then runs the app as webhooker.
EXPOSE 8080 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
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost: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"] 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
# script/test.
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$'
-43
View File
@@ -1,43 +0,0 @@
# Lint-only image, built by script/lint. golangci-lint is never installed on
# the host: the repo is COPYed into the pinned image and linted as a build
# step, so a successful build IS a clean lint. This works even when the docker
# daemon is remote and bind mounts are impossible.
#
# script/lint passes --no-cache-filter=lint. Without it an unchanged tree
# replays the lint stage from cache and the build succeeds in under a second
# having run no linter at all. Do not drop that flag.
#
# The lint steps run with --network=none. `golangci-lint config verify` is
# documented as fetching its JSON schema over HTTPS, which would make linting
# depend on an unpinned remote artifact; this pinned image resolves the schema
# without any network, and --network=none enforces that rather than trusting
# it. It also proves no linter reaches out at analysis time. If a future image
# bump makes either step need the network, this build fails loudly instead of
# quietly acquiring an unpinned dependency.
# golangci/golangci-lint:v2.12.2 (Debian-based), 2026-08-07
# 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.12.2@sha256:5cceeef04e53efe1470638d4b4b4f5ceefd574955ab3941b2d9a68a8c9ad5240 AS deps
WORKDIR /src
# Copy go mod files first for better layer caching. This stage is cacheable;
# only the lint stage below is forced to re-execute.
COPY go.mod go.sum ./
RUN go mod download
FROM deps AS lint
COPY . .
# static/static.go embeds the Alpine.js file this extracts from 3p/; without
# it the static package does not compile and cannot be linted.
RUN script/assets
# `run` silently ignores config keys it does not recognize, so a typo would
# disable a setting without a word. `config verify` is what catches that.
RUN --network=none golangci-lint config verify --config .golangci.yml
# --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 ./...
+6 -33
View File
@@ -1,26 +1,8 @@
.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 assets test lint fmt fmt-check check build run dev deps docker clean hooks css
# Default target # Default target
.DEFAULT_GOAL := check .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))
# Extra linker flags for the build target. The static relink in the
# Dockerfile adds -extldflags here rather than passing its own -ldflags,
# so composing flags cannot drop the version stamp.
GO_LDFLAGS ?=
bootstrap: bootstrap:
@script/bootstrap @script/bootstrap
@@ -28,14 +10,11 @@ setup:
@script/setup @script/setup
assets: assets:
@script/assets @script/fetch-assets
test: test:
@script/test @script/test
test-browser:
@script/test-browser
lint: lint:
@script/lint @script/lint
@@ -48,22 +27,19 @@ fmt-check:
check: check:
@script/check @script/check
build: assets build:
go build -ldflags '$(strip -X main.version=$(VERSION) $(GO_LDFLAGS))' -o bin/webhooker ./cmd/webhooker go build -o bin/webhooker ./cmd/webhooker
run: build run: build
./bin/webhooker ./bin/webhooker
dev: assets dev:
go run ./cmd/webhooker go run ./cmd/webhooker
deps: deps:
go mod download go mod download
go mod tidy go mod tidy
version:
@echo $(VERSION)
docker: docker:
@script/docker @script/docker
@@ -74,7 +50,4 @@ hooks:
@script/install-precommit @script/install-precommit
css: css:
@script/css tailwindcss -i static/css/input.css -o static/css/tailwind.css --minify
css-check:
@script/css-check
+936 -2935
View File
File diff suppressed because it is too large Load Diff
+6 -14
View File
@@ -1,6 +1,6 @@
--- ---
title: Repository Policies title: Repository Policies
last_modified: 2026-08-07 last_modified: 2026-07-06
--- ---
This document covers repository structure, tooling, and workflow standards. Code This document covers repository structure, tooling, and workflow standards. Code
@@ -189,13 +189,8 @@ style conventions are in separate documents:
module under test to verify it compiles/parses. There is no excuse for module under test to verify it compiles/parses. There is no excuse for
`make test` to be a no-op. `make test` to be a no-op.
- `make test` must complete in under 60 seconds. That is the hard cap, and a - `make test` must complete in under 20 seconds. Add a 30-second timeout in the
suite that exceeds it fails. Under 20 seconds is the target. A suite between Makefile.
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 in the Makefile (`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` should use the conditional verbose rerun pattern.** Run tests - **`make test` should use the conditional verbose rerun pattern.** Run tests
without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to without `-v` (verbose) first. If tests fail, automatically rerun with `-v` to
@@ -214,9 +209,9 @@ style conventions are in separate documents:
```makefile ```makefile
test: test:
@go test -timeout 90s -race -cover ./... || \ @go test -timeout 30s -race -cover ./... || \
{ echo "--- Rerunning with -v for details ---"; \ { echo "--- Rerunning with -v for details ---"; \
go test -timeout 90s -race -v ./...; exit 1; } go test -timeout 30s -race -v ./...; exit 1; }
``` ```
Python example: Python example:
@@ -265,10 +260,7 @@ style conventions are in separate documents:
- `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only - `.golangci.yml` is standardized and must _NEVER_ be modified by an agent, only
manually by the user. Fetch from manually by the user. Fetch from
`https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`. The `https://git.eeqj.de/sneak/prompts/raw/branch/main/.golangci.yml`.
canonical golangci-lint version is v2.12.2 (released 2026-05-06), installed
commit-pinned via
`go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@c0d3ddc9cf3faa61a4e378e879ece580256d76e5`.
- When pinning images or packages by hash, add a comment above the reference - When pinning images or packages by hash, add a comment above the reference
with the version and date (YYYY-MM-DD). with the version and date (YYYY-MM-DD).
+161 -338
View File
@@ -2,367 +2,191 @@
One issue per unit of work, one branch and one PR per issue: One issue per unit of work, one branch and one PR per issue:
- ensure a tracked issue exists with a definition of done * ensure a tracked issue exists with a definition of done
- branch from `next` (never from `main`) * branch from `next` (never from `main`)
- do the work; open a PR based on `next` (never on `main`) * do the work; open a PR based on `next` (never on `main`)
- pass an independent review, then the manager squash-merges into `next` * pass an independent review, then the manager squash-merges into `next`
- push; nothing stays local-only * push; nothing stays local-only
`next` is the branch for the next milestone and must stay green and mergeable to `next` is the branch for the next milestone and must stay green and
`main` without notice. One `next` -> `main` PR accumulates the milestone; mergeable to `main` without notice. One `next` -> `main` PR accumulates
releases are cut from `main` separately. the milestone; releases are cut from `main` separately.
Issue branches do NOT touch this file — the manager maintains it on `next`. Issue branches do NOT touch this file — the manager maintains it on
Every branch editing `TODO.md` conflicts with every other (#112). `next`. Every branch editing `TODO.md` conflicts with every other
(#112).
# Status # Status
The milestone (https://git.eeqj.de/sneak/webhooker/milestone/9) is the pre-1.0. No git tags exist. `main` (4f5ecb1) is a working webhook proxy
authoritative list, and the only place to read a count or a state of play from. with auth, CSRF/SSRF protections, login rate limiting, Slack target,
This file records where the project is, not what is in flight: a sentence whose event retention (#63), the database archiving target (#43), the admin
truth depends on a branch being unmerged is wrong the moment it merges, and this password change flow (#65), policy compliance (#6), pinned lint tooling
file has been wrong that way before. (#55), and fail-loud configuration parsing (#80).
The durability defect that held the tag has landed `next` holds the completed 1.0.0 milestone: every issue in it is closed,
(https://git.eeqj.de/sneak/webhooker/issues/256, commit `8d64259`). Every SQLite and it is verified green by cache-defeated container runs
handle opens with WAL journaling and a busy timeout, a bookkeeping write that (`docker build --no-cache-filter=lint --no-cache-filter=builder`). The
fails leaves its delivery in a recoverable state rather than a lying one, and CI status is not independently claimed here: a superseded run is
recovery skips a delivery that already has a successful result row. Final recorded as `skipped` and still rolls up green, so a commit status on
pre-tag verification exercised it and confirmed it holds. Whatever the milestone `next` does not by itself evidence an executed check (#152). Before
still shows open is what remains before `v1.0.0`. #119, a warm layer cache also let the gate report success without
executing anything, and replayed the previous build's console log so
Delivery is at-least-once by design, not by accident: a send whose result row the lie looked like a real run. Note: `TODO.md` was deliberately
does not land is attempted again, so a receiver can see a duplicate. That is deleted from this repo in f9a9569 (2026-03-01, #6); its content was
deliberate — the alternative is a silent lost delivery — and the README says so folded into the README TODO section, which this draft reconstructs as
under Rationale. It is not a defect to re-file. of 2026-07-06.
# Next Step # Next Step
Clear the rest of the open 1.0.0 milestone Merge the milestone PR to `main` and tag 1.0.0 from it.
(https://git.eeqj.de/sneak/webhooker/milestone/9) and tag `v1.0.0`. Merging
`next` into `main` is a separate act from tagging and waits on neither of those: Two decisions are open and belong to the owner, neither blocking the
`next` is kept mergeable at all times, which is the point of the branch. tag: #115 (mask the `http` target's destination URL, implemented
speculatively and awaiting a yes or no) and #125 (whether IPv6
rate-limit keys should bucket by `/64`).
# Completed Steps # Completed Steps
- 2026-08-24 Bind the plaintext HTTP listener deliberately, via `BIND_ADDRESS` - 2026-08-12 Bound the receiver rate limit per client IP across the
defaulting to `127.0.0.1`, and document the reverse-proxy deployment. A whole `/webhook/*` route. The existing limiter keyed on the request
hostname, an empty value or a value carrying a port is a startup error, and path and `/webhook/{uuid}` matches any single segment, so a client
the `Dockerfile` sets `0.0.0.0` because a loopback bind inside a container is that invented a fresh path per request minted a fresh bucket per
unreachable (https://git.eeqj.de/sneak/webhooker/issues/268). The same commit request: the limit on the only unauthenticated endpoint bounded
removed the shutdown race: `httpServer` is built in the constructor rather nothing in aggregate, and every request still cost an entrypoint
than assigned from the serving goroutine, which orders the write before every lookup before it 404ed. An outer limiter keyed on the client address
fx hook and rules out the nil dereference a SIGTERM arriving first would have alone now bounds that, chained in front of the unchanged
caused, and `sentryEnabled` is an `atomic.Bool`
(https://git.eeqj.de/sneak/webhooker/issues/226)
- 2026-08-24 Remove inbound request signature verification. The entrypoint UUID
is the authentication secret, so the per-entrypoint shared secret, the
`internal/signature` package, the receiver check, the model fields and the
forms are all gone. This reverses the feature that landed earlier in the same
milestone (https://git.eeqj.de/sneak/webhooker/issues/67,
https://git.eeqj.de/sneak/webhooker/issues/279)
- 2026-08-24 Stamp the build version into the binary and render it in the UI
footer. `script/version` is the single source — `$VERSION`, else
`git describe --tags --always --dirty`, else `unknown` — so a `make build`
binary and a `make docker` image from one checkout report the same thing, and
nothing in it varies between two builds of the same commit, which the release
gate's byte-identical assertion would catch
(https://git.eeqj.de/sneak/webhooker/issues/253)
- 2026-08-24 Derive cookie `Secure` and CSRF strictness from the request
transport rather than from `WEBHOOKER_ENVIRONMENT`. Behind a real TLS proxy
with the environment left at its `dev` default, the session cookie silently
lost `Secure` while the CSRF cookie on the same response kept it.
`X-Forwarded-Proto` is now matched case-insensitively on its first
comma-separated element, so `HTTPS` and `https, http` no longer fall to the
relaxed CSRF path (https://git.eeqj.de/sneak/webhooker/issues/269)
- 2026-08-24 Roll back a failed webhook deletion instead of committing it. A
failing delete committed whatever had already succeeded, hard-deleted the
per-webhook event database anyway, and redirected as though it had worked —
orphaned config plus permanently destroyed history, reported as success. All
three delete positions now roll back with the event database intact
(https://git.eeqj.de/sneak/webhooker/issues/262)
- 2026-08-24 Name a deleted target on its historical deliveries, marked
`(deleted)`, rather than leaving the event log unable to say where a delivery
went. A deleted target's credentials stay masked exactly as a live one's, and
it cannot become deliverable again through the receiver, resubmit, replay, the
edit form or the toggle (https://git.eeqj.de/sneak/webhooker/issues/211)
- 2026-08-24 Bound both request-controlled `/metrics` label dimensions, so the
unauthenticated receiver is no longer a memory-exhaustion vector: `handler`
carries the chi route pattern, and `method` folds anything chi cannot route
onto a single `(unmatched)` sentinel. Both were reproduced before the fix —
300 random method tokens took the series count from 106 to 7,631, and path
flooding reached 62,532 — and a label audit across a live scrape found no
third unbounded dimension (https://git.eeqj.de/sneak/webhooker/issues/254,
https://git.eeqj.de/sneak/webhooker/issues/261)
- 2026-08-24 Validate `max_retries` on both target forms. `abc`, `2.7` and `-5`
silently became 0 — fire-and-forget — including on the edit path, where it
destroyed a working value, and `999999999` stored verbatim. The ceiling of 20
is the `max` both templates already declared
(https://git.eeqj.de/sneak/webhooker/issues/221)
- 2026-08-24 Resubmit a stored event as a new undelivered event, so a backend
under development can be tested against real captured traffic. Per-delivery
replay cannot serve that: it re-sends one finished delivery to its own
original target, and a target created for a dev backend has no prior delivery
to replay. Resubmit re-injects the stored event at the top of the receiver
path and fans it out to whatever targets are active now
(https://git.eeqj.de/sneak/webhooker/issues/250)
- 2026-08-20 Take an exclusive lock on `DATA_DIR` at startup, so two instances
on one directory cannot both deliver
(https://git.eeqj.de/sneak/webhooker/issues/201)
- 2026-08-20 Shut down the app when the HTTP listener fails. The `OnStart` hook
returned as soon as the serving goroutine was spawned, so a failed listen left
fx reporting RUNNING and a live process with nothing bound — invisible to
systemd and Docker restart policies
(https://git.eeqj.de/sneak/webhooker/issues/200)
- 2026-08-20 Stop target credentials leaking into the per-webhook event
databases (https://git.eeqj.de/sneak/webhooker/issues/206), log SQL with
placeholders rather than bound values
(https://git.eeqj.de/sneak/webhooker/issues/207), and fail loudly on half-set
metrics auth credentials (https://git.eeqj.de/sneak/webhooker/issues/205)
- 2026-08-20 Read queue depths with `Find`, not `Scan`. `Scan` swaps GORM's own
trace recorder in for the logging adapter, and that recorder does not
implement `gorm.ParamsFilter`, so those statements logged their bound values
interpolated and bypassed the suppression above. The two units gated green
against a `next` that lacked the other, and `next` went red when both landed
(https://git.eeqj.de/sneak/webhooker/issues/234)
- 2026-08-20 Render per-attempt delivery detail in the event log
(https://git.eeqj.de/sneak/webhooker/issues/202) and add replay of a
terminally failed delivery (https://git.eeqj.de/sneak/webhooker/issues/203)
- 2026-08-20 Expose delivery metrics on `/metrics`
(https://git.eeqj.de/sneak/webhooker/issues/209) and document the backup,
restore and upgrade procedures
(https://git.eeqj.de/sneak/webhooker/issues/210)
- 2026-08-20 Add a `webhooker resetpw` subcommand and a bootstrap banner. The
admin bootstrap password was printed once among roughly 45 fx lines, and under
`docker run -d` went to container logs subject to rotation; there was no reset
path at all, so recovery meant hand-deleting the users row, documented
nowhere. The password is read from stdin or generated, never from argv where
`/proc` would publish it (https://git.eeqj.de/sneak/webhooker/issues/208)
- 2026-08-20 Add `ALLOWED_EGRESS_CIDRS`, an allowlist-only escape hatch for the
SSRF guard, so a self-hosted proxy can forward into the operator's own
network. The guard's always-blocked set cannot be reopened by configuration
(https://git.eeqj.de/sneak/webhooker/issues/204)
- 2026-08-20 Harden operator-set target headers, which were carried unsafely
across a redirect (https://git.eeqj.de/sneak/webhooker/issues/233)
- 2026-08-20 Add a target edit form with headers and timeout fields
(https://git.eeqj.de/sneak/webhooker/issues/127)
- 2026-08-18 Raise `script/test`'s per-package timeout from 30s to 90s, matching
the org-wide backstop. `go test` applies `-timeout` per package, and
`internal/handlers` had grown past the old budget: a cache-defeated build
failed outright at `GOMAXPROCS=4`, and every run under deliberate host load
breached 30s. The measurement table lives in the script (#194)
- 2026-08-18 Re-sync `REPO_POLICIES.md` from `prompts`. The local copy was stale
and still mandated a 20s test target with a 30s timeout, which the org
replaced with a 60s cap and a 90s backstop. A synced copy is not a source;
reading it as one nearly produced a PR against `prompts` proposing a change
already merged there (#196)
- 2026-08-18 Report handler panics through the logger and answer 500. chi
v1.5.5's `Recoverer` scans for a `panic(0x` frame the runtime no longer emits,
then indexes `pkg[-1:]`, so it panicked inside its own stack printer before
writing a byte: the recovery never ran, the client got a dropped connection
instead of a 500, and the original panic was lost. A local middleware replaces
it, bounded by `MaxPanicLogLineBytes` (#187)
- 2026-08-18 Route GORM's logger through `slog` and bound it. Every `gorm.Open`
left `logger.Default` in place at `Warn` with `IgnoreRecordNotFoundError`
false, so **every record-not-found printed the fully interpolated SQL to
stdout** — including the client-chosen path on `/webhook/{uuid}` and the
submitted username on the login form, at no level the operator set and outside
`internal/logger` entirely. Three call sites, not the two the issue named
(#178)
- 2026-08-18 Bound every `slog` line against client-chosen text. Eight sites
reachable unauthenticated, found by reading every `slog` call in the tree
rather than only the one reported; the budget moved to a shared
`internal/logfield` so no second truncation exists. `DEBUG` being off by
default is not a bound and is not treated as one (#176)
- 2026-08-18 Stop a slow host turning a login-guard test into a segfault. A
non-fatal `assert` on an acquire result was dereferenced on the next line, so
one timing miss killed the whole `internal/middleware` binary and reddened CI
for unrelated PRs. The fix also removed a real production race — `acquire`
could shed a request with a slot standing free, because Go picks uniformly
among ready `select` cases (#186)
- 2026-08-18 Send the chi route pattern to Sentry rather than the concrete path.
The receiver's path carries the entrypoint capability token, so every Sentry
event from `/webhook/{uuid}` shipped a live credential to a third party.
Request `Data`, `QueryString`, `Cookies` and `Env` are dropped and headers
reduced to an allowlist (#179)
- 2026-08-18 Read form fields from the POST body only. `r.FormValue` merges the
query string, so a login could be driven by URL parameters — putting the
password somewhere that lands in access logs, proxy logs and browser history
(#160)
- 2026-08-18 Verify login credentials before spending rate-limit budget, so a
flood of wrong passwords cannot lock out the account it is guessing at. The
manager took this decision rather than stall the queue; it is flagged on the
issue for reversal (#150)
- 2026-08-18 Run all linting in Docker via `Dockerfile.lint`. Host lint was
wrong in both directions from version skew and shared caches. `script/lint`
asserts the summary line, because `--no-cache-filter` silently ignores a stage
name it does not match — the flag that makes the gate meaningful fails open
(#109)
- 2026-08-18 Serve an event's full stored body over HTTP. The list query
truncates for rendering, and that truncated value was the only way to read a
body, so the full payload was unreachable (#157)
- 2026-08-18 Bound the access log line against client-chosen text.
`internal/logfield` budgets by _encoded_ bytes, not runes, so a handler's JSON
escaping cannot multiply a field past its allowance (#146)
- 2026-08-18 Mark superseded CI commits `failure` rather than `skipped`. A
skipped run rolls up green, so a commit that was never tested reported success
(#152)
- 2026-08-18 Set `fx.StopTimeout` inside the container stop grace, so shutdown
hooks are bounded by a deadline the orchestrator will actually honour rather
than being killed mid-flush (#134)
- 2026-08-17 Bucket IPv6 rate-limit keys by `/64`. A single allocation hands out
2^64 addresses, so per-address keying let one client mint unlimited buckets.
Manager decision, recorded on the issue (#125)
- 2026-08-17 Correct release-blocking README and startup-warning inaccuracies,
including claims about behaviour the code does not have (#151)
- 2026-08-17 Fetch and verify Alpine.js at build time against
`static/vendor.sha256` instead of committing the minified blob, so the
dependency is pinned by hash rather than by trust (#145)
- 2026-08-17 Bound the event log's rendered bodies in the query itself, so a
large stored payload cannot be read into memory just to be truncated for
display (#135)
- 2026-08-17 Mask the `http` target's destination URL in the UI: it can carry a
bearer credential in its path or query, and was rendered verbatim. Manager
decision to mask unconditionally (#115)
- 2026-08-14 Bound shutdown hooks by their stop context, so a hook that hangs
cannot hold the process past its grace period (#102)
- 2026-08-14 Render templates via a buffer rather than the `ResponseWriter`, so
a template error part-way through cannot commit a 200 and then fail — the
response is written only once it is whole (#123)
- 2026-08-14 Align the session codec's max-age with the 7-day absolute cap. The
codec accepted cookies the session layer considered expired, so the cap was
enforced in one place and not the other (#108)
- 2026-08-12 Warn when `TRUSTED_PROXIES` is empty in production, where the safe
default silently discards forwarded headers and every client rate-limits as
the proxy's address (#149)
- 2026-08-12 Bound the receiver rate limit per client IP across the whole
`/webhook/*` route. The existing limiter keyed on the request path and
`/webhook/{uuid}` matches any single segment, so a client that invented a
fresh path per request minted a fresh bucket per request: the limit on the
only unauthenticated endpoint bounded nothing in aggregate, and every request
still cost an entrypoint lookup before it 404ed. An outer limiter keyed on the
client address alone now bounds that, chained in front of the unchanged
per-entrypoint limiter (#139) per-entrypoint limiter (#139)
- 2026-08-12 Correct release-blocking documentation inaccuracies: the README - 2026-08-12 Correct release-blocking documentation inaccuracies: the
promised manual redelivery in the present tense in three places when nothing README promised manual redelivery in the present tense in three
implements it (the same false claim also sat in the doc comment that was its places when nothing implements it (the same false claim also sat in
source text), the env table omitted `RETENTION_SWEEP_INTERVAL`, and `TODO.md` the doc comment that was its source text), the env table omitted
itself omitted five landed units (#141) `RETENTION_SWEEP_INTERVAL`, and `TODO.md` itself omitted five landed
- 2026-08-12 Make the CI gate execute the checks it reports on. The workflow now units (#141)
writes a build-context fingerprint before calling `script/cibuild`, so a code - 2026-08-12 Make the CI gate execute the checks it reports on. The
commit invalidates the `COPY` layer of the lint and builder stages while a workflow now writes a build-context fingerprint before calling
docs-only commit still replays from cache; a superseding run also rewrites the `script/cibuild`, so a code commit invalidates the `COPY` layer of
`failure` status Gitea leaves on commits it cancelled and never tested. the lint and builder stages while a docs-only commit still replays
Verified by pushing a deliberately broken test and watching CI go red (#119) from cache; a superseding run also rewrites the `failure` status
- 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a non-positive value Gitea leaves on commits it cancelled and never tested. Verified by
reached `time.NewTicker` in both the retention reaper and the archive sweeper, pushing a deliberately broken test and watching CI go red (#119)
panicking two goroutines with no recover after startup had already reported - 2026-08-12 Require a positive `RETENTION_SWEEP_INTERVAL`: a
success (#140) non-positive value reached `time.NewTicker` in both the retention
- 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop cap: the reaper and the archive sweeper, panicking two goroutines with no
reverse walk cuts entries with `strings.LastIndexByte` instead of joining and recover after startup had already reported success (#140)
splitting, so a 1 MB header allocates 16 bytes rather than 1.6 MB per request - 2026-08-12 Bound the `X-Forwarded-For` scan's allocation to the hop
on the unauthenticated receiver. Semantics proven unchanged by differential cap: the reverse walk cuts entries with `strings.LastIndexByte`
testing against the previous implementation (#133) instead of joining and splitting, so a 1 MB header allocates 16 bytes
rather than 1.6 MB per request on the unauthenticated receiver.
Semantics proven unchanged by differential testing against the
previous implementation (#133)
- 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an - 2026-08-12 Cap the `X-Forwarded-For` hop walk at 64 entries, so an
attacker-supplied chain cannot burn unbounded CPU in the rate-limit key attacker-supplied chain cannot burn unbounded CPU in the rate-limit
function; running off the end falls back to the peer address (#124) key function; running off the end falls back to the peer address
- 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR list: (#124)
all three rate limiters key on the connection's own address unless the direct - 2026-08-12 Gate forwarded-header trust behind a `TRUSTED_PROXIES` CIDR
peer is a configured proxy, in which case `X-Forwarded-For` is walked right to list: all three rate limiters key on the connection's own address
left for the first non-proxy hop. Default trusts nothing, and a unless the direct peer is a configured proxy, in which case
set-but-unparseable value aborts startup. Before this, any client could mint a `X-Forwarded-For` is walked right to left for the first non-proxy hop.
fresh bucket or drain another's by rotating a spoofed header (#88) Default trusts nothing, and a set-but-unparseable value aborts
- 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the Profile startup. Before this, any client could mint a fresh bucket or drain
settings placeholder removed, a progressive-enhancement copy button for the another's by rotating a spoofed header (#88)
entrypoint URL, and retention form copy that states the actual policy - 2026-08-11 Web UI cleanup: nav terminology unified on Webhooks, the
(deletion by the reaper, 0 retains forever) (#57) Profile settings placeholder removed, a progressive-enhancement copy
- 2026-08-11 Mask the webhook credential in delivery errors and logs: Go embeds button for the entrypoint URL, and retention form copy that states the
the request URL in `*url.Error`, so every transport failure persisted the full actual policy (deletion by the reaper, 0 retains forever) (#57)
Slack webhook URL into the per-webhook event database via - 2026-08-11 Mask the webhook credential in delivery errors and logs:
`DeliveryResult.Error`, a field a future REST API would have served. Go embeds the request URL in `*url.Error`, so every transport failure
`maskURLError` drops path, query and userinfo while preserving the wrapped persisted the full Slack webhook URL into the per-webhook event
cause, so `errors.Is`/`As` and `Timeout()` still work and DNS, TLS and timeout database via `DeliveryResult.Error`, a field a future REST API would
failures still read differently (#118) have served. `maskURLError` drops path, query and userinfo while
preserving the wrapped cause, so `errors.Is`/`As` and `Timeout()`
still work and DNS, TLS and timeout failures still read differently
(#118)
- 2026-08-11 Rate-limit the public webhook receiver endpoint - 2026-08-11 Rate-limit the public webhook receiver endpoint
(`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus entrypoint (`RECEIVER_RATE_LIMIT`, default 120/min), keyed on client IP plus
path so one entrypoint cannot exhaust another's budget; over-limit requests entrypoint path so one entrypoint cannot exhaust another's budget;
get 429 with `Retry-After`. It was the one unauthenticated, internet-facing over-limit requests get 429 with `Retry-After`. It was the one
endpoint with no limit at all (#64) unauthenticated, internet-facing endpoint with no limit at all (#64)
- 2026-08-11 Enforce the body size limit before CSRF parses the form: - 2026-08-11 Enforce the body size limit before CSRF parses the form:
`MaxBodySize` is now first in all four form-parsing route groups, so an `MaxBodySize` is now first in all four form-parsing route groups, so
oversized request is rejected with 413 instead of being read in full by the an oversized request is rejected with 413 instead of being read in
CSRF middleware before any cap applied (#90) full by the CSRF middleware before any cap applied (#90)
- 2026-08-11 Mask target config on the source detail page, which rendered the - 2026-08-11 Mask target config on the source detail page, which
stored blob verbatim and so exposed the Slack incoming-webhook URL — a bearer rendered the stored blob verbatim and so exposed the Slack
credential that cannot be revoked per-holder. Config reaches the template only incoming-webhook URL — a bearer credential that cannot be revoked
as a `TargetView` of labelled fields, and header values are rendered as a per-holder. Config reaches the template only as a `TargetView` of
count (#113) labelled fields, and header values are rendered as a count (#113)
- 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a sentinel - 2026-08-11 Allow `retention_days` of 0 to mean retain forever, via a
written in `BeforeSave` so the GORM column default cannot win the race. Also sentinel written in `BeforeSave` so the GORM column default cannot
bounds the reaper's cutoff arithmetic: day counts above 106751 overflowed win the race. Also bounds the reaper's cutoff arithmetic: day counts
`time.Duration` and wrapped the cutoff into the future, where every row above 106751 overflowed `time.Duration` and wrapped the cutoff into
matched and the sweep deleted everything (#79) the future, where every row matched and the sweep deleted everything
(#79)
- 2026-08-09 Inactivity-based session timeout: sliding idle expiry - 2026-08-09 Inactivity-based session timeout: sliding idle expiry
(`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated requests, (`SESSION_IDLE_TIMEOUT`, default `24h`) refreshed on authenticated
with the 7-day absolute cap kept as an independent backstop that activity requests, with the 7-day absolute cap kept as an independent
never extends (#66) backstop that activity never extends (#66)
- 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an - 2026-08-09 Restart recovery and the 60s retry sweep terminally fail an
orphaned `retrying` delivery whose target type no longer supports retries, orphaned `retrying` delivery whose target type no longer supports
recording a `DeliveryResult` with the reason instead of leaving the delivery retries, recording a `DeliveryResult` with the reason instead of
stuck forever (#82) leaving the delivery stuck forever (#82)
- 2026-08-09 Root the delivery engine's worker pool and the retention reaper's - 2026-08-09 Root the delivery engine's worker pool and the retention
sweep loop at `context.Background()` rather than the fx `OnStart` hook context reaper's sweep loop at `context.Background()` rather than the fx
(#97), which carries fx's 15s start timeout and killed both roughly fifteen `OnStart` hook context (#97), which carries fx's 15s start timeout and
seconds after boot: the proxy silently stopped delivering webhooks entirely, killed both roughly fifteen seconds after boot: the proxy silently
and the reaper never ran a single sweep under its default one-hour interval stopped delivering webhooks entirely, and the reaper never ran a
- 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its last single sweep under its default one-hour interval
`database` target) evicts the cached archive writer and closes its handle - 2026-08-09 Archive writer lifecycle (#89): deleting a webhook (or its
while deliberately leaving `archive-{webhookID}.db` on disk, and a new last `database` target) evicts the cached archive writer and closes
`ArchiveSweeper` prunes idle archives on the existing its handle while deliberately leaving `archive-{webhookID}.db` on
disk, and a new `ArchiveSweeper` prunes idle archives on the existing
`RETENTION_SWEEP_INTERVAL` without ever creating an archive file `RETENTION_SWEEP_INTERVAL` without ever creating an archive file
- 2026-08-09 Configuration parsing fails loudly on set-but-unparseable - 2026-08-09 Configuration parsing fails loudly on set-but-unparseable
environment values: `envInt` removed in favour of `envPositiveInt` plus a environment values: `envInt` removed in favour of `envPositiveInt`
`PORT` range check, `envBool` now parses with `strconv.ParseBool`, and plus a `PORT` range check, `envBool` now parses with
defaults apply only to unset variables (#80) `strconv.ParseBool`, and defaults apply only to unset variables (#80)
- 2026-08-07 Automatic event retention cleanup based on `retention_days`, - 2026-08-07 Automatic event retention cleanup based on
deleting expired events, deliveries, and delivery results from each `retention_days`, deleting expired events, deliveries, and delivery
per-webhook event database (#63) results from each per-webhook event database (#63)
- 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in - 2026-08-07 Update golangci-lint to v2.12.2 (Docker image digest in
`Dockerfile`, release-archive sha256 pins in `script/bootstrap`), adopt the `Dockerfile`, release-archive sha256 pins in `script/bootstrap`),
canonical `.golangci.yml` (v2 `linters.settings` layout so adopt the canonical `.golangci.yml` (v2 `linters.settings` layout so
`lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix all newly `lll`/`funlen`/`cyclop`/`dupl` thresholds actually apply), and fix
surfaced lint findings all newly surfaced lint findings
- 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints, Makefile - 2026-07-07 Adopted scripts-to-rule-them-all: `script/` entrypoints,
shims, README Entrypoints section Makefile shims, README Entrypoints section
- 2026-03-25 pin golangci-lint Docker image for linting (#55) - 2026-03-25 pin golangci-lint Docker image for linting (#55)
- 2026-03-18 CSRF middleware detects TLS per-request, fixing login over plain - 2026-03-18 CSRF middleware detects TLS per-request, fixing login over
HTTP and behind reverse proxies (#54) plain HTTP and behind reverse proxies (#54)
- 2026-03-17 root path redirects based on auth state (#52) - 2026-03-17 root path redirects based on auth state (#52)
- 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets with DNS - 2026-03-17 CSRF protection, SSRF prevention for HTTP delivery targets
rebinding defense, and per-IP login rate limiting (#42) 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 Slack target type for incoming webhook notifications (#47)
- 2026-03-17 Dockerfile absolute paths and static linking (#49); absolute dev - 2026-03-17 Dockerfile absolute paths and static linking (#49);
DATA_DIR default and clarified env docs (#46) absolute dev DATA_DIR default and clarified env docs (#46)
- 2026-03-05 security headers middleware, session regeneration on login, request - 2026-03-05 security headers middleware, session regeneration on
body size limits (#41) login, request body size limits (#41)
- 2026-03-04 tests for delivery, middleware, and session packages (#32); removed - 2026-03-04 tests for delivery, middleware, and session packages
the build-architecture global (#31) (#32); removed globals.Buildarch (#31)
- 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core delivery - 2026-03-04 1.0 MVP merge: Webhook/Entrypoint/Target rename, core
engine with bounded worker pool and circuit breaker, parallel fan-out, delivery engine with bounded worker pool and circuit breaker,
per-webhook event databases, management UI (#16) parallel fan-out, per-webhook event databases, management UI (#16)
- 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded into README - 2026-03-01 repo brought to REPO_POLICIES standards; TODO.md folded
(#6) into README (#6)
# Future Steps # Future Steps
- Delivery status and retry management UI. Replay of a terminally failed - Manual event redelivery from the web UI — the "Replay" capability the
delivery and per-attempt detail already landed README describes as planned. No redelivery code exists anywhere in the
(https://git.eeqj.de/sneak/webhooker/issues/203, tree; events are stored in full, which is all it would be built on
https://git.eeqj.de/sneak/webhooker/issues/202) - Delivery status and retry management UI
- Per-webhook rate limiting in the receiver handler (per-webhook config plus - Per-webhook rate limiting in the receiver handler (per-webhook config
handler enforcement; global limits must not apply to receiver endpoints) plus handler enforcement; global limits must not apply to receiver
- API key authentication for programmatic access (APIKey model exists; Bearer endpoints)
token middleware does not) - 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 - REST API v1
- CRUD for webhooks, entrypoints, targets - CRUD for webhooks, entrypoints, targets
- event viewing and filtering endpoints - event viewing and filtering endpoints
@@ -370,10 +194,9 @@ Clear the rest of the open 1.0.0 milestone
- OpenAPI specification - OpenAPI specification
- Analytics dashboard: success rates, response times, volume - Analytics dashboard: success rates, response times, volume
- A remember-me option at login - A remember-me option at login
- Password reset flow for a forgotten password over the web. The authenticated - Password reset flow for a forgotten password. The authenticated
password _change_ flow already landed, and a lost password is recoverable from password *change* flow already landed on `main` (#65); reset does not
the console with `webhooker resetpw` exist
(https://git.eeqj.de/sneak/webhooker/issues/208)
- Later, nice to have - Later, nice to have
- email delivery target type - email delivery target type
- SNS and S3 delivery targets - SNS and S3 delivery targets
-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())
}
+12 -143
View File
@@ -2,24 +2,17 @@
package main package main
import ( import (
"fmt"
"io"
"os"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"go.uber.org/fx/fxevent"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/handlers" "sneak.berlin/go/webhooker/internal/handlers"
"sneak.berlin/go/webhooker/internal/healthcheck" "sneak.berlin/go/webhooker/internal/healthcheck"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
"sneak.berlin/go/webhooker/internal/metrics"
"sneak.berlin/go/webhooker/internal/middleware" "sneak.berlin/go/webhooker/internal/middleware"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
"sneak.berlin/go/webhooker/internal/session" "sneak.berlin/go/webhooker/internal/session"
) )
@@ -38,31 +31,19 @@ import (
// hook that used the whole budget would exhaust it at that instant, // hook that used the whole budget would exhaust it at that instant,
// and fx would skip every hook after the server — the delivery // and fx would skip every hook after the server — the delivery
// engine, the healthcheck, the webhook DB manager and the database // engine, the healthcheck, the webhook DB manager and the database
// close. That hook is the HTTP drain plus the Sentry flush that // close. That hook is the 3s HTTP drain plus the Sentry flush that
// follows it in the same hook, and each is clamped to the stop // follows it in the same hook, so the flush is clamped to the stop
// context's remaining time less server.TailHookReserve rather than // context's remaining time less server.TailHookReserve rather than
// running for its own fixed 3s and 2s; the reserve is what the tail // running for its own fixed 2s; the reserve is what the tail hooks
// hooks live on, and they are microsecond-scale in normal operation. // live on, and they are microsecond-scale in normal operation.
// TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic // TestStopTimeout_LeavesHeadroomForTailHooks pins the arithmetic
// across every drain length and every amount of budget the hooks // across every drain length.
// before the server may already have spent.
// //
// This does not make the database close unconditional: the // This does not make the database close unconditional: the
// ArchiveSweeper and RetentionReaper hooks run before the server. // ArchiveSweeper and RetentionReaper hooks run before the server
// What they spend comes out of the drain first, but past 3s it comes // and can still consume the whole budget on their own.
// out of the reserve, and they can consume the whole budget.
const stopTimeout = 5 * time.Second 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. // Build-time variables set via -ldflags.
// //
//nolint:gochecknoglobals // Build-time variables injected by the linker. //nolint:gochecknoglobals // Build-time variables injected by the linker.
@@ -75,95 +56,7 @@ func main() {
globals.Appname = appname globals.Appname = appname
globals.Version = version 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() newApp().Run()
return 0
} }
// newApp builds the application graph. It is separate from main so // newApp builds the application graph. It is separate from main so
@@ -171,19 +64,6 @@ func run(stderr io.Writer) int {
func newApp() *fx.App { func newApp() *fx.App {
return fx.New( return fx.New(
fx.StopTimeout(stopTimeout), 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.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -194,28 +74,17 @@ func newApp() *fx.App {
healthcheck.New, healthcheck.New,
session.New, session.New,
handlers.New, handlers.New,
// The registry /metrics serves, and the delivery
// collectors registered on it.
metrics.NewRegistry,
metrics.New,
middleware.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, delivery.New,
delivery.NewArchiveSweeper, delivery.NewArchiveSweeper,
// Wire *delivery.Engine as delivery.Notifier so the // Wire *delivery.Engine as delivery.Notifier so the
// webhook handler can notify the engine of new deliveries. // webhook handler can notify the engine of new deliveries.
func(e *delivery.Engine) delivery.Notifier { return e }, func(e *delivery.Engine) delivery.Notifier { return e },
// Wire *delivery.Engine as delivery.Archives so deleting // Wire *delivery.Engine as delivery.WebhookEvictor so
// or renaming a webhook or target reaches its archive // deleting a webhook releases its archive writer.
// files. func(e *delivery.Engine) delivery.WebhookEvictor {
func(e *delivery.Engine) delivery.Archives { return e }, return e
// 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, server.New,
), ),
fx.Invoke( fx.Invoke(
+9 -225
View File
@@ -1,22 +1,10 @@
package main package main
import ( import (
"bytes"
"encoding/json"
"io"
"log/slog"
"net"
"os"
"strconv"
"strings"
"testing" "testing"
"time" "time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/resetpw"
"sneak.berlin/go/webhooker/internal/server" "sneak.berlin/go/webhooker/internal/server"
) )
@@ -37,7 +25,6 @@ const dockerStopGrace = 10 * time.Second
// fx.New applies options before it executes invokes, so the timeout // fx.New applies options before it executes invokes, so the timeout
// is set whether or not the graph itself can be constructed here. // is set whether or not the graph itself can be constructed here.
func TestNewApp_StopTimeout(t *testing.T) { func TestNewApp_StopTimeout(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("DATA_DIR", t.TempDir()) t.Setenv("DATA_DIR", t.TempDir())
got := newApp().StopTimeout() got := newApp().StopTimeout()
@@ -46,191 +33,6 @@ func TestNewApp_StopTimeout(t *testing.T) {
require.Less(t, got, dockerStopGrace) 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) {
config.ClearEnvForTest(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 // tailHeadroom is the slack the fx stop budget must keep beyond the
// server stop hook. The hooks that run after the server — the // server stop hook. The hooks that run after the server — the
// delivery engine, the healthcheck, the webhook DB manager and the // delivery engine, the healthcheck, the webhook DB manager and the
@@ -252,40 +54,22 @@ const tailHeadroom = 2 * time.Second
// can produce, since a shorter drain leaves the flush more room and // can produce, since a shorter drain leaves the flush more room and
// the worst case is not necessarily at either extreme. // the worst case is not necessarily at either extreme.
// //
// Nor does the hook start on a full budget: the ArchiveSweeper and // Shrinking either budget, or unbounding the flush again, must fail
// RetentionReaper hooks run before it, and whatever they spent is // here rather than silently recreating a hook that swallows the
// gone. The outer sweep walks every amount they can spend. Once they // whole sequence.
// 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) { func TestStopTimeout_LeavesHeadroomForTailHooks(t *testing.T) {
t.Parallel() t.Parallel()
require.Less(t, server.ShutdownTimeout, stopTimeout) 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 const step = 10 * time.Millisecond
for spent := time.Duration(0); spent <= stopTimeout; spent += step { for drain := time.Duration(0); drain <= server.ShutdownTimeout; drain += step {
remaining := stopTimeout - spent hook := drain + server.SentryFlushBudget(stopTimeout-drain)
longest := max(server.DrainBudget(remaining), 0)
for drain := time.Duration(0); drain <= longest; drain += step { require.LessOrEqual(
hook := drain + server.SentryFlushBudget(remaining-drain) t, hook+tailHeadroom, stopTimeout,
"a %s drain leaves the tail hooks short", 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 ( require (
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8 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/getsentry/sentry-go v0.25.0
github.com/go-chi/chi v1.5.5 github.com/go-chi/chi v1.5.5
github.com/go-chi/cors v1.2.1 github.com/go-chi/cors v1.2.1
github.com/go-chi/httprate v0.15.0 github.com/go-chi/httprate v0.15.0
github.com/gofrs/flock v0.13.0
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/gorilla/csrf v1.7.3 github.com/gorilla/csrf v1.7.3
github.com/gorilla/sessions v1.4.0 github.com/gorilla/sessions v1.4.0
github.com/joho/godotenv v1.5.1 github.com/joho/godotenv v1.5.1
github.com/prometheus/client_golang v1.18.0 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/slok/go-http-metrics v0.11.0
github.com/stretchr/testify v1.11.1 github.com/stretchr/testify v1.8.4
go.uber.org/fx v1.24.0 go.uber.org/fx v1.20.1
golang.org/x/crypto v0.38.0 golang.org/x/crypto v0.38.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/sqlite v1.5.4 gorm.io/driver/sqlite v1.5.4
@@ -31,31 +26,30 @@ require (
require ( require (
github.com/beorn7/perks v1.0.1 // indirect github.com/beorn7/perks v1.0.1 // indirect
github.com/cespare/xxhash/v2 v2.2.0 // 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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/go-json-experiment/json v0.0.0-20260820222146-c27c302e5fc3 // indirect github.com/dustin/go-humanize v1.0.1 // 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/gorilla/securecookie v1.1.2 // indirect github.com/gorilla/securecookie v1.1.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // 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-isatty v0.0.20 // indirect
github.com/mattn/go-sqlite3 v1.14.17 // indirect github.com/mattn/go-sqlite3 v1.14.17 // indirect
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0 // 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/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/common v0.45.0 // indirect
github.com/prometheus/procfs v0.12.0 // indirect github.com/prometheus/procfs v0.12.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/zeebo/xxh3 v1.0.2 // indirect github.com/zeebo/xxh3 v1.0.2 // indirect
go.uber.org/dig v1.19.0 // indirect go.uber.org/atomic v1.9.0 // indirect
go.uber.org/multierr v1.10.0 // indirect go.uber.org/dig v1.17.0 // indirect
go.uber.org/zap v1.26.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/mod v0.17.0 // indirect
golang.org/x/sync v0.14.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/text v0.25.0 // indirect
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect
google.golang.org/protobuf v1.31.0 // 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 h1:nMpu1t4amK3vJWBibQ5X/Nv0aXL+b69TQf2uK5PH7Go=
github.com/99designs/basicauth-go v0.0.0-20230316000542-bf6f9cbbf0f8/go.mod h1:3cARGAK9CfW3HoxCy1a0G4TKrdiKke8ftOMEOHyySYs= 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 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= 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 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44=
github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
github.com/chromedp/cdproto v0.0.0-20260922220944-a19bff23514f/go.mod h1:3v4FIp5njIUyPDvqXsxEOxnB34lijG0up98/5kM1KaE= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/chromedp/chromedp v0.16.0 h1:rOO4deOm4CbZgBCa8mD9g2rDyIoNs0BkgvNrlbp5ouk= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
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/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= 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/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= 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-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 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
github.com/go-errors/errors v1.4.2/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og= 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/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.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 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/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 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= 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 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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 h1:mCRHCLDUBXgpKAqIKsaAaAsrAlbkeomtRFKXh2L6YIM=
github.com/mattn/go-sqlite3 v1.14.17/go.mod h1:2eHXhiwb8IkHr+BDWZGa96P6+rkvnG63S2DGjv9HUNg= 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 h1:jWpvCLoY8Z/e3VKvlsiIGKtc+UG6U5vzxaoagmhXfyg=
github.com/matttproud/golang_protobuf_extensions/v2 v2.0.0/go.mod h1:QUyp042oQthUoa9bqDv0ER0wrtXnBruoNd7aNjkbP+k= 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 h1:lFuQV/oaUMGcD2tqt+01ROSmJs75VG1ToEOkZIZ4nE4=
github.com/pingcap/errors v0.11.4/go.mod h1:Oi8TUi2kEtXXLMJk9l1cGmz20kV3TaQ0usTwv5KuLY8= 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 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 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 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 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= 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/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 h1:ABJUpekCZSkQT1wQrFvS4kGbhea/w6ndFJaWJeh3zL0=
github.com/slok/go-http-metrics v0.11.0/go.mod h1:ZGKeYG1ET6TEJpQx18BqAJAvxw9jBAZXCHU7bWQqqAc= 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.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/objx v0.5.1 h1:4VhoImhV/Bm0ToFkXFi8hXNXwpDRZ/ynw3amt82mzq0=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/objx v0.5.1/go.mod h1:/iHQpkQwBD6DLUmQ4pE+s1TXdob1mORJ4/UFdrifcy0=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= 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 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ=
github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= 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 h1:xZmwmqxHZA8AI603jOQ0tMqmBr9lPeFwGg6d+xy9DC0=
github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA= github.com/zeebo/xxh3 v1.0.2/go.mod h1:5NWz9Sef7zIDm2JHfFlcQvNekmcEl9ekUZQQKCYaDcA=
go.uber.org/dig v1.19.0 h1:BACLhebsYdpQ7IROQ1AGPjrXcP5dF80U3gKoFzbaq/4= go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
go.uber.org/dig v1.19.0/go.mod h1:Us0rSJiThwCv2GteUN0Q7OKvU7n5J4dxZ9JKUXozFdE= go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
go.uber.org/fx v1.24.0 h1:wE8mruvpg2kiiL1Vqd0CC+tr0/24XIB10Iwp2lLWzkg= go.uber.org/dig v1.17.0 h1:5Chju+tUvcC+N7N6EV08BJz41UZuO3BmHcN4A287ZLI=
go.uber.org/fx v1.24.0/go.mod h1:AmDeGyS+ZARGKM4tlH4FY2Jr63VjbEDJHtqXTGP5hbo= go.uber.org/dig v1.17.0/go.mod h1:rTxpf7l5I0eBTlE6/9RL+lDybC7WFwY2QH55ZSjy1mU=
go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk= go.uber.org/fx v1.20.1 h1:zVwVQGS8zYvhh9Xxcu4w1M6ESyeMzebzj2NbSayZ4Mk=
go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo= go.uber.org/fx v1.20.1/go.mod h1:iSYNbHf2y55acNCwCXKx7LbWb5WG1Bnue5RDXz1OREg=
go.uber.org/multierr v1.10.0 h1:S0h4aNzvfcFsC3dRF1jLoaov7oRaKqRGC/pUEJ2yvPQ= go.uber.org/goleak v1.1.11 h1:wy28qYRKZgnJTxGxvye5/wgWr1EKjmUDGYox5mGlRlI=
go.uber.org/multierr v1.10.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/goleak v1.1.11/go.mod h1:cwTWslyiVhfpKIDGSZEM2HlOvcqm+tG4zioyIeLoqMQ=
go.uber.org/zap v1.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo= go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so= 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 h1:jt+WWG8IZlBnVbomuhg2Mdq0+BBQaHbtqHEFEigjUV8=
golang.org/x/crypto v0.38.0/go.mod h1:MvrbAqul58NNYPKnOra203SB9vpuZW0e+RRZV+Ggqjw= 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= 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 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 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.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= 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 h1:qVyWApTSYLk/drJRO5mDlNYskwQznZmkpV2c8q9zls4=
golang.org/x/text v0.25.0/go.mod h1:WEdwpYrmk1qmdHvhkSTNPm3app7v4rsT8F2UD6+VHIA= 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= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg=
-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)
}
+90 -392
View File
@@ -4,7 +4,6 @@ package config
import ( import (
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"net/netip" "net/netip"
"os" "os"
@@ -12,11 +11,13 @@ import (
"strings" "strings"
"time" "time"
"github.com/getsentry/sentry-go"
"github.com/joho/godotenv"
"go.uber.org/fx" "go.uber.org/fx"
"sneak.berlin/go/webhooker/internal/globals" "sneak.berlin/go/webhooker/internal/globals"
"sneak.berlin/go/webhooker/internal/logger" "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 ( const (
@@ -25,41 +26,9 @@ const (
// EnvironmentProd represents production environment. // EnvironmentProd represents production environment.
EnvironmentProd = "prod" 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 is the default HTTP listen port.
defaultPort = 8080 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 // defaultRetentionSweepInterval is how often the retention
// reaper deletes events older than each webhook's RetentionDays. // reaper deletes events older than each webhook's RetentionDays.
defaultRetentionSweepInterval = time.Hour defaultRetentionSweepInterval = time.Hour
@@ -75,24 +44,14 @@ const (
// internet-exposed endpoint. // internet-exposed endpoint.
defaultReceiverRateLimit = 120 defaultReceiverRateLimit = 120
// defaultTrustedProxies is TRUSTED_PROXIES when it is unset: the // maxPort is the highest valid TCP port number. The lower
// RFC 1918 private ranges, which a reverse proxy reaching the // bound (at least 1) is enforced by envPositiveInt.
// 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 maxPort = 65535
// mappedV4Offset is the number of leading bits an IPv4-mapped // mappedV4Offset is the number of leading bits an IPv4-mapped
// IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104 // IPv6 prefix spends on the ::ffff:0:0/96 wrapper, so a /104
// covers the same addresses as an IPv4 /8. // covers the same addresses as an IPv4 /8.
mappedV4Offset = 96 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 // ErrInvalidEnvironment is returned when WEBHOOKER_ENVIRONMENT
@@ -104,7 +63,7 @@ var ErrInvalidEnvironment = errors.New("invalid environment")
var ErrNonPositiveValue = errors.New("value must be positive") var ErrNonPositiveValue = errors.New("value must be positive")
// ErrInvalidPort is returned when an environment variable holding a // ErrInvalidPort is returned when an environment variable holding a
// TCP port number is set to a number outside 1 to 65535. // TCP port number is set above the valid port range.
var ErrInvalidPort = errors.New("invalid port") var ErrInvalidPort = errors.New("invalid port")
// ErrInvalidCIDR is returned when an environment variable holding a // ErrInvalidCIDR is returned when an environment variable holding a
@@ -112,29 +71,6 @@ var ErrInvalidPort = errors.New("invalid port")
// nor a bare IP address. // nor a bare IP address.
var ErrInvalidCIDR = errors.New("invalid CIDR") 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")
// 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. //nolint:revive // ConfigParams is a standard fx naming convention.
type ConfigParams struct { type ConfigParams struct {
fx.In fx.In
@@ -148,19 +84,13 @@ type ConfigParams struct {
type Config struct { type Config struct {
DataDir string DataDir string
Debug bool Debug bool
MaintenanceMode bool
Environment string Environment string
MetricsPassword string MetricsPassword string
MetricsUsername string MetricsUsername string
Port int Port int
SentryDSN string 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. // RetentionSweepInterval is how often the retention reaper runs.
// Always positive: it becomes a time.NewTicker period. // Always positive: it becomes a time.NewTicker period.
RetentionSweepInterval time.Duration RetentionSweepInterval time.Duration
@@ -175,34 +105,15 @@ type Config struct {
// TrustedProxies is the set of networks whose members are // TrustedProxies is the set of networks whose members are
// allowed to speak for the client with X-Forwarded-For, the // allowed to speak for the client with X-Forwarded-For, the
// only forwarded header read. Unless TRUSTED_PROXIES is set it // only forwarded header read. It is empty unless
// is the RFC 1918 private ranges (defaultTrustedProxies); a set // TRUSTED_PROXIES is set, and empty means no peer is
// value replaces them. If any client can reach the process, or // trusted: forwarded headers are then ignored entirely and
// the proxy in front of it, from an RFC 1918 source address // clients are identified by the connection's own address.
// (directly, or through anything that can rewrite source // Members can choose their own rate-limit key, so this must
// addresses, such as NAT or a published container port), it // name proxy hosts only, never a block that also covers
// must be set to the proxy's address alone, or every rate limit // clients.
// can be bypassed by those clients.
TrustedProxies []netip.Prefix 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 params *ConfigParams
log *slog.Logger log *slog.Logger
} }
@@ -217,90 +128,12 @@ func (c *Config) IsProd() bool {
return c.Environment == EnvironmentProd 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, // envString returns the value of the named environment variable,
// or an empty string if not set. // or an empty string if not set.
func envString(key string) string { func envString(key string) string {
return os.Getenv(key) 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 // envBool returns the value of the named environment variable
// parsed as a boolean. Returns defaultValue if not set. If the // parsed as a boolean. Returns defaultValue if not set. If the
// variable is set but cannot be parsed, it returns a wrapped error // variable is set but cannot be parsed, it returns a wrapped error
@@ -362,27 +195,17 @@ func envPositiveInt(
// envPort returns the value of the named environment variable parsed // envPort returns the value of the named environment variable parsed
// as a TCP port number. Returns defaultValue if not set. A set value // 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 // 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 // naming the key and the bad value.
// ErrInvalidPort, including one too large or too small for an int.
func envPort(key string, defaultValue int) (int, error) { func envPort(key string, defaultValue int) (int, error) {
v := os.Getenv(key) port, err := envPositiveInt(key, defaultValue)
if v == "" { if err != nil {
return defaultValue, nil return 0, err
} }
// strconv.ErrRange means a number too large or too small for an if port > maxPort {
// 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( return 0, fmt.Errorf(
"invalid integer for %s: %q: %w", key, v, err, "%w: %s must be at most %d, got %d",
) ErrInvalidPort, key, maxPort, port,
}
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,
) )
} }
@@ -475,15 +298,14 @@ func parseCIDR(entry string) (netip.Prefix, error) {
// envPrefixList returns the value of the named environment variable // envPrefixList returns the value of the named environment variable
// parsed as a comma-separated list of CIDR blocks (bare addresses // parsed as a comma-separated list of CIDR blocks (bare addresses
// allowed). An unset, empty, or blank value is read as defaultValue // allowed). An unset, empty, or blank value yields an empty list. A
// instead. A set value containing an unparseable entry is a hard // set value containing an unparseable entry is a hard error naming
// error naming the key and the bad entry, so startup fails loudly // the key and the bad entry, so startup fails loudly rather than
// rather than silently running with a list the operator did not // silently running with a list the operator did not intend.
// intend. func envPrefixList(key string) ([]netip.Prefix, error) {
func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
v := strings.TrimSpace(os.Getenv(key)) v := strings.TrimSpace(os.Getenv(key))
if v == "" { if v == "" {
v = defaultValue return nil, nil
} }
var prefixes []netip.Prefix var prefixes []netip.Prefix
@@ -507,109 +329,12 @@ func envPrefixList(key, defaultValue string) ([]netip.Prefix, error) {
return prefixes, nil return prefixes, nil
} }
// envBindAddress returns the value of the named environment variable // resolveEnvironment reads WEBHOOKER_ENVIRONMENT, defaulting to
// parsed as an IP address literal. An unset (or empty, or // dev, and rejects unrecognised values.
// 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) { func resolveEnvironment() (string, error) {
environment := os.Getenv("WEBHOOKER_ENVIRONMENT") environment := os.Getenv("WEBHOOKER_ENVIRONMENT")
if environment == "" { if environment == "" {
environment = EnvironmentProd environment = EnvironmentDev
} }
if environment != EnvironmentDev && if environment != EnvironmentDev &&
@@ -624,27 +349,6 @@ func resolveEnvironment() (string, error) {
return environment, nil return environment, 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 // loadFromEnv builds a Config from the environment. Every value that
// needs parsing fails loudly when it is set but unparseable: the // needs parsing fails loudly when it is set but unparseable: the
// documented defaults apply only to variables that are unset (or // documented defaults apply only to variables that are unset (or
@@ -656,7 +360,7 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
port, bindAddress, err := resolveListener() port, err := envPort("PORT", defaultPort)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -666,6 +370,11 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
maintenanceMode, err := envBool("MAINTENANCE_MODE", false)
if err != nil {
return nil, err
}
retentionSweepInterval, err := envPositiveDuration( retentionSweepInterval, err := envPositiveDuration(
"RETENTION_SWEEP_INTERVAL", "RETENTION_SWEEP_INTERVAL",
defaultRetentionSweepInterval, defaultRetentionSweepInterval,
@@ -692,81 +401,66 @@ func loadFromEnv() (*Config, error) {
return nil, err return nil, err
} }
trustedProxies, err := envPrefixList("TRUSTED_PROXIES", defaultTrustedProxies) trustedProxies, err := envPrefixList("TRUSTED_PROXIES")
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 { if err != nil {
return nil, err return nil, err
} }
return &Config{ return &Config{
DataDir: DataDir(), DataDir: envString("DATA_DIR"),
Debug: debug, Debug: debug,
MaintenanceMode: maintenanceMode,
Environment: environment, Environment: environment,
MetricsUsername: metricsUsername, MetricsUsername: envString("METRICS_USERNAME"),
MetricsPassword: metricsPassword, MetricsPassword: envString("METRICS_PASSWORD"),
Port: port, Port: port,
BindAddress: bindAddress, SentryDSN: envString("SENTRY_DSN"),
SentryDSN: sentryDSN,
RetentionSweepInterval: retentionSweepInterval, RetentionSweepInterval: retentionSweepInterval,
SessionIdleTimeout: sessionIdleTimeout, SessionIdleTimeout: sessionIdleTimeout,
ReceiverRateLimit: receiverRateLimit, ReceiverRateLimit: receiverRateLimit,
TrustedProxies: trustedProxies, TrustedProxies: trustedProxies,
AllowedEgressCIDRs: allowedEgressCIDRs,
}, nil }, nil
} }
// PrefixStrings renders a prefix list as its CIDR strings, for // warnSharedRateLimitBucket logs a startup warning whenever
// logging a list an operator has to be able to read back. // TRUSTED_PROXIES is empty, in any environment.
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 // With no trusted proxies every rate limiter keys on the connecting
// this is the one setting that lets a delivery target reach the // peer's address. Whether that is harmless or dangerous depends on
// host's own network: an operator reading the startup log has to // what is in front of the process, which this code cannot observe:
// be able to see exactly which hole is open. Silence means the // with nothing in front, the peer is the client and the limits are
// list is empty and the SSRF guard is refusing every // per-client as intended; behind a reverse proxy the peer is the proxy
// private/reserved range, which is the default. // for every request, so all clients share one bucket per limiter. The
func (c *Config) warnEgressAllowlist(log *slog.Logger) { // login limiter's bucket is the dangerous one: any remote client can
if len(c.AllowedEgressCIDRs) == 0 { // keep it full, which denies the only administrative login to everyone
// until the process restarts.
//
// The warning is deliberately not gated on WEBHOOKER_ENVIRONMENT. That
// variable defaults to dev, so gating on it would silence the warning
// for exactly the operator who forgot to configure the deployment —
// the case it exists to catch.
//
// The default of trusting nobody is deliberate — trusting forwarded
// headers from arbitrary peers lets any client choose its own bucket —
// so this warns rather than failing startup or changing the key.
func (c *Config) warnSharedRateLimitBucket(log *slog.Logger) {
if len(c.TrustedProxies) > 0 {
return return
} }
log.Warn( log.Warn(
"ALLOWED_EGRESS_CIDRS lets delivery targets reach these "+ "TRUSTED_PROXIES is empty: every rate limit keys on the "+
"otherwise-blocked networks. Anyone who can create a "+ "connecting peer's address. With nothing proxying to "+
"delivery target can now make this process issue "+ "this process that is the client itself and the limits "+
"requests into them, and read back the response. Only "+ "are per-client as intended. Behind a reverse proxy the "+
"the addresses the README lists as blocked "+ "peer is the proxy on every request, so all clients "+
"unconditionally stay blocked regardless of what is "+ "share one bucket per limit and any remote client can "+
"listed here; a public cloud metadata address such as "+ "keep the login limit full, denying the admin login — "+
"168.63.129.16 is reachable once it, or a block "+ "the only administrative path — until restart. If "+
"covering it, is listed.", "anything proxies to this process, set TRUSTED_PROXIES "+
"allowedEgressCIDRs", "to its address.",
strings.Join(PrefixStrings(c.AllowedEgressCIDRs), ","), "environment", c.Environment,
"trustedProxies", len(c.TrustedProxies),
) )
} }
@@ -787,6 +481,14 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
s.log = log s.log = log
s.params = &params s.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 { if s.Debug {
params.Logger.EnableDebugLogging() params.Logger.EnableDebugLogging()
} }
@@ -795,12 +497,8 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
log.Info("Configuration loaded", log.Info("Configuration loaded",
"environment", s.Environment, "environment", s.Environment,
"port", s.Port, "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, "debug", s.Debug,
"maintenanceMode", s.MaintenanceMode,
"dataDir", s.DataDir, "dataDir", s.DataDir,
"retentionSweepInterval", s.RetentionSweepInterval.String(), "retentionSweepInterval", s.RetentionSweepInterval.String(),
// Logged because a perfectly valid non-positive value here // Logged because a perfectly valid non-positive value here
@@ -809,12 +507,12 @@ func New(lc fx.Lifecycle, params ConfigParams) (*Config, error) {
"sessionIdleTimeout", s.SessionIdleTimeout.String(), "sessionIdleTimeout", s.SessionIdleTimeout.String(),
"receiverRateLimit", s.ReceiverRateLimit, "receiverRateLimit", s.ReceiverRateLimit,
"trustedProxies", len(s.TrustedProxies), "trustedProxies", len(s.TrustedProxies),
"allowedEgressCIDRs", len(s.AllowedEgressCIDRs), "hasSentryDSN", s.SentryDSN != "",
"sentryEnabled", s.SentryEnabled(), "hasMetricsAuth",
"hasMetricsAuth", s.MetricsAuthEnabled(), s.MetricsUsername != "" && s.MetricsPassword != "",
) )
s.warnEgressAllowlist(log) s.warnSharedRateLimitBucket(log)
return s, nil return s, nil
} }
+87 -370
View File
@@ -3,6 +3,7 @@ package config_test
import ( import (
"bytes" "bytes"
"log/slog" "log/slog"
"os"
"testing" "testing"
"time" "time"
@@ -25,12 +26,6 @@ const (
// cidrPrivateV4 is the sample trusted-proxy block the // cidrPrivateV4 is the sample trusted-proxy block the
// TRUSTED_PROXIES cases are built from. // TRUSTED_PROXIES cases are built from.
cidrPrivateV4 = "10.0.0.0/8" cidrPrivateV4 = "10.0.0.0/8"
// metricsAuthValue is the sample METRICS_PASSWORD the metrics
// credential cases are built from. It is asserted absent from
// the startup error, so it must not be a substring of either
// variable name that error prints.
metricsAuthValue = "s3cret"
) )
func TestEnvironmentConfig(t *testing.T) { func TestEnvironmentConfig(t *testing.T) {
@@ -43,9 +38,9 @@ func TestEnvironmentConfig(t *testing.T) {
isProd bool isProd bool
}{ }{
{ {
name: "default is prod", name: "default is dev",
isDev: false, isDev: true,
isProd: true, isProd: false,
}, },
{ {
name: "explicit dev", name: "explicit dev",
@@ -70,12 +65,14 @@ func TestEnvironmentConfig(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.envValue != "" { if tt.envValue != "" {
t.Setenv( t.Setenv(
"WEBHOOKER_ENVIRONMENT", tt.envValue, "WEBHOOKER_ENVIRONMENT", tt.envValue,
) )
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
for k, v := range tt.envVars { for k, v := range tt.envVars {
@@ -121,11 +118,6 @@ func testEnvironmentConfigSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
// fx's own log is discarded, not sent to t.Logf: a hook still
// running after a start or stop timeout would write there after
// the test has returned. The same holds for every fxtest.New
// below.
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -196,11 +188,14 @@ func TestRetentionSweepInterval(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value) t.Setenv("RETENTION_SWEEP_INTERVAL", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RETENTION_SWEEP_INTERVAL",
))
} }
if tt.expectError { if tt.expectError {
@@ -271,7 +266,6 @@ func testRetentionSweepIntervalSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -335,11 +329,14 @@ func TestSessionIdleTimeout(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("SESSION_IDLE_TIMEOUT", tt.value) t.Setenv("SESSION_IDLE_TIMEOUT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"SESSION_IDLE_TIMEOUT",
))
} }
if tt.expectError { if tt.expectError {
@@ -361,7 +358,6 @@ func testSessionIdleTimeoutSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -388,17 +384,20 @@ func TestDefaultDataDir(t *testing.T) {
t.Run("env="+name, func(t *testing.T) { t.Run("env="+name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if env != "" { if env != "" {
t.Setenv("WEBHOOKER_ENVIRONMENT", env) t.Setenv("WEBHOOKER_ENVIRONMENT", env)
} else {
require.NoError(t, os.Unsetenv(
"WEBHOOKER_ENVIRONMENT",
))
} }
require.NoError(t, os.Unsetenv("DATA_DIR"))
var cfg *config.Config var cfg *config.Config
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -419,36 +418,6 @@ func TestDefaultDataDir(t *testing.T) {
} }
} }
// TestDataDirHelper pins the exported resolver against the value
// Config carries. The entry point takes the DATA_DIR lock through the
// helper before the fx graph exists, so the two disagreeing would mean
// locking one directory and writing to another.
func TestDataDirHelper(t *testing.T) {
for _, set := range []string{"", "/tmp/webhooker-datadir-helper"} {
name := "set"
if set == "" {
name = "unset"
}
t.Run(name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if set != "" {
t.Setenv("DATA_DIR", set)
}
expected := set
if expected == "" {
expected = config.DefaultDataDir
}
assert.Equal(t, expected, config.DataDir())
})
}
}
func TestReceiverRateLimit(t *testing.T) { func TestReceiverRateLimit(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
@@ -498,11 +467,14 @@ func TestReceiverRateLimit(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("RECEIVER_RATE_LIMIT", tt.value) t.Setenv("RECEIVER_RATE_LIMIT", tt.value)
} else {
require.NoError(t, os.Unsetenv(
"RECEIVER_RATE_LIMIT",
))
} }
if tt.expectError { if tt.expectError {
@@ -526,7 +498,6 @@ func testReceiverRateLimitSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -544,11 +515,6 @@ func testReceiverRateLimitSuccess(
} }
func TestTrustedProxies(t *testing.T) { func TestTrustedProxies(t *testing.T) {
// Unset, the RFC 1918 private ranges are trusted, so a reverse
// proxy on a Docker network or a private LAN is covered without
// configuration.
defaultProxies := []string{cidrPrivateV4, "172.16.0.0/12", "192.168.0.0/16"}
tests := []struct { tests := []struct {
name string name string
set bool set bool
@@ -557,21 +523,18 @@ func TestTrustedProxies(t *testing.T) {
expected []string expected []string
}{ }{
{ {
// The default must be "trust nobody": an empty list
// means forwarded headers are ignored, never that
// every peer may speak for the client.
name: caseUnsetUsesDefault, name: caseUnsetUsesDefault,
set: false, set: false,
expected: defaultProxies, expected: []string{},
}, },
{ {
name: "blank value uses default", name: "blank value trusts nothing",
set: true, set: true,
value: " ", value: " ",
expected: defaultProxies, expected: []string{},
},
{
name: "set value replaces the default entirely",
set: true,
value: "203.0.113.7",
expected: []string{"203.0.113.7/32"},
}, },
{ {
name: caseValidValueParsed, name: caseValidValueParsed,
@@ -614,11 +577,12 @@ func TestTrustedProxies(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set { if tt.set {
t.Setenv("TRUSTED_PROXIES", tt.value) t.Setenv("TRUSTED_PROXIES", tt.value)
} else {
require.NoError(t, os.Unsetenv("TRUSTED_PROXIES"))
} }
if tt.expectError { if tt.expectError {
@@ -642,7 +606,6 @@ func testTrustedProxiesSuccess(
app := fxtest.New( app := fxtest.New(
t, t,
fx.NopLogger,
fx.Provide( fx.Provide(
globals.New, globals.New,
logger.New, logger.New,
@@ -664,144 +627,60 @@ func testTrustedProxiesSuccess(
assert.Equal(t, expected, got) assert.Equal(t, expected, got)
} }
// TestAllowedEgressCIDRs covers ALLOWED_EGRESS_CIDRS, the escape // TestSharedRateLimitBucketWarning covers the startup warning that
// hatch that lets a self-hosted deployment forward to its own // tells an operator a deployment behind a reverse proxy shares one
// network. Unset it must stay empty, so the SSRF guard keeps // rate-limit bucket between every client, which makes the admin login
// refusing every private/reserved range; a set-but-unparseable // remotely deniable. It must fire whenever TRUSTED_PROXIES is empty,
// value must abort startup naming the variable rather than // in any environment: WEBHOOKER_ENVIRONMENT defaults to dev, so gating
// silently running with a list the operator did not write. // on it would silence the warning for exactly the operator who never
func TestAllowedEgressCIDRs(t *testing.T) { // configured the deployment. It stays quiet once proxies are named.
func TestSharedRateLimitBucketWarning(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
set bool environment string
value string trustedProxies string
expected []string expectWarning bool
expectError bool
}{ }{
{ {
name: caseUnsetUsesDefault, name: "prod without trusted proxies warns",
set: false, environment: config.EnvironmentProd,
expected: []string{},
},
{
name: "empty value yields empty list",
set: true,
value: "",
expected: []string{},
},
{
name: caseValidValueParsed,
set: true,
value: cidrPrivateV4,
expected: []string{cidrPrivateV4},
},
{
name: "multiple blocks with whitespace",
set: true,
value: " 10.0.0.0/8 , 127.0.0.0/8 ",
expected: []string{cidrPrivateV4, "127.0.0.0/8"},
},
{
name: "bare address becomes a single host",
set: true,
value: "172.17.0.5",
expected: []string{"172.17.0.5/32"},
},
{
name: caseUnparseableFails,
set: true,
value: cidrPrivateV4 + ",not-an-address",
expectError: true,
},
{
name: "out-of-range prefix length fails startup",
set: true,
value: "10.0.0.0/33",
expectError: true,
},
}
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.
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
if tt.set {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.value)
}
if tt.expectError {
expectStartupErrorFor(
t, "ALLOWED_EGRESS_CIDRS", config.ErrInvalidCIDR,
)
} else {
testAllowedEgressCIDRsSuccess(t, tt.expected)
}
})
}
}
func testAllowedEgressCIDRsSuccess(
t *testing.T,
expected []string,
) {
t.Helper()
var cfg *config.Config
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(
globals.New,
logger.New,
config.New,
),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(
t, expected, config.PrefixStrings(cfg.AllowedEgressCIDRs),
)
}
// TestEgressAllowlistWarning covers the startup log that shows an
// operator the hole ALLOWED_EGRESS_CIDRS opened. It must stay
// silent on the default (empty) list and, when set, print the
// blocks themselves rather than a count.
func TestEgressAllowlistWarning(t *testing.T) {
tests := []struct {
name string
allowed string
expectWarning bool
}{
{
name: "empty allowlist is quiet",
expectWarning: false,
},
{
name: "non-empty allowlist warns",
allowed: "10.0.0.0/8,127.0.0.0/8",
expectWarning: true, expectWarning: true,
}, },
{
name: "prod with trusted proxies is quiet",
environment: config.EnvironmentProd,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
{
// The default environment. An internet-exposed
// deployment whose operator never set
// WEBHOOKER_ENVIRONMENT lands here and has exactly
// the exposure the warning announces.
name: "dev without trusted proxies warns",
environment: config.EnvironmentDev,
expectWarning: true,
},
{
name: "dev with trusted proxies is quiet",
environment: config.EnvironmentDev,
trustedProxies: cidrPrivateV4,
expectWarning: false,
},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t) t.Setenv("WEBHOOKER_ENVIRONMENT", tt.environment)
t.Setenv("WEBHOOKER_ENVIRONMENT", config.EnvironmentDev)
if tt.allowed != "" { if tt.trustedProxies == "" {
t.Setenv("ALLOWED_EGRESS_CIDRS", tt.allowed) require.NoError(
t, os.Unsetenv("TRUSTED_PROXIES"),
)
} else {
t.Setenv("TRUSTED_PROXIES", tt.trustedProxies)
} }
var buf bytes.Buffer var buf bytes.Buffer
@@ -813,7 +692,8 @@ func TestEgressAllowlistWarning(t *testing.T) {
)) ))
require.NoError( require.NoError(
t, config.WarnEgressAllowlistForTest(log), t,
config.WarnSharedRateLimitBucketForTest(log),
) )
if !tt.expectWarning { if !tt.expectWarning {
@@ -825,178 +705,15 @@ func TestEgressAllowlistWarning(t *testing.T) {
logged := buf.String() logged := buf.String()
assert.Contains(t, logged, `"level":"WARN"`) assert.Contains(t, logged, `"level":"WARN"`)
assert.Contains(t, logged, "ALLOWED_EGRESS_CIDRS") assert.Contains(t, logged, "TRUSTED_PROXIES")
// The blocks themselves, not a count: the operator has assert.Contains(t, logged, "share one bucket")
// to be able to read back which networks are open. assert.Contains(t, logged, "denying the admin login")
assert.Contains(t, logged, "10.0.0.0/8") // The text must stay accurate for a developer with
assert.Contains(t, logged, "127.0.0.0/8") // nothing in front of the process, where an empty
// What stays shut is the whole unconditional set, not // list costs nothing.
// link-local alone; a public metadata address is not in assert.Contains(
// it, so a listed block covering it opens it. t, logged, "nothing proxying to this process",
assert.Contains(t, logged, "blocked unconditionally") )
assert.Contains(t, logged, "168.63.129.16 is reachable")
// The listed blocks need not be private or reserved.
assert.NotContains(t, logged, "private/reserved")
}) })
} }
} }
// metricsEnv describes what one subtest below puts in the
// environment for a single METRICS_ variable. A variable that is
// set to the empty string and one that is not set at all are
// distinct inputs here, because the reported bug arrived through
// the first of them.
type metricsEnv struct {
set bool
value string
}
// unset leaves the variable out of the environment entirely.
func unset() metricsEnv {
return metricsEnv{set: false, value: ""}
}
// setTo sets the variable, including to the empty string.
func setTo(value string) metricsEnv {
return metricsEnv{set: true, value: value}
}
// metricsAuthCase is one row of the table in TestMetricsAuthConfig,
// named so the table can live in its own function and keep the test
// itself short.
type metricsAuthCase struct {
name string
username metricsEnv
password metricsEnv
expectError bool
expectAuth bool
}
// metricsAuthCases enumerates every combination of the two
// credentials, counting "set to the empty string" and "not set at
// all" as separate inputs on each side.
func metricsAuthCases() []metricsAuthCase {
return []metricsAuthCase{
{
name: "both unset leaves metrics unmounted",
username: unset(),
password: unset(),
},
{
name: "both empty leaves metrics unmounted",
username: setTo(""),
password: setTo(""),
},
{
name: "both set enables metrics auth",
username: setTo("metrics"),
password: setTo(metricsAuthValue),
expectAuth: true,
},
{
name: "username with unset password fails",
username: setTo("metrics"),
password: unset(),
expectError: true,
},
{
name: "username with empty password fails",
username: setTo("metrics"),
password: setTo(""),
expectError: true,
},
{
name: "password with unset username fails",
username: unset(),
password: setTo(metricsAuthValue),
expectError: true,
},
{
name: "password with empty username fails",
username: setTo(""),
password: setTo(metricsAuthValue),
expectError: true,
},
}
}
// TestMetricsAuthConfig covers every combination of METRICS_USERNAME
// and METRICS_PASSWORD. Either both carry a value, in which case
// /metrics is served behind basic auth, or neither does, in which
// case the route is never mounted. One without the other is a
// startup error rather than a fallback: mounting on the username
// alone published /metrics behind a credential map that accepted an
// empty password, which is the defect this test exists to pin. See
// https://git.eeqj.de/sneak/webhooker/issues/205.
func TestMetricsAuthConfig(t *testing.T) {
for _, tt := range metricsAuthCases() {
t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.username.set {
t.Setenv("METRICS_USERNAME", tt.username.value)
}
if tt.password.set {
t.Setenv("METRICS_PASSWORD", tt.password.value)
}
if tt.expectError {
assertMetricsAuthRejected(t)
return
}
assertMetricsAuthAccepted(t, tt.expectAuth)
})
}
}
// assertMetricsAuthRejected requires that fx refused to build the
// graph, that the failure is ErrIncompleteMetricsAuth, and that the
// operator is told both variable names — the point of failing here
// rather than degrading is that the message says what to fix.
func assertMetricsAuthRejected(t *testing.T) {
t.Helper()
var cfg *config.Config
app := fx.New(
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
err := app.Err()
require.Error(t, err)
require.ErrorIs(t, err, config.ErrIncompleteMetricsAuth)
assert.Contains(t, err.Error(), "METRICS_USERNAME")
assert.Contains(t, err.Error(), "METRICS_PASSWORD")
// The password is a secret and must not reach a startup error.
assert.NotContains(t, err.Error(), metricsAuthValue)
}
// assertMetricsAuthAccepted requires that startup succeeded and that
// MetricsAuthEnabled — the single value the /metrics mount and the
// startup log both read — reports what the environment asked for.
func assertMetricsAuthAccepted(t *testing.T, expectAuth bool) {
t.Helper()
var cfg *config.Config
app := fxtest.New(
t,
fx.NopLogger,
fx.Provide(globals.New, logger.New, config.New),
fx.Populate(&cfg),
)
require.NoError(t, app.Err())
app.RequireStart()
defer app.RequireStop()
assert.Equal(t, expectAuth, cfg.MetricsAuthEnabled())
}
-147
View File
@@ -1,147 +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"
)
// 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 // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MissingFileIsFine(t *testing.T) {
config.ClearEnvForTest(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 // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_AppliesValues(t *testing.T) {
config.ClearEnvForTest(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 // ClearEnvForTest uses t.Setenv.
func TestLoadDotEnv_MalformedFileAborts(t *testing.T) {
config.ClearEnvForTest(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) {
config.ClearEnvForTest(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))
}
+106 -363
View File
@@ -1,6 +1,7 @@
package config_test package config_test
import ( import (
"os"
"testing" "testing"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
@@ -17,24 +18,9 @@ const testEnvKey = "WEBHOOKER_TEST_VALUE"
// Real configuration variables exercised by the config.New tests. // Real configuration variables exercised by the config.New tests.
const ( const (
envKeyPort = "PORT" envKeyPort = "PORT"
envKeyDebug = "DEBUG" envKeyDebug = "DEBUG"
envKeyBindAddress = "BIND_ADDRESS" envKeyMaintenanceMode = "MAINTENANCE_MODE"
)
// 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. // envBoolCase is one row of the envBool table.
@@ -120,10 +106,10 @@ func TestEnvBool(t *testing.T) {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBoolForTest( got, err := config.EnvBoolForTest(
@@ -144,62 +130,17 @@ func TestEnvBool(t *testing.T) {
} }
} }
// 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.
config.ClearEnvForTest(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) { func TestEnvPositiveInt(t *testing.T) {
const defaultValue = 7 const defaultValue = 7
runEnvIntCases(t, config.EnvPositiveIntForTest, defaultValue, []envIntCase{ tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{ {
name: "unset returns the default integer", name: "unset returns the default integer",
expected: defaultValue, expected: defaultValue,
@@ -236,14 +177,51 @@ func TestEnvPositiveInt(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrNonPositiveValue, errIs: config.ErrNonPositiveValue,
}, },
}) }
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.
if tt.set {
t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
}
got, err := config.EnvPositiveIntForTest(
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 TestEnvPort(t *testing.T) { func TestEnvPort(t *testing.T) {
const defaultValue = 8080 const defaultValue = 8080
runEnvIntCases(t, config.EnvPortForTest, defaultValue, []envIntCase{ tests := []struct {
name string
set bool
value string
expectError bool
errIs error
expected int
}{
{ {
name: "unset returns the default port", name: "unset returns the default port",
expected: defaultValue, expected: defaultValue,
@@ -271,14 +249,7 @@ func TestEnvPort(t *testing.T) {
set: true, set: true,
value: "0", value: "0",
expectError: true, expectError: true,
errIs: config.ErrInvalidPort, errIs: config.ErrNonPositiveValue,
},
{
name: "negative is rejected",
set: true,
value: "-1",
expectError: true,
errIs: config.ErrInvalidPort,
}, },
{ {
name: "above the port range is rejected", name: "above the port range is rejected",
@@ -287,44 +258,29 @@ func TestEnvPort(t *testing.T) {
expectError: true, expectError: true,
errIs: config.ErrInvalidPort, 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. for _, tt := range tests {
//
// 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) { t.Run(tt.name, func(t *testing.T) {
// Cannot use t.Parallel() here because t.Setenv // Cannot use t.Parallel() here because t.Setenv
// is incompatible with parallel subtests. // is incompatible with parallel subtests.
config.ClearEnvForTest(t)
if tt.set { if tt.set {
t.Setenv(testEnvKey, tt.value) t.Setenv(testEnvKey, tt.value)
} else {
require.NoError(t, os.Unsetenv(testEnvKey))
} }
got, err := config.EnvBindAddressForTest( got, err := config.EnvPortForTest(
testEnvKey, bindAddressDefault, testEnvKey, defaultValue,
) )
if tt.expectError { if tt.expectError {
require.Error(t, err) require.Error(t, err)
require.ErrorIs(t, err, config.ErrInvalidBindAddress)
assert.Contains(t, err.Error(), testEnvKey) assert.Contains(t, err.Error(), testEnvKey)
assert.Contains(t, err.Error(), tt.value)
if tt.errIs != nil {
require.ErrorIs(t, err, tt.errIs)
}
return return
} }
@@ -335,122 +291,6 @@ func TestEnvBindAddress(t *testing.T) {
} }
} }
// 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 // buildConfig constructs a Config through fx exactly as the
// application does, returning the config and any construction error. // application does, returning the config and any construction error.
func buildConfig(t *testing.T) (*config.Config, error) { func buildConfig(t *testing.T) (*config.Config, error) {
@@ -472,58 +312,13 @@ func buildConfig(t *testing.T) (*config.Config, error) {
} }
func TestNewRejectsBadEnvValues(t *testing.T) { func TestNewRejectsBadEnvValues(t *testing.T) {
for _, tt := range badEnvValueCases() { tests := []struct {
t.Run(tt.name, func(t *testing.T) { name string
// Cannot use t.Parallel() here because t.Setenv key string
// is incompatible with parallel subtests. value string
config.ClearEnvForTest(t) expectError bool
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") check func(t *testing.T, cfg *config.Config)
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", name: "valid PORT is used",
key: envKeyPort, key: envKeyPort,
@@ -545,41 +340,6 @@ func listenerEnvValueCases() []badEnvValueCase {
value: "70000", value: "70000",
expectError: true, 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", name: "valid DEBUG is used",
key: envKeyDebug, key: envKeyDebug,
@@ -595,42 +355,35 @@ func flagEnvValueCases() []badEnvValueCase {
value: "ture", value: "ture",
expectError: true, expectError: true,
}, },
{
name: "unparseable MAINTENANCE_MODE aborts startup",
key: envKeyMaintenanceMode,
value: "sometimes",
expectError: true,
},
} }
}
// sentryEnvValueCases covers SENTRY_DSN. The three rejected values are for _, tt := range tests {
// the ones measured on the defect: each initialised the SDK with an t.Run(tt.name, func(t *testing.T) {
// error and left the process serving with error reporting off. // Cannot use t.Parallel() here because t.Setenv
func sentryEnvValueCases() []badEnvValueCase { // is incompatible with parallel subtests.
return []badEnvValueCase{ t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
{ t.Setenv(tt.key, tt.value)
name: "valid SENTRY_DSN is used",
key: envKeySentryDSN, cfg, err := buildConfig(t)
value: validSentryDSN,
check: func(t *testing.T, cfg *config.Config) { if tt.expectError {
t.Helper() require.Error(t, err)
assert.Equal(t, validSentryDSN, cfg.SentryDSN) assert.Contains(t, err.Error(), tt.key)
assert.True(t, cfg.SentryEnabled()) assert.Contains(t, err.Error(), tt.value)
},
}, return
{ }
name: "unparseable SENTRY_DSN aborts startup",
key: envKeySentryDSN, require.NoError(t, err)
value: "not-a-dsn", require.NotNil(t, cfg)
expectError: true, tt.check(t, cfg)
}, })
{
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,
},
} }
} }
@@ -638,29 +391,19 @@ func sentryEnvValueCases() []badEnvValueCase {
// break the legitimate unset case: absent variables still get their // break the legitimate unset case: absent variables still get their
// documented defaults. // documented defaults.
func TestNewUsesDefaultsWhenUnset(t *testing.T) { func TestNewUsesDefaultsWhenUnset(t *testing.T) {
config.ClearEnvForTest(t)
t.Setenv("WEBHOOKER_ENVIRONMENT", "dev") t.Setenv("WEBHOOKER_ENVIRONMENT", "dev")
for _, key := range []string{
envKeyPort, envKeyDebug, envKeyMaintenanceMode,
} {
require.NoError(t, os.Unsetenv(key))
}
cfg, err := buildConfig(t) cfg, err := buildConfig(t)
require.NoError(t, err) require.NoError(t, err)
require.NotNil(t, cfg) require.NotNil(t, cfg)
assert.Equal(t, 8080, cfg.Port) assert.Equal(t, 8080, cfg.Port)
assert.False(t, cfg.Debug) assert.False(t, cfg.Debug)
assert.False(t, cfg.MaintenanceMode)
// 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())
} }
+6 -28
View File
@@ -6,17 +6,17 @@ import "log/slog"
// the external config_test package so each helper can be covered by // the external config_test package so each helper can be covered by
// its own table-driven test without weakening the package API. // its own table-driven test without weakening the package API.
// WarnEgressAllowlistForTest loads a Config from the current // WarnSharedRateLimitBucketForTest loads a Config from the current
// environment and emits its egress-allowlist startup warning to // environment and emits its startup warnings to log. The real logger
// log, so a test can assert both that the warning fires only when // writes to stdout, so this lets the warning's firing condition be
// the list is non-empty and that it names the blocks it opened. // asserted against a handler the test controls.
func WarnEgressAllowlistForTest(log *slog.Logger) error { func WarnSharedRateLimitBucketForTest(log *slog.Logger) error {
c, err := loadFromEnv() c, err := loadFromEnv()
if err != nil { if err != nil {
return err return err
} }
c.warnEgressAllowlist(log) c.warnSharedRateLimitBucket(log)
return nil return nil
} }
@@ -35,25 +35,3 @@ func EnvPositiveIntForTest(key string, defaultValue int) (int, error) {
func EnvPortForTest(key string, defaultValue int) (int, error) { func EnvPortForTest(key string, defaultValue int) (int, error) {
return envPort(key, defaultValue) 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
-140
View File
@@ -1,140 +0,0 @@
package config_test
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// 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.
config.ClearEnvForTest(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(),
)
}
-50
View File
@@ -1,50 +0,0 @@
package config
import (
"os"
"strings"
"testing"
)
// ClearEnvForTest 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 ClearEnvForTest(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)
}
}
})
}
-36
View File
@@ -1,36 +0,0 @@
package config_test
import (
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/config"
)
// TestClearEnvForTest_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 // ClearEnvForTest uses t.Setenv.
func TestClearEnvForTest_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.
config.ClearEnvForTest(t)
t.Run("loads a .env file after the clear", func(t *testing.T) {
config.ClearEnvForTest(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",
)
}
-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,
)
}
+25 -141
View File
@@ -4,10 +4,10 @@ package database
import ( import (
"context" "context"
"crypto/rand" "crypto/rand"
"database/sql"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"os" "os"
"path/filepath" "path/filepath"
@@ -15,32 +15,17 @@ import (
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "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/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
const ( const (
dataDirPerm = 0750
randomPasswordLen = 16 randomPasswordLen = 16
sessionKeyLen = 32 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. //nolint:revive // DatabaseParams is a standard fx naming convention.
type DatabaseParams struct { type DatabaseParams struct {
fx.In fx.In
@@ -54,39 +39,6 @@ type Database struct {
db *gorm.DB db *gorm.DB
log *slog.Logger log *slog.Logger
params *DatabaseParams 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. // New creates a Database that connects on fx start and disconnects on stop.
@@ -169,25 +121,11 @@ func (d *Database) GetOrCreateSessionKey() (string, error) {
return encoded, nil 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 { 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. // Ensure the data directory exists before opening the database.
// datadir.DirPerm is the single source of the directory mode; this dataDir := d.params.Config.DataDir
// package creates the directory too, since either may run first.
err := os.MkdirAll(dataDir, datadir.DirPerm) err := os.MkdirAll(dataDir, dataDirPerm)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"creating data directory %s: %w", "creating data directory %s: %w",
@@ -197,59 +135,41 @@ func (d *Database) connectTo(dataDir string) error {
} }
// Construct the main application database path inside DATA_DIR. // 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 // Open the database with the pure Go SQLite driver
// is unexpectedly empty -- its volume not mounted, say -- looks sqlDB, err := sql.Open("sqlite", dbURL)
// 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)
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to open database", "failed to open database",
"path", dbPath,
"error", err, "error", err,
) )
return err return err
} }
// Then use it with GORM. Its errors are SQLite's alone and name no // Then use it with GORM
// file, so the path is added to them here.
db, err := gorm.Open(sqlite.Dialector{ db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB, Conn: sqlDB,
}, &gorm.Config{ }, &gorm.Config{})
// Never leave this at GORM's default. See internal/gormlog.
Logger: gormlog.New(d.log),
})
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to connect to database", "failed to connect to database",
"path", dbPath,
"error", err, "error", err,
) )
return fmt.Errorf("connecting to %s: %w", dbPath, err) return err
} }
d.db = db d.db = db
d.log.Info("connected to database", "path", dbPath)
if created { // Run migrations
d.log.Warn("created a new, empty database", "path", dbPath) return d.migrate()
} else {
d.log.Info("connected to database", "path", dbPath)
}
err = d.migrate()
if err != nil {
return fmt.Errorf("migrating %s: %w", dbPath, err)
}
return nil
} }
func (d *Database) migrate() error { func (d *Database) migrate() error {
@@ -266,16 +186,10 @@ func (d *Database) migrate() error {
d.log.Info("database migrations completed") d.log.Info("database migrations completed")
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 // Check if admin user exists
var userCount int64 var userCount int64
err := d.db.Model(&User{}).Count(&userCount).Error err = d.db.Model(&User{}).Count(&userCount).Error
if err != nil { if err != nil {
d.log.Error( d.log.Error(
"failed to count users", "failed to count users",
@@ -335,46 +249,16 @@ func (d *Database) createAdminUser() error {
return err return err
} }
// The plaintext leaves this process here and nowhere else. It is d.log.Info("admin user created",
// deliberately not a log field: as one INFO record among the fx "username", "admin",
// graph's own output it read as one more startup line, which is "password", password,
// how deployments lost it. See internal/banner. "message",
err = banner.Credentials( "SAVE THIS PASSWORD - it will not be shown again!",
d.banner(),
"WEBHOOKER FIRST BOOT: an admin account has been created.",
adminUser.Username,
password,
BootstrapPasswordNote,
) )
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 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 { func (d *Database) close() error {
if d.db != nil { if d.db != nil {
sqlDB, err := d.db.DB() sqlDB, err := d.db.DB()
-25
View File
@@ -1,15 +1,9 @@
package database_test package database_test
import ( import (
"bytes"
"context" "context"
"log/slog"
"os"
"path/filepath"
"testing" "testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.uber.org/fx/fxtest" "go.uber.org/fx/fxtest"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
@@ -106,22 +100,3 @@ func TestDatabaseConnection(t *testing.T) {
) )
} }
} }
// TestOpen_UnreadableDatabaseIsNamed pins
// https://git.eeqj.de/sneak/webhooker/issues/459: when SQLite cannot
// read webhooker.db, the error that stops the server and `webhooker
// resetpw` names the file, not only SQLite's own message.
func TestOpen_UnreadableDatabaseIsNamed(t *testing.T) {
t.Parallel()
dir := t.TempDir()
path := filepath.Join(dir, database.MainDBFileName)
require.NoError(t, os.WriteFile(
path, bytes.Repeat([]byte("junk"), 1024), database.SQLiteFilePerm,
))
_, err := database.Open(dir, slog.New(slog.DiscardHandler))
require.Error(t, err)
assert.Contains(t, err.Error(), path)
assert.Contains(t, err.Error(), "file is not a database")
}
-159
View File
@@ -1,159 +0,0 @@
package database
import (
"fmt"
"log/slog"
"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,
// copying targets.config, which holds destination URLs and bearer
// credentials, into the file most likely to be backed up or handed
// to someone else. 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
}
// eventDBSweptVersion is the PRAGMA user_version purgeTargetRows
// stamps into a per-webhook database once it has removed any leaked
// target rows *and* the VACUUM that removes their bytes has returned.
// Nothing else in the tree uses user_version, so 0 means "not swept
// by this build".
//
// The stamp, not the DELETE, is what records that a file is done. A
// DELETE commits on its own, so a sweep that is interrupted or whose
// VACUUM fails leaves a file whose rows are gone but whose credential
// bytes are still in the free pages -- indistinguishable, by row
// count, from a file that never leaked. Both leave the stamp unset,
// so the next open sweeps again.
const eventDBSweptVersion = 1
// purgeTargetRows deletes target rows that an earlier build's
// association upsert wrote into a per-webhook database, and rewrites
// the file so their bytes are gone with them. AutoMigrate creates a
// targets table in every one of these files because Delivery declares
// a belongs-to Target, but nothing in the event tier may put rows in
// it. The rows it did put there are junk, not history: they carry an
// empty webhook_id, and delivery rows resolve their target against
// the main database, so nothing here refers to them.
//
// The DELETE only unlinks the rows: modernc.org/sqlite leaves
// secure_delete at SQLite's default of off, so the credential bytes
// stay readable in the file's free pages and a backup of a swept file
// would still hand them over. VACUUM rewrites the file without them.
//
// This runs before every migration and is gated on
// eventDBSweptVersion, so a file pays for the rewrite once, on the
// first open that finds it unstamped, and every open after that is a
// PRAGMA read. A file this build created is stamped before its
// targets table exists, so it never vacuums at all. A failure here
// fails the open with the stamp left unset, so the sweep is retried
// rather than skipped -- a webhook whose file cannot be swept stays
// unusable instead of quietly serving from a file that still holds
// recoverable credentials.
func purgeTargetRows(
db *gorm.DB, log *slog.Logger, webhookID string,
) error {
var version int
// Row().Scan, not (*gorm.DB).Scan: see internal/gormlog.
err := db.Raw("PRAGMA user_version").Row().Scan(&version)
if err != nil {
return fmt.Errorf(
"reading sweep marker of webhook database %s: %w",
webhookID, err,
)
}
if version >= eventDBSweptVersion {
return nil
}
var purged int64
if db.Migrator().HasTable("targets") {
res := db.Exec("DELETE FROM targets")
if res.Error != nil {
return fmt.Errorf(
"purging target rows from webhook database %s: %w",
webhookID, res.Error,
)
}
purged = res.RowsAffected
// Unconditional: a zero row count here does not mean there is
// nothing to remove, only that no *live* row is left. See
// eventDBSweptVersion.
err = db.Exec("VACUUM").Error
if err != nil {
return fmt.Errorf(
"purged %d leaked target rows from webhook database "+
"%s but vacuuming it failed, so the deleted "+
"target credentials are still recoverable from "+
"the file; it stays marked unswept and the next "+
"open retries: %w",
purged, webhookID, err,
)
}
}
err = db.Exec(fmt.Sprintf(
"PRAGMA user_version = %d", eventDBSweptVersion,
)).Error
if err != nil {
return fmt.Errorf(
"marking webhook database %s swept: %w", webhookID, err,
)
}
if purged > 0 {
log.Warn(
"purged leaked target rows from per-webhook database",
"webhook_id", webhookID,
"rows", purged,
)
}
return nil
}
@@ -1,438 +0,0 @@
package database_test
import (
"bytes"
"database/sql"
"fmt"
"os"
"path/filepath"
"testing"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
_ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database"
)
// testDataDirPerm is the mode the test data directory is created
// with.
const testDataDirPerm = 0o750
// eventDBDataDir returns a data directory that a WebhookDBManager
// can be pointed at.
func eventDBDataDir(t *testing.T) string {
t.Helper()
dir := filepath.Join(t.TempDir(), "events")
require.NoError(t, os.MkdirAll(dir, testDataDirPerm))
return dir
}
// openRawEventDB opens the per-webhook database file directly,
// without the manager, so a test can put a file on disk in a state
// the manager has to cope with, or inspect one afterwards.
func openRawEventDB(
t *testing.T, dataDir, webhookID string,
) *sql.DB {
t.Helper()
path := filepath.Join(
dataDir, fmt.Sprintf("events-%s.db", webhookID),
)
sqlDB, err := sql.Open(
"sqlite",
fmt.Sprintf("file:%s?mode=rwc", path),
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
return sqlDB
}
// eventDBFileBytes reads a per-webhook database file off disk, so a
// test can assert on what the file itself still holds rather than on
// what a query returns.
func eventDBFileBytes(t *testing.T, dataDir, webhookID string) []byte {
t.Helper()
//nolint:gosec // reads a file the test just created under t.TempDir()
raw, err := os.ReadFile(filepath.Join(
dataDir, fmt.Sprintf("events-%s.db", webhookID),
))
require.NoError(t, err)
return raw
}
// eventDBUserVersion returns the PRAGMA user_version of a per-webhook
// database file, which is the marker purgeTargetRows stamps once it
// has swept and vacuumed.
func eventDBUserVersion(t *testing.T, sqlDB *sql.DB) int {
t.Helper()
var version int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(), "PRAGMA user_version",
).Scan(&version))
return version
}
// clearEventDBSweptMarker resets the sweep marker to 0, which is what
// a file written by a build without the sweep looks like. Tests that
// seed a leaked row have to create the file through the manager to
// get the real targets table shape, and that stamps it.
func clearEventDBSweptMarker(t *testing.T, sqlDB *sql.DB) {
t.Helper()
_, err := sqlDB.ExecContext(t.Context(), "PRAGMA user_version = 0")
require.NoError(t, err)
}
// countTargetRows returns the number of rows in the targets table of
// a per-webhook database file, or -1 if the table does not exist.
func countTargetRows(t *testing.T, sqlDB *sql.DB) int {
t.Helper()
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 -1
}
var rows int
require.NoError(t, sqlDB.QueryRowContext(
t.Context(), "SELECT count(*) FROM targets",
).Scan(&rows))
return rows
}
// TestOpenPurgesLeakedTargetRows covers the sweep for event
// databases written by a build that let GORM upsert target rows
// into them: opening the database clears them, and opening it again
// is a no-op.
func TestOpenPurgesLeakedTargetRows(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
// Create the file the way the application does, so the targets
// table has exactly the shape AutoMigrate gives it, then write
// a leaked row into it the way the association upsert did.
initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
`{"webhookUrl":"https://hooks.example/T000/B000/secret"}`,
)
require.NoError(t, err)
require.Equal(t, 1, countTargetRows(t, seed))
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, check))
assert.Equal(
t, 1, eventDBUserVersion(t, check),
"a completed sweep must mark the file so later opens skip it",
)
require.NoError(t, check.Close())
// Idempotent: a second open leaves it at zero and does not
// error.
again := database.NewTestWebhookDBManager(dataDir)
_, err = again.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, again.CloseAll())
recheck := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, recheck))
}
// TestOpenPurgeRemovesCredentialBytes covers the sweep at the level
// that matters for a backup handed to someone else: the leaked
// credential must be gone from the raw bytes of the file, not merely
// unreachable by query. A bare DELETE unlinks the row and leaves the
// bytes readable in the free pages, so this fails without the VACUUM
// in purgeTargetRows.
func TestOpenPurgeRemovesCredentialBytes(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String()
initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
fmt.Sprintf(
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
),
)
require.NoError(t, err)
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
// The seed has to be in the file for its absence later to mean
// anything.
require.True(
t,
bytes.Contains(
eventDBFileBytes(t, dataDir, webhookID),
[]byte(credential),
),
"seeded credential is not in the file, so this test proves nothing",
)
mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
assert.NotContains(
t,
string(eventDBFileBytes(t, dataDir, webhookID)),
credential,
"leaked credential is still recoverable from the raw file",
)
}
// TestOpenRevacuumsAfterIncompleteSweep covers the case a row count
// cannot see: the rows are already deleted but the file was never
// vacuumed, because an earlier sweep died between the two or its
// VACUUM failed. The credential bytes are still recoverable, and the
// unset marker is the only thing that says so, so the next open must
// vacuum rather than conclude from the empty table that there is
// nothing to do.
func TestOpenRevacuumsAfterIncompleteSweep(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
credential := "T00000000/B00000000/" + uuid.New().String()
initial := database.NewTestWebhookDBManager(dataDir)
_, err := initial.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, initial.CloseAll())
seed := openRawEventDB(t, dataDir, webhookID)
_, err = seed.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(),
"leaked-target",
"slack",
fmt.Sprintf(
`{"webhookUrl":"https://hooks.example/%s"}`, credential,
),
)
require.NoError(t, err)
// Exactly the state an interrupted sweep leaves: rows gone,
// marker unset, bytes still in the free pages.
_, err = seed.ExecContext(t.Context(), "DELETE FROM targets")
require.NoError(t, err)
require.Zero(t, countTargetRows(t, seed))
clearEventDBSweptMarker(t, seed)
require.NoError(t, seed.Close())
require.True(
t,
bytes.Contains(
eventDBFileBytes(t, dataDir, webhookID),
[]byte(credential),
),
"the deleted row's bytes must still be in the file, or this "+
"test proves nothing",
)
mgr := database.NewTestWebhookDBManager(dataDir)
_, err = mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
assert.NotContains(
t,
string(eventDBFileBytes(t, dataDir, webhookID)),
credential,
"an interrupted sweep was not retried, so the credential is "+
"still recoverable from the raw file",
)
check := openRawEventDB(t, dataDir, webhookID)
assert.Equal(t, 1, eventDBUserVersion(t, check))
}
// TestOpenSkipsSweptDatabase covers the other half of the marker: a
// file this build created is marked without ever being vacuumed, and
// a marked file is not swept again.
func TestOpenSkipsSweptDatabase(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
mgr := database.NewTestWebhookDBManager(dataDir)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
marked := openRawEventDB(t, dataDir, webhookID)
assert.Equal(t, 1, eventDBUserVersion(t, marked))
// A marked file is left alone, so a row written into it survives
// a reopen. Nothing writes target rows any more; this stands in
// for the sweep having run.
_, err = marked.ExecContext(
t.Context(),
"INSERT INTO targets "+
"(id, webhook_id, name, type, config) "+
"VALUES (?, '', ?, ?, ?)",
uuid.New().String(), "sentinel", "slack", `{}`,
)
require.NoError(t, err)
require.NoError(t, marked.Close())
again := database.NewTestWebhookDBManager(dataDir)
_, err = again.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, again.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Equal(
t, 1, countTargetRows(t, check),
"a marked file must not be swept again",
)
}
// TestOpenSucceedsWithoutTargetsTable covers an existing event
// database that never grew a targets table. The sweep must not fail
// startup on it.
func TestOpenSucceedsWithoutTargetsTable(t *testing.T) {
t.Parallel()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
seed := openRawEventDB(t, dataDir, webhookID)
_, err := seed.ExecContext(
t.Context(),
"CREATE TABLE events (id text PRIMARY KEY)",
)
require.NoError(t, err)
require.NoError(t, seed.Close())
mgr := database.NewTestWebhookDBManager(dataDir)
db, err := mgr.GetDB(webhookID)
require.NoError(t, err)
assert.NotNil(t, db)
require.NoError(t, mgr.CloseAll())
}
// 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()
dataDir := eventDBDataDir(t)
webhookID := uuid.New().String()
mgr := database.NewTestWebhookDBManager(dataDir)
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"}`,
}
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)
require.NoError(t, mgr.CloseAll())
check := openRawEventDB(t, dataDir, webhookID)
assert.Zero(t, countTargetRows(t, check))
}
@@ -1,348 +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 opening a
// per-webhook database that predates these indexes creates them. It
// stands in for an older database file by dropping the indexes
// AutoMigrate just created, then reopening the same file.
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)
// A fresh database has them.
for _, ix := range indexes {
require.True(t, db.Migrator().HasIndex(ix.model, ix.name))
}
// Stand in for a database file created before the indexes existed.
for _, ix := range indexes {
require.NoError(t, db.Migrator().DropIndex(ix.model, ix.name))
require.False(t, db.Migrator().HasIndex(ix.model, ix.name))
}
// Drop the cached connection so the next open reopens the file and
// runs AutoMigrate against it, as a restart would.
require.NoError(t, mgr.CloseAll())
db, err = mgr.GetDB(webhookID)
require.NoError(t, err)
for _, ix := range indexes {
assert.True(t, db.Migrator().HasIndex(ix.model, ix.name),
"opening the existing database should create %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: recovery and the retry sweep, the sweep for
// stranded pending deliveries, 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())
}
}
-30
View File
@@ -2,10 +2,8 @@ package database
import ( import (
"context" "context"
"io"
"log/slog" "log/slog"
"os" "os"
"testing"
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
@@ -29,10 +27,6 @@ func NewTestRetentionReaper(
} }
} }
// ExportReapBatchSize exposes how many expired events one retention
// transaction deletes.
const ExportReapBatchSize = reapBatchSize
// ExportSweep runs a single retention sweep synchronously for tests. // ExportSweep runs a single retention sweep synchronously for tests.
func (r *RetentionReaper) ExportSweep(ctx context.Context) { func (r *RetentionReaper) ExportSweep(ctx context.Context) {
r.sweep(ctx) r.sweep(ctx)
@@ -71,27 +65,3 @@ func (r *RetentionReaper) ExportWedgeLoop(
func (r *RetentionReaper) ExportSetInterval(d time.Duration) { func (r *RetentionReaper) ExportSetInterval(d time.Duration) {
r.interval = d 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" import "time"
// APIKey represents an API key for a user. // 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.
type APIKey struct { type APIKey struct {
BaseModel BaseModel
UserID string `gorm:"type:uuid;not null" json:"userId"` 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"` Description string `json:"description"`
LastUsedAt *time.Time `json:"lastUsedAt,omitempty"` LastUsedAt *time.Time `json:"lastUsedAt,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations
// User.APIKeys leads back here, and the JSON could loop. User User `json:"user,omitzero"`
User User `json:"-"`
} }
+6 -49
View File
@@ -1,11 +1,5 @@
package database package database
import (
"time"
"gorm.io/gorm"
)
// DeliveryStatus represents the status of a delivery // DeliveryStatus represents the status of a delivery
type DeliveryStatus string type DeliveryStatus string
@@ -17,53 +11,16 @@ const (
DeliveryStatusRetrying DeliveryStatus = "retrying" 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 // Delivery represents a delivery attempt for an event to a target
//
//nolint:lll // a struct tag cannot wrap
type Delivery struct { type Delivery struct {
BaseModel BaseModel
EventID string `gorm:"type:uuid;not null;index:idx_deliveries_event_id,priority:1" json:"eventId"` EventID string `gorm:"type:uuid;not null" json:"eventId"`
TargetID string `gorm:"type:uuid;not null;index:idx_deliveries_status,priority:4" json:"targetId"` TargetID string `gorm:"type:uuid;not null" json:"targetId"`
Status DeliveryStatus `gorm:"not null;default:'pending';index:idx_deliveries_status,priority:1" json:"status"` Status DeliveryStatus `gorm:"not null;default:'pending'" json:"status"`
// DeletedAt repeats the BaseModel field only to be the second column // Relations
// of the event_id and status indexes, for the reason DeliveryResult Event Event `json:"event,omitzero"`
// gives. Target Target `json:"target,omitzero"`
DeletedAt gorm.DeletedAt `gorm:"index:idx_deliveries_event_id,priority:2;index:idx_deliveries_status,priority:2" json:"deletedAt,omitzero"`
// FinishedAt is when the delivery became delivered or failed, and
// nil while it is pending or retrying. It and then TargetID end the
// status index, so the webhook page counts each target's deliveries
// that finished in a recent window by reading just that window from
// the index.
FinishedAt *time.Time `gorm:"index:idx_deliveries_status,priority:3" json:"finishedAt,omitempty"`
// 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:"-"`
DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"` DeliveryResults []DeliveryResult `json:"deliveryResults,omitempty"`
} }
+3 -15
View File
@@ -1,21 +1,10 @@
package database package database
import "gorm.io/gorm"
// DeliveryResult represents the result of a delivery attempt // DeliveryResult represents the result of a delivery attempt
//
//nolint:lll // a struct tag cannot wrap
type DeliveryResult struct { type DeliveryResult struct {
BaseModel BaseModel
// DeliveryID and DeletedAt make up one index, in that order. DeliveryID string `gorm:"type:uuid;not null" json:"deliveryId"`
// 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"`
AttemptNum int `gorm:"not null" json:"attemptNum"` AttemptNum int `gorm:"not null" json:"attemptNum"`
Success bool `json:"success"` Success bool `json:"success"`
StatusCode int `json:"statusCode,omitempty"` StatusCode int `json:"statusCode,omitempty"`
@@ -23,7 +12,6 @@ type DeliveryResult struct {
Error string `json:"error,omitempty"` Error string `json:"error,omitempty"`
Duration int64 `json:"durationMs"` // Duration in milliseconds Duration int64 `json:"durationMs"` // Duration in milliseconds
// Relations. No model marshals the record it belongs to: // Relations
// Delivery.DeliveryResults leads back here, and the JSON could loop. Delivery Delivery `json:"delivery,omitzero"`
Delivery Delivery `json:"-"`
} }
+3 -7
View File
@@ -6,16 +6,12 @@ type Entrypoint struct {
WebhookID string `gorm:"type:uuid;not null" json:"webhookId"` WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
// Path is the URL path for this entrypoint. It is the // Path is the URL path for this entrypoint.
// 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"` Path string `gorm:"uniqueIndex;not null" json:"path"`
Description string `json:"description"` 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: // Relations
// Webhook.Entrypoints leads back here, and the JSON could loop. Webhook Webhook `json:"webhook,omitzero"`
Webhook Webhook `json:"-"`
} }
+5 -42
View File
@@ -1,34 +1,11 @@
package database package database
import (
"time"
"gorm.io/gorm"
)
// Event represents a captured webhook event // Event represents a captured webhook event
//
//nolint:lll // a struct tag cannot wrap
type Event struct { type Event struct {
BaseModel BaseModel
// CreatedAt and DeletedAt repeat the BaseModel fields only to index WebhookID string `gorm:"type:uuid;not null" json:"webhookId"`
// them for retention, which finds events by age. Its lookups carry EntrypointID string `gorm:"type:uuid;not null" json:"entrypointId"`
// 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;index:idx_events_entrypoint_id,priority:1" json:"entrypointId"`
// Request data // Request data
Method string `gorm:"not null" json:"method"` Method string `gorm:"not null" json:"method"`
@@ -36,22 +13,8 @@ type Event struct {
Body string `gorm:"type:text" json:"body"` Body string `gorm:"type:text" json:"body"`
ContentType string `json:"contentType"` ContentType string `json:"contentType"`
// BodyBytes is the size of Body in bytes, recorded when the event // Relations
// is stored, so that the recent events list, which reads only the Webhook Webhook `json:"webhook,omitzero"`
// start of each body, knows the whole body's size. Entrypoint Entrypoint `json:"entrypoint,omitzero"`
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, which is every event created before the column
// existed. It is not a foreign key: the source event can be
// reaped by retention while its copies remain, and the id is
// kept as the record of where the copy came from either way.
ResubmittedFromID *string `gorm:"type:uuid;index: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:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
-126
View File
@@ -1,126 +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,
}},
}
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)
})
}
}
-107
View File
@@ -1,107 +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 + `"}`,
},
},
{
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 + `"}`,
}},
})
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. // Setting stores application-level key-value configuration.
// Used for auto-generated values like the session encryption key. // Used for auto-generated values like the session encryption key.
type Setting struct { type Setting struct {
Key string `gorm:"primaryKey" json:"key"` Key string `gorm:"primaryKey" json:"key"`
Value string `gorm:"type:text;not null" json:"value"`
// Value holds the session encryption key, so it is never
// marshalled with the model.
Value string `gorm:"type:text;not null" json:"-"`
} }
+6 -12
View File
@@ -20,21 +20,15 @@ type Target struct {
Type TargetType `gorm:"not null" json:"type"` Type TargetType `gorm:"not null" json:"type"`
Active bool `gorm:"default:true" json:"active"` Active bool `gorm:"default:true" json:"active"`
// Configuration fields (JSON stored based on type). // Configuration fields (JSON stored based on type)
// Config string `gorm:"type:text" json:"config"` // JSON configuration
// 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
// For HTTP targets (max_retries=0 means fire-and-forget, // For HTTP targets (max_retries=0 means fire-and-forget,
// >0 enables retries with backoff) // >0 enables retries with backoff)
MaxRetries int `json:"maxRetries,omitempty"` MaxRetries int `json:"maxRetries,omitempty"`
MaxQueueSize int `json:"maxQueueSize,omitempty"`
// Relations. No model marshals the record it belongs to: // Relations
// Webhook.Targets leads back here, and the JSON could loop. Webhook Webhook `json:"webhook,omitzero"`
Webhook Webhook `json:"-"`
Deliveries []Delivery `json:"deliveries,omitempty"` Deliveries []Delivery `json:"deliveries,omitempty"`
} }
-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 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 // User represents a user of the webhooker service
//
//nolint:lll // a struct tag cannot wrap
type User struct { type User struct {
BaseModel BaseModel
Username string `gorm:"uniqueIndex;not null;check:length(CAST(username AS BLOB)) <= 1024" json:"username"` Username string `gorm:"uniqueIndex;not null" json:"username"`
Password string `gorm:"not null" json:"-"` // Argon2 hashed Password string `gorm:"not null" json:"-"` // Argon2 hashed
// Relations // Relations
Webhooks []Webhook `json:"webhooks,omitempty"` Webhooks []Webhook `json:"webhooks,omitempty"`
APIKeys []APIKey `json:"apiKeys,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")
}
+2 -10
View File
@@ -66,9 +66,8 @@ type Webhook struct {
// must equal DefaultRetentionDays. // must equal DefaultRetentionDays.
RetentionDays int `gorm:"default:30" json:"retentionDays"` RetentionDays int `gorm:"default:30" json:"retentionDays"`
// Relations. No model marshals the record it belongs to: // Relations
// User.Webhooks leads back here, and the JSON could loop. User User `json:"user,omitzero"`
User User `json:"-"`
Entrypoints []Entrypoint `json:"entrypoints,omitempty"` Entrypoints []Entrypoint `json:"entrypoints,omitempty"`
Targets []Target `json:"targets,omitempty"` Targets []Target `json:"targets,omitempty"`
} }
@@ -111,13 +110,6 @@ func (w *Webhook) RetainsForever() bool {
return retainsForever(w.RetentionDays) 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 // RetentionLabel returns the webhook's retention policy as display
// text, so that no template has to know about the sentinel value. // text, so that no template has to know about the sentinel value.
func (w *Webhook) RetentionLabel() string { func (w *Webhook) RetentionLabel() string {
+1 -2
View File
@@ -2,8 +2,7 @@ package database
// Migrate runs database migrations for the main application database. // Migrate runs database migrations for the main application database.
// Only configuration-tier models are stored in the main database. // Only configuration-tier models are stored in the main database.
// Event-tier models (Event, Delivery, DeliveryResult, EventTotals, // Event-tier models (Event, Delivery, DeliveryResult) live in
// TargetTotals, EntrypointTotals) live in
// per-webhook dedicated databases managed by WebhookDBManager. // per-webhook dedicated databases managed by WebhookDBManager.
func (d *Database) Migrate() error { func (d *Database) Migrate() error {
return d.db.AutoMigrate( return d.db.AutoMigrate(
+1 -59
View File
@@ -8,8 +8,6 @@ import (
"fmt" "fmt"
"math/big" "math/big"
"strings" "strings"
"sync"
"testing"
"golang.org/x/crypto/argon2" "golang.org/x/crypto/argon2"
) )
@@ -31,10 +29,6 @@ const hashParts = 6
// triggers per-character-class complexity enforcement. // triggers per-character-class complexity enforcement.
const minPasswordComplexityLen = 4 const minPasswordComplexityLen = 4
// dummyPasswordLen is the length of the throwaway password behind
// dummyPasswordHash.
const dummyPasswordLen = 32
// Sentinel errors returned by decodeHash. // Sentinel errors returned by decodeHash.
var ( var (
errInvalidHashFormat = errors.New("invalid hash format") errInvalidHashFormat = errors.New("invalid hash format")
@@ -64,30 +58,10 @@ func DefaultPasswordConfig() *PasswordConfig {
} }
} }
// testArgon2Memory is the Argon2id memory cost, in KiB, that a test // HashPassword generates an Argon2id hash of the password
// binary hashes with: 1 MB instead of the shipped 64 MB. Every test
// that starts a database hashes the bootstrap admin password, dozens
// of them run in parallel, and under the race detector each 64 MB hash
// holds about 150 MB. VerifyPassword reads the cost from the hash it
// checks, so verification follows.
const testArgon2Memory = 1024
// hashAtShippedCostInTest makes a test binary hash at the shipped
// memory cost. Only TestHashPassword_ShippedParameters sets it.
//
//nolint:gochecknoglobals // set by one test, see above
var hashAtShippedCostInTest bool
// HashPassword generates an Argon2id hash of the password. A binary
// built by go test hashes at testArgon2Memory; one built by go build
// always hashes at the defaults.
func HashPassword(password string) (string, error) { func HashPassword(password string) (string, error) {
config := DefaultPasswordConfig() config := DefaultPasswordConfig()
if testing.Testing() && !hashAtShippedCostInTest {
config.Memory = testArgon2Memory
}
// Generate a salt // Generate a salt
salt := make([]byte, config.SaltLen) salt := make([]byte, config.SaltLen)
@@ -148,38 +122,6 @@ func VerifyPassword(
return subtle.ConstantTimeCompare(hash, otherHash) == 1, nil 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 // decodeHash extracts parameters, salt, and hash from an
// encoded hash string. // encoded hash string.
func decodeHash( 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,
)
}
}
+63 -120
View File
@@ -18,19 +18,6 @@ import (
// computation. // computation.
const hoursPerDay = 24 const hoursPerDay = 24
// reapBatchSize is how many expired events one retention transaction
// deletes. A transaction holds the event database's write lock, which
// the receiver and the delivery workers wait for, so a large prune is
// split into transactions each short enough to finish well inside the
// busy timeout.
const reapBatchSize = 1000
// reapBatchPause is how long retention waits after one batch before
// starting the next. A writer waiting for the write lock checks for it
// again after at most 100 ms, so a longer pause lets it in between two
// batches instead of only after the whole prune.
const reapBatchPause = 200 * time.Millisecond
// RetentionReaperParams holds the fx dependencies for the // RetentionReaperParams holds the fx dependencies for the
// RetentionReaper. // RetentionReaper.
type RetentionReaperParams struct { type RetentionReaperParams struct {
@@ -184,31 +171,32 @@ func (r *RetentionReaper) sweep(ctx context.Context) {
wh := webhooks[i] wh := webhooks[i]
// A missing database has nothing to reap. Restart recovery // Skip retain-forever webhooks before building any query.
// reports a lost one (see WebhookDBManager.GetDB). // RetainsForever covers both the RetentionForeverDays
// sentinel and the non-positive values that predate it: the
// sentinel is a positive number, so without this the reaper
// would compute a cutoff a thousand years in the past and
// issue a DELETE matching nothing on every single sweep.
if wh.RetainsForever() {
continue
}
// Nothing to reap if the per-webhook database has never
// been created.
if !r.dbManager.DBExists(wh.ID) { if !r.dbManager.DBExists(wh.ID) {
continue continue
} }
r.reapWebhook(ctx, wh.ID, wh.RetentionDays) r.reapWebhook(wh.ID, wh.RetentionDays)
} }
} }
// reapWebhook removes every expired event (and its dependents) from a // reapWebhook removes every expired event (and its dependents) from a
// single webhook's database, or as many as it reaches before ctx is // single webhook's database.
// cancelled.
func (r *RetentionReaper) reapWebhook( func (r *RetentionReaper) reapWebhook(
ctx context.Context,
webhookID string, webhookID string,
retentionDays int, retentionDays int,
) { ) {
// 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) db, err := r.dbManager.GetDB(webhookID)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
@@ -220,7 +208,12 @@ func (r *RetentionReaper) reapWebhook(
return return
} }
deleted, err := reapExpired(ctx, db, cutoff) cutoff, ok := retentionCutoff(time.Now(), retentionDays)
if !ok {
return
}
deleted, err := reapExpired(db, cutoff)
if err != nil { if err != nil {
r.log.Error( r.log.Error(
"retention sweep: failed to reap expired events", "retention sweep: failed to reap expired events",
@@ -272,107 +265,57 @@ func retentionCutoff(
), true ), true
} }
// reapExpired hard-deletes the events older than cutoff, with their // reapExpired hard-deletes, in foreign-key-safe order, the delivery
// deliveries and delivery results, reapBatchSize events per // results, deliveries, and events associated with events older than
// transaction with reapBatchPause between transactions, until none is // cutoff. Deletes are unscoped so rows are physically removed rather
// left. Once ctx is cancelled it returns after the batch in hand, // than soft-deleted, reclaiming disk. It returns the number of events
// leaving the rest to the next sweep, so stopping the app does not // deleted.
// wait for a long prune. It returns the number of events deleted. func reapExpired(db *gorm.DB, cutoff time.Time) (int64, error) {
func reapExpired( // Fresh subqueries are built per statement to avoid reusing a
ctx context.Context, db *gorm.DB, cutoff time.Time, // mutated builder across executions.
) (int64, error) { expiredEventIDs := func() *gorm.DB {
var total int64 return db.Model(&Event{}).
for {
var eventIDs []string
err := db.Transaction(func(tx *gorm.DB) error {
err := tx.Unscoped().Model(&Event{}).
Where("created_at < ?", cutoff).
Limit(reapBatchSize).
Pluck("id", &eventIDs).Error
if err != nil {
return fmt.Errorf("selecting expired events: %w", err)
}
if len(eventIDs) == 0 {
return nil
}
return deleteEvents(tx, eventIDs)
})
if err != nil {
return total, err
}
total += int64(len(eventIDs))
if len(eventIDs) < reapBatchSize {
return total, nil
}
select {
case <-ctx.Done():
return total, nil
case <-time.After(reapBatchPause):
}
}
}
// deleteEvents hard-deletes the given events and, in foreign-key-safe
// order before them, their delivery results and deliveries, then adds
// what it deleted to the running totals. It runs on reapExpired's
// transaction, so the totals change exactly when the rows do. Deletes
// are unscoped so rows are physically removed rather than
// soft-deleted, reclaiming disk.
func deleteEvents(tx *gorm.DB, eventIDs []string) error {
// 1. The delivery results of the events' deliveries.
err := tx.Unscoped().
Where("delivery_id IN (?)", tx.Unscoped().Model(&Delivery{}).
Select("id"). Select("id").
Where("event_id IN ?", eventIDs)). Where("created_at < ?", cutoff)
Delete(&DeliveryResult{}).Error }
if err != nil { expiredDeliveryIDs := func() *gorm.DB {
return fmt.Errorf("deleting expired delivery results: %w", err) return db.Model(&Delivery{}).
Select("id").
Where("event_id IN (?)", expiredEventIDs())
} }
// 2. The events' deliveries, after counting them, and the failed // 1. Delivery results whose delivery belongs to an expired event.
// ones among them, per target. The status is tested in the select res := db.Unscoped().
// list rather than the WHERE clause: there, SQLite would read every Where("delivery_id IN (?)", expiredDeliveryIDs()).
// failed delivery the webhook has through the status index, Delete(&DeliveryResult{})
// instead of only these through the event_id index. if res.Error != nil {
var removed []TargetTotals return 0, fmt.Errorf(
"deleting expired delivery results: %w",
err = tx.Unscoped().Model(&Delivery{}). res.Error,
Select("target_id, count(*) AS deliveries_removed, "+ )
"count(CASE WHEN status = ? THEN 1 END) AS failed_removed",
DeliveryStatusFailed).
Where("event_id IN ?", eventIDs).
Group("target_id").
Find(&removed).Error
if err != nil {
return fmt.Errorf("counting expired deliveries: %w", err)
} }
err = tx.Unscoped(). // 2. Deliveries belonging to an expired event.
Where("event_id IN ?", eventIDs). del := db.Unscoped().
Delete(&Delivery{}).Error Where("event_id IN (?)", expiredEventIDs()).
if err != nil { Delete(&Delivery{})
return fmt.Errorf("deleting expired deliveries: %w", err) if del.Error != nil {
return 0, fmt.Errorf(
"deleting expired deliveries: %w",
del.Error,
)
} }
// 3. The events themselves. // 3. The expired events themselves.
ev := tx.Unscoped().Where("id IN ?", eventIDs).Delete(&Event{}) ev := db.Unscoped().
Where("created_at < ?", cutoff).
Delete(&Event{})
if ev.Error != nil { if ev.Error != nil {
return fmt.Errorf("deleting expired events: %w", ev.Error) return 0, fmt.Errorf(
"deleting expired events: %w",
ev.Error,
)
} }
for i := range removed { return ev.RowsAffected, nil
err = AddTargetTotals(tx, removed[i])
if err != nil {
return err
}
}
return AddEventTotals(tx, EventTotals{EventsRemoved: ev.RowsAffected})
} }
+1 -1
View File
@@ -362,7 +362,7 @@ func TestRetentionReaper_HugeFiniteRetentionRetainsRecentEvents(
t, t,
overflowingRetentionDays, overflowingRetentionDays,
database.RetentionForeverDays, database.RetentionForeverDays,
"the test value must not be treated as retain-forever", "the test value must not be rescued by the forever skip",
) )
webhookID := createWebhook( webhookID := createWebhook(
-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/delivery 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)
}
-276
View File
@@ -1,276 +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.
//
// Chmod of a file that already exists is what tightens a data
// directory an earlier build left at 0644 — including a developer's
// own scratch directory — without any migration machinery.
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)")
return "file:" + path + "?" + q.Encode()
}
// 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
}
-178
View File
@@ -1,178 +0,0 @@
package database_test
import (
"context"
"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)
}
// 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)
}
+3 -16
View File
@@ -24,24 +24,11 @@ func NewTestDatabase(db *gorm.DB) *Database {
// NewTestWebhookDBManager creates a WebhookDBManager backed by the given // NewTestWebhookDBManager creates a WebhookDBManager backed by the given
// data directory. Intended for use in tests without the fx lifecycle. // data directory. Intended for use in tests without the fx lifecycle.
func NewTestWebhookDBManager(dataDir string) *WebhookDBManager { func NewTestWebhookDBManager(dataDir string) *WebhookDBManager {
return NewTestWebhookDBManagerWithLogger( return &WebhookDBManager{
dataDir, dataDir: dataDir,
slog.New(slog.NewTextHandler( log: slog.New(slog.NewTextHandler(
os.Stderr, os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug}, &slog.HandlerOptions{Level: slog.LevelDebug},
)), )),
)
}
// NewTestWebhookDBManagerWithLogger is NewTestWebhookDBManager 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.
func NewTestWebhookDBManagerWithLogger(
dataDir string, log *slog.Logger,
) *WebhookDBManager {
return &WebhookDBManager{
dataDir: dataDir,
log: log,
} }
} }
-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)
}
}
+58 -191
View File
@@ -2,6 +2,7 @@ package database
import ( import (
"context" "context"
"database/sql"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -13,8 +14,6 @@ import (
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/datadir"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/logger" "sneak.berlin/go/webhooker/internal/logger"
) )
@@ -33,33 +32,14 @@ var errInvalidCachedDBType = errors.New(
"invalid cached database type", "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 // WebhookDBManager manages per-webhook SQLite database files
// for event storage. Each webhook gets its own dedicated // for event storage. Each webhook gets its own dedicated
// database containing Events, Deliveries, DeliveryResults and the // database containing Events, Deliveries, and DeliveryResults.
// running totals of them (EventTotals, TargetTotals, EntrypointTotals).
// Database connections are opened lazily and cached. // Database connections are opened lazily and cached.
type WebhookDBManager struct { type WebhookDBManager struct {
dataDir string dataDir string
dbs sync.Map // map[webhookID]*gorm.DB dbs sync.Map // map[webhookID]*gorm.DB
log *slog.Logger 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 // NewWebhookDBManager creates a new WebhookDBManager and
@@ -73,9 +53,8 @@ func NewWebhookDBManager(
log: params.Logger.Get(), log: params.Logger.Get(),
} }
// Create data directory if it doesn't exist. datadir.DirPerm is the // Create data directory if it doesn't exist
// single source of the directory mode; either package may run first. err := os.MkdirAll(m.dataDir, dataDirPerm)
err := os.MkdirAll(m.dataDir, datadir.DirPerm)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"creating data directory %s: %w", "creating data directory %s: %w",
@@ -98,60 +77,61 @@ func NewWebhookDBManager(
return m, nil return m, nil
} }
// GetDB returns the database connection for a webhook, opening it on // GetDB returns the database connection for a webhook,
// first use. // creating the database file lazily if it doesn't exist.
//
// 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.
func (m *WebhookDBManager) GetDB( func (m *WebhookDBManager) GetDB(
webhookID string, webhookID string,
) (*gorm.DB, error) { ) (*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 return cachedDB, nil
// 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()
ok, err := check() // Slow path: open/create the database
if err != nil || !ok { db, err := m.openDB(webhookID)
if err != nil {
return nil, err 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 existingDB, castOK := actual.(*gorm.DB)
// handle it is. if !castOK {
func asGormDB(val any, webhookID string) (*gorm.DB, error) { return nil, fmt.Errorf(
db, ok := val.(*gorm.DB) "%w for webhook %s",
if !ok { errInvalidCachedDBType,
return nil, fmt.Errorf( webhookID,
"%w for webhook %s", )
errInvalidCachedDBType, }
webhookID,
) return existingDB, nil
} }
return db, nil return db, nil
} }
// CreateDB creates a new webhook's database file and runs // CreateDB explicitly creates a new per-webhook database file
// migrations. // and runs migrations.
func (m *WebhookDBManager) CreateDB( func (m *WebhookDBManager) CreateDB(
webhookID string, webhookID string,
) error { ) error {
_, err := m.getDB(webhookID, true) _, err := m.GetDB(webhookID)
return err return err
} }
@@ -167,18 +147,10 @@ func (m *WebhookDBManager) DBExists(
} }
// DeleteDB closes the connection and deletes the database file // DeleteDB closes the connection and deletes the database file
// for a webhook, with its -wal and -shm sidecars. The files are // for a webhook. The file is permanently removed.
// 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.
func (m *WebhookDBManager) DeleteDB( func (m *WebhookDBManager) DeleteDB(
webhookID string, webhookID string,
) error { ) 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 // Close and remove from cache
if val, ok := m.dbs.LoadAndDelete(webhookID); ok { if val, ok := m.dbs.LoadAndDelete(webhookID); ok {
if gormDB, castOK := val.(*gorm.DB); castOK { 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) path := m.dbPath(webhookID)
for _, suffix := range []string{"", "-wal", "-shm"} {
dbErr := removeFile(path) err := os.Remove(path + suffix)
sidecarErr := errors.Join( if err != nil && !os.IsNotExist(err) {
removeFile(path+"-wal"), return fmt.Errorf(
removeFile(path+"-shm"), "deleting webhook database file %s%s: %w",
) path, suffix, err,
)
if dbErr != nil { }
return fmt.Errorf(
"%w: %w",
ErrEventDBNotRemoved, errors.Join(dbErr, sidecarErr),
)
}
if sidecarErr != nil {
return fmt.Errorf("%w: %w", ErrSidecarNotRemoved, sidecarErr)
} }
m.log.Info( m.log.Info(
@@ -216,25 +181,9 @@ func (m *WebhookDBManager) DeleteDB(
return nil 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. // CloseAll closes all open per-webhook database connections.
// Called during application shutdown. // Called during application shutdown.
func (m *WebhookDBManager) CloseAll() error { 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 var lastErr error
m.dbs.Range(func(key, value any) bool { m.dbs.Range(func(key, value any) bool {
@@ -269,54 +218,6 @@ func (m *WebhookDBManager) DBPath(
return m.dbPath(webhookID) 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( func (m *WebhookDBManager) dbPath(
webhookID string, webhookID string,
) string { ) string {
@@ -332,11 +233,12 @@ func (m *WebhookDBManager) openDB(
webhookID string, webhookID string,
) (*gorm.DB, error) { ) (*gorm.DB, error) {
path := m.dbPath(webhookID) path := m.dbPath(webhookID)
dbURL := fmt.Sprintf(
"file:%s?cache=shared&mode=rwc",
path,
)
// See sqlite_open.go: WAL, a busy timeout, immediate-transaction sqlDB, err := sql.Open("sqlite", dbURL)
// 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)
if err != nil { if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"opening webhook database %s: %w", "opening webhook database %s: %w",
@@ -346,10 +248,7 @@ func (m *WebhookDBManager) openDB(
db, err := gorm.Open(sqlite.Dialector{ db, err := gorm.Open(sqlite.Dialector{
Conn: sqlDB, Conn: sqlDB,
}, &gorm.Config{ }, &gorm.Config{})
// Never leave this at GORM's default. See internal/gormlog.
Logger: gormlog.New(m.log),
})
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -359,29 +258,9 @@ 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 = purgeTargetRows(db, m.log, webhookID)
if err != nil {
_ = sqlDB.Close()
return nil, err
}
// Run migrations for event-tier models only // Run migrations for event-tier models only
err = db.AutoMigrate( err = db.AutoMigrate(
&Event{}, &Delivery{}, &DeliveryResult{}, &Event{}, &Delivery{}, &DeliveryResult{},
&EventTotals{}, &TargetTotals{}, &EntrypointTotals{},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
@@ -392,18 +271,6 @@ func (m *WebhookDBManager) openDB(
) )
} }
// A new database gets its row of event totals, all zero. Target
// totals rows are created by the first delivery to each target.
err = db.FirstOrCreate(&EventTotals{}).Error
if err != nil {
_ = sqlDB.Close()
return nil, fmt.Errorf(
"creating event totals for webhook database %s: %w",
webhookID, err,
)
}
m.log.Info( m.log.Info(
"opened per-webhook database", "opened per-webhook database",
"webhook_id", webhookID, "webhook_id", webhookID,
+4 -204
View File
@@ -1,14 +1,10 @@
package database_test package database_test
import ( import (
"bytes"
"context" "context"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"sync"
"testing" "testing"
"github.com/google/uuid" "github.com/google/uuid"
@@ -108,54 +104,6 @@ func TestWebhookDBManager_CreateAndGetDB(t *testing.T) {
assert.Equal(t, `{"test": true}`, readEvent.Body) 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 := database.NewTestWebhookDBManagerWithLogger(
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) { func TestWebhookDBManager_DeleteDB(t *testing.T) {
t.Parallel() t.Parallel()
@@ -182,91 +130,17 @@ func TestWebhookDBManager_DeleteDB(t *testing.T) {
} }
require.NoError(t, db.Create(event).Error) 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 // Delete the DB
require.NoError(t, mgr.DeleteDB(webhookID)) require.NoError(t, mgr.DeleteDB(webhookID))
// File should no longer exist // File should no longer exist
assert.False(t, mgr.DBExists(webhookID)) assert.False(t, mgr.DBExists(webhookID))
// Verify the files are actually gone from disk // Verify the file is 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()
dbPath := mgr.DBPath(webhookID) dbPath := mgr.DBPath(webhookID)
blockRemoval(t, dbPath) _, err = os.Stat(dbPath)
require.NoError(t, os.WriteFile(dbPath+"-wal", nil, 0o600)) assert.True(t, os.IsNotExist(err))
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")
} }
func TestWebhookDBManager_LazyCreation(t *testing.T) { func TestWebhookDBManager_LazyCreation(t *testing.T) {
@@ -289,75 +163,6 @@ func TestWebhookDBManager_LazyCreation(t *testing.T) {
assert.True(t, mgr.DBExists(webhookID)) assert.True(t, mgr.DBExists(webhookID))
} }
// A webhook's database is made by CreateDB along with the webhook. One
// that GetDB finds missing or zero-length has lost the webhook's events
// and pending deliveries, so the empty database made in its place is
// logged as a warning naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290). CreateDB, and
// reopening a database that is there, log no such warning.
func TestWebhookDBManager_LostDatabaseIsLogged(t *testing.T) {
t.Parallel()
const created = `level=WARN msg="created a new, empty database"`
open := func(
t *testing.T, prepare func(*database.WebhookDBManager, string),
) (string, string) {
t.Helper()
var logs bytes.Buffer
mgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(),
slog.New(slog.NewTextHandler(&logs, nil)),
)
webhookID := uuid.New().String()
prepare(mgr, webhookID)
_, err := mgr.GetDB(webhookID)
require.NoError(t, err)
require.NoError(t, mgr.CloseAll())
return logs.String(),
" webhook_id=" + webhookID + " path=" + mgr.DBPath(webhookID)
}
t.Run("missing", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(*database.WebhookDBManager, string) {},
)
assert.Contains(t, logs, created+fields)
})
t.Run("zero-length", func(t *testing.T) {
t.Parallel()
logs, fields := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, os.WriteFile(
mgr.DBPath(webhookID), nil, database.SQLiteFilePerm,
))
},
)
assert.Contains(t, logs, created+fields)
})
t.Run("created with the webhook, then reopened", func(t *testing.T) {
t.Parallel()
logs, _ := open(
t, func(mgr *database.WebhookDBManager, webhookID string) {
require.NoError(t, mgr.CreateDB(webhookID))
require.NoError(t, mgr.CloseAll())
},
)
assert.NotContains(t, logs, created)
})
}
func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) { func TestWebhookDBManager_DeliveryWorkflow(t *testing.T) {
t.Parallel() t.Parallel()
@@ -534,12 +339,7 @@ func TestWebhookDBManager_MultipleWebhooks(t *testing.T) {
var events []database.Event var events []database.Event
require.NoError(t, db2.Find(&events).Error) require.NoError(t, db2.Find(&events).Error)
assert.Len(t, events, 1)
// 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.Equal(t, "PUT", events[0].Method) 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)
}
+14 -29
View File
@@ -8,7 +8,6 @@ import (
"time" "time"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/config" "sneak.berlin/go/webhooker/internal/config"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/lifecycle" "sneak.berlin/go/webhooker/internal/lifecycle"
@@ -26,14 +25,14 @@ type ArchiveSweeperParams struct {
Logger *logger.Logger Logger *logger.Logger
} }
// ArchiveSweeper periodically prunes expired rows from the // ArchiveSweeper periodically prunes expired rows from
// archive databases of database targets that carry a positive // per-webhook archive databases whose database target carries a
// expiry. // positive expiry.
// //
// Without it, pruning happens only when an archive is // Without it, pruning happens only when an archive is
// (re)opened, and archives are only ever reopened by writes: an // (re)opened, and archives are only ever reopened by writes: an
// archive whose target has stopped receiving events would keep // archive belonging to a webhook that has stopped receiving
// its expired rows forever. The sweep closes // events would keep its expired rows forever. The sweep closes
// that gap without changing anything for archives whose expiry // that gap without changing anything for archives whose expiry
// is unset or "never". // is unset or "never".
// //
@@ -45,13 +44,8 @@ type ArchiveSweeper struct {
eng *Engine eng *Engine
log *slog.Logger log *slog.Logger
interval time.Duration interval time.Duration
cancel context.CancelFunc
// cancel needs no lock: fx calls the stop hook only after the wg sync.WaitGroup
// start hook has returned, so stop never reads it while start
// is still setting it.
cancel context.CancelFunc
wg sync.WaitGroup
} }
// NewArchiveSweeper creates the archive sweeper and registers // NewArchiveSweeper creates the archive sweeper and registers
@@ -161,25 +155,17 @@ func (s *ArchiveSweeper) run(ctx context.Context) {
// soft-deleted along with it, so GORM's default scope already // soft-deleted along with it, so GORM's default scope already
// excludes them. // excludes them.
// //
// A failure for one target is logged and the sweep continues, // A failure for one webhook is logged and the sweep continues,
// matching how the write path already treats a prune error as // matching how the write path already treats a prune error as
// non-fatal. // non-fatal.
func (s *ArchiveSweeper) sweep(ctx context.Context) { func (s *ArchiveSweeper) sweep(ctx context.Context) {
var targets []database.Target var targets []database.Target
err := s.db.DB(). err := s.db.DB().
WithContext(ctx).
Model(&database.Target{}). Model(&database.Target{}).
Where("type = ?", database.TargetTypeDatabase). Where("type = ?", database.TargetTypeDatabase).
Find(&targets).Error Find(&targets).Error
if err != nil { 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( s.log.Error(
"archive sweep: failed to list database targets", "archive sweep: failed to list database targets",
"error", err, "error", err,
@@ -224,20 +210,19 @@ func (s *ArchiveSweeper) sweepTarget(target *database.Target) {
return return
} }
err = s.eng.dbTarget.sweepArchive(target.ID, expiry) err = s.eng.dbTarget.sweepWebhook(target.WebhookID, expiry)
if err == nil { if err == nil {
return return
} }
// A writer evicted, or a target row gone, underneath the sweep // A writer evicted underneath the sweep means the operator
// means the operator deleted the target or its webhook while // deleted the webhook (or its last database target) while the
// the sweep was walking the target list. That is an ordinary // sweep was walking the target list. That is an ordinary
// interleaving, not a failure, so it must not produce an // interleaving, not a failure, so it must not produce an
// error line. // error line.
if errors.Is(err, errArchiveWriterEvicted) || if errors.Is(err, errArchiveWriterEvicted) {
errors.Is(err, gorm.ErrRecordNotFound) {
s.log.Debug( s.log.Debug(
"archive sweep: target deleted mid-sweep", "archive sweep: writer evicted mid-sweep",
"webhook_id", target.WebhookID, "webhook_id", target.WebhookID,
"target_id", target.ID, "target_id", target.ID,
) )
+137 -220
View File
@@ -1,11 +1,9 @@
package delivery_test package delivery_test
import ( import (
"bytes"
"context" "context"
"database/sql" "database/sql"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"os" "os"
"path/filepath" "path/filepath"
@@ -22,7 +20,6 @@ import (
_ "modernc.org/sqlite" // Pure Go SQLite driver. _ "modernc.org/sqlite" // Pure Go SQLite driver.
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
const ( const (
@@ -37,23 +34,18 @@ const (
sweepConcurrentWrites = 20 sweepConcurrentWrites = 20
) )
// archiveTestWebhookName is the name of every webhook // sweeperEnv bundles the pieces an archive sweep test drives:
// seedDatabaseTarget creates. It is not safe in a file name as it // a main configuration database holding webhooks and targets, a
// stands, so every archive test goes through archiveNamePart. // delivery engine owning the archive writer registry, and the
const archiveTestWebhookName = "Sweep Test!" // data directory the archive files live in.
type sweeperEnv struct {
// archiveEnv bundles the pieces an archive test drives: a main
// configuration database holding webhooks and targets, a delivery
// engine owning the archive writer registry, the archive sweeper,
// and the data directory the archive files live in.
type archiveEnv struct {
sweeper *delivery.ArchiveSweeper sweeper *delivery.ArchiveSweeper
eng *delivery.Engine eng *delivery.Engine
mainDB *database.Database mainDB *database.Database
dataDir string dataDir string
} }
func setupArchiveTest(t *testing.T) *archiveEnv { func setupSweeperTest(t *testing.T) *sweeperEnv {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
@@ -71,8 +63,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -87,7 +78,7 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
1, 1,
) )
return &archiveEnv{ return &sweeperEnv{
sweeper: delivery.NewTestArchiveSweeper( sweeper: delivery.NewTestArchiveSweeper(
mainDB, eng, log, mainDB, eng, log,
), ),
@@ -97,27 +88,25 @@ func setupArchiveTest(t *testing.T) *archiveEnv {
} }
} }
// archivePath returns where the engine keeps a database target's // archivePath returns where the engine keeps a webhook's
// archive file, for the names seedDatabaseTarget gave it. // archive file.
func (env *archiveEnv) archivePath(tgt *database.Target) string { func (env *sweeperEnv) archivePath(webhookID string) string {
return filepath.Join( return filepath.Join(
env.dataDir, env.dataDir, fmt.Sprintf("archive-%s.db", webhookID),
delivery.ArchiveFileName(
archiveTestWebhookName, tgt.Name, tgt.ID,
),
) )
} }
// seedDatabaseTarget creates a webhook with one database target // seedDatabaseTarget creates a webhook with one database target
// carrying the given target config JSON, and returns the target. // carrying the given target config JSON, and returns the
func (env *archiveEnv) seedDatabaseTarget( // webhook id.
func (env *sweeperEnv) seedDatabaseTarget(
t *testing.T, configJSON string, t *testing.T, configJSON string,
) *database.Target { ) string {
t.Helper() t.Helper()
wh := &database.Webhook{ wh := &database.Webhook{
UserID: uuid.New().String(), UserID: uuid.New().String(),
Name: archiveTestWebhookName, Name: "sweep-test",
} }
require.NoError( require.NoError(
t, t,
@@ -126,19 +115,9 @@ func (env *archiveEnv) seedDatabaseTarget(
Create(wh).Error, Create(wh).Error,
) )
return env.addDatabaseTarget(t, wh.ID, configJSON)
}
// addDatabaseTarget creates one more database target on an
// existing webhook and returns it.
func (env *archiveEnv) addDatabaseTarget(
t *testing.T, webhookID, configJSON string,
) *database.Target {
t.Helper()
tgt := &database.Target{ tgt := &database.Target{
WebhookID: webhookID, WebhookID: wh.ID,
Name: "Archive", Name: "archive",
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Active: true, Active: true,
Config: configJSON, Config: configJSON,
@@ -150,30 +129,19 @@ func (env *archiveEnv) addDatabaseTarget(
Create(tgt).Error, Create(tgt).Error,
) )
return tgt return wh.ID
} }
// seedArchiveRows creates the archive file for a target and // seedArchiveRows creates the archive file for a webhook and
// inserts one row per supplied archived-at timestamp, returning // inserts one row per supplied archived-at timestamp, returning
// the archive path. The handle is closed before returning, so // the archive path. The handle is closed before returning, so
// the archive is idle exactly as it would be with no traffic. // the archive is idle exactly as it would be with no traffic.
func (env *archiveEnv) seedArchiveRows( func (env *sweeperEnv) seedArchiveRows(
t *testing.T, tgt *database.Target, archivedAt ...time.Time, t *testing.T, webhookID string, archivedAt ...time.Time,
) string { ) string {
t.Helper() t.Helper()
path := env.archivePath(tgt) path := env.archivePath(webhookID)
seedArchiveFile(t, path, tgt.WebhookID, archivedAt...)
return path
}
// seedArchiveFile creates the archive file at path and inserts one row
// per supplied archived-at timestamp, as seedArchiveRows does.
func seedArchiveFile(
t *testing.T, path, webhookID string, archivedAt ...time.Time,
) {
t.Helper()
sqlDB, err := sql.Open( sqlDB, err := sql.Open(
"sqlite", fmt.Sprintf("file:%s?mode=rwc", path), "sqlite", fmt.Sprintf("file:%s?mode=rwc", path),
@@ -181,8 +149,7 @@ func seedArchiveFile(
require.NoError(t, err) require.NoError(t, err)
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -202,6 +169,8 @@ func seedArchiveFile(
} }
require.NoError(t, sqlDB.Close()) require.NoError(t, sqlDB.Close())
return path
} }
// archivedEventIDs returns the event ids currently stored in an // archivedEventIDs returns the event ids currently stored in an
@@ -239,8 +208,7 @@ func countArchivedRows(path string) (int64, error) {
defer func() { _ = sqlDB.Close() }() defer func() { _ = sqlDB.Close() }()
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
if err != nil { if err != nil {
return 0, err return 0, err
@@ -275,13 +243,13 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -319,60 +287,60 @@ func TestArchiveSweeper_LoopOutlivesStartHookContext(
} }
// TestArchiveSweep_DoesNotResurrectEvictedWriter covers the // TestArchiveSweep_DoesNotResurrectEvictedWriter covers the
// interleaving where a sweep tick has already listed a target // interleaving where a sweep tick has already listed a webhook's
// when the target is deleted and its writer evicted. The sweep // target when the webhook is deleted and its writer evicted. The
// must not put a writer back into the registry: nothing would // sweep must not put a writer back into the registry: nothing
// ever evict it again, which is precisely the leak this change // would ever evict it again, which is precisely the leak this
// exists to close. // change exists to close.
func TestArchiveSweep_DoesNotResurrectEvictedWriter( func TestArchiveSweep_DoesNotResurrectEvictedWriter(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
// Prime the registry the way a delivery would, then evict as // Prime the registry the way a delivery would, then evict as
// the deletion path does. The target row is deliberately left // the deletion path does. The target row is deliberately left
// in place: this is the tick that listed the target before // in place: this is the tick that listed the webhook before
// the deletion committed. // the deletion committed.
_, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) _, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err) require.NoError(t, err)
env.eng.EvictTarget(tgt.ID) env.eng.EvictWebhook(webhookID)
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must never re-register a writer for a target "+ "a sweep must never re-register a writer for a webhook "+
"whose registry entry has already been released", "whose registry entry has already been released",
) )
} }
// TestArchiveSweep_LeavesNoRegistryEntry states the same // TestArchiveSweep_LeavesNoRegistryEntry states the same
// invariant in its general form: sweeping an archive whose // invariant in its general form: sweeping an archive whose
// target has no cached writer must not leave one behind, so the // webhook has no cached writer must not leave one behind, so the
// registry keeps holding only writers a delivery created and an // registry keeps holding only writers a delivery created and an
// eviction can reach. // eviction can reach.
func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) { func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
@@ -381,7 +349,7 @@ func TestArchiveSweep_LeavesNoRegistryEntry(t *testing.T) {
"the sweep must still prune an idle archive", "the sweep must still prune an idle archive",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the sweep must release the registry entry it created", "the sweep must release the registry entry it created",
) )
} }
@@ -396,31 +364,34 @@ func TestArchiveSweep_KeepsWriterAdoptedByDelivery(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.False(t, env.eng.ExportHasArchiveWriter(tgt.ID)) require.False(t, env.eng.ExportHasArchiveWriter(webhookID))
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a delivery's writer must stay registered", "a delivery's writer must stay registered",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a sweep must not drop a writer a delivery owns", "a sweep must not drop a writer a delivery owns",
) )
} }
@@ -452,15 +423,15 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
sweepWriter, created, err := env.eng.ExportSweepWriterFor( sweepWriter, created, err := env.eng.ExportSweepWriterFor(
tgt.ID, webhookID,
) )
require.NoError(t, err) require.NoError(t, err)
require.True( require.True(
@@ -471,34 +442,37 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
// The delivery lands mid-sweep and adopts the entry. // The delivery lands mid-sweep and adopts the entry.
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
adopted := env.eng.ExportArchiveWriterFor(tgt.ID) adopted := env.eng.ExportArchiveWriterFor(webhookID)
require.NotNil(t, adopted) require.NotNil(t, adopted)
require.True( require.True(
t, sweepWriter.Same(adopted), t, sweepWriter.Same(adopted),
"the delivery must have adopted the sweep's writer", "the delivery must have adopted the sweep's writer",
) )
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery leaves the archive handle open", "the delivery leaves the archive handle open",
) )
// The sweep finishes. // The sweep finishes.
env.eng.ExportReleaseSweepWriter(tgt.ID, sweepWriter) env.eng.ExportReleaseSweepWriter(webhookID, sweepWriter)
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"a writer adopted by a delivery during a sweep must "+ "a writer adopted by a delivery during a sweep must "+
"stay registered, or its open handle is unreachable", "stay registered, or its open handle is unreachable",
) )
env.eng.EvictTarget(tgt.ID) env.eng.EvictWebhook(webhookID)
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the adopted writer must still be evictable", "the adopted writer must still be evictable",
) )
assert.False( assert.False(
@@ -507,34 +481,34 @@ func TestArchiveSweep_KeepsWriterAdoptedDuringSweep(
) )
} }
// TestArchiveSweep_ContinuesAfterPerTargetFailure proves a // TestArchiveSweep_ContinuesAfterPerWebhookFailure proves a
// failure for one target does not abort the sweep for the // failure for one webhook does not abort the sweep for the
// others: an unparseable expiry and an unreadable archive both // others: an unparseable expiry and an unreadable archive both
// have to be logged and stepped over. // have to be logged and stepped over.
func TestArchiveSweep_ContinuesAfterPerTargetFailure( func TestArchiveSweep_ContinuesAfterPerWebhookFailure(
t *testing.T, t *testing.T,
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
// Seeded first so the sweep reaches them before the healthy // Seeded first so the sweep reaches them before the healthy
// target: targets come back in insertion order. // webhook: targets come back in insertion order.
badConfig := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`) badConfigID := env.seedDatabaseTarget(t, `{"expiry":"!!!"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, badConfig, time.Now().Add(-48*time.Hour), t, badConfigID, time.Now().Add(-48*time.Hour),
) )
corrupt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) corruptID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
require.NoError(t, os.WriteFile( require.NoError(t, os.WriteFile(
env.archivePath(corrupt), env.archivePath(corruptID),
[]byte("this is not a sqlite database"), []byte("this is not a sqlite database"),
0o600, 0o600,
)) ))
healthy := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) healthyID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
healthyPath := env.seedArchiveRows( healthyPath := env.seedArchiveRows(
t, healthy, t, healthyID,
time.Now().Add(-48*time.Hour), time.Now().Add(-48*time.Hour),
time.Now().Add(-time.Minute), time.Now().Add(-time.Minute),
) )
@@ -544,14 +518,14 @@ func TestArchiveSweep_ContinuesAfterPerTargetFailure(
assert.Equal( assert.Equal(
t, []string{sweepRowNew}, t, []string{sweepRowNew},
archivedEventIDs(t, healthyPath), archivedEventIDs(t, healthyPath),
"a failure for an earlier target must not stop the "+ "a failure for an earlier webhook must not stop the "+
"sweep from pruning the ones after it", "sweep from pruning the ones after it",
) )
} }
// TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second // TestArchiveSweep_OpenExistingDoesNotCreateFile pins the second
// of the two no-create guards. The first is the stat in // of the two no-create guards. The first is the stat in
// sweepExpired; this one is the SQLite open mode, which is what // sweepWebhook; this one is the SQLite open mode, which is what
// protects the window between that stat and the open. Flipping // protects the window between that stat and the open. Flipping
// the sweep's mode to create-if-missing makes this fail. // the sweep's mode to create-if-missing makes this fail.
func TestArchiveSweep_OpenExistingDoesNotCreateFile( func TestArchiveSweep_OpenExistingDoesNotCreateFile(
@@ -587,13 +561,13 @@ func TestArchiveSweep_OpenExistingDoesNotCreateFile(
func TestArchiveSweep_PrunesIdleArchive(t *testing.T) { func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
now := time.Now() now := time.Now()
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
now.Add(-48*time.Hour), now.Add(-48*time.Hour),
now.Add(-time.Minute), now.Add(-time.Minute),
) )
@@ -626,11 +600,11 @@ func TestArchiveSweep_PrunesIdleArchive(t *testing.T) {
func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) { func TestArchiveSweep_LeavesArchiveClosed(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
w := delivery.NewExportArchiveWriter( w := delivery.NewExportArchiveWriter(
@@ -666,94 +640,39 @@ func TestArchiveSweep_ClosesHandleOfRegisteredWriter(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, tgt) event.WebhookID = webhookID
d := seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
)
env.eng.ExportDeliverDatabase(webhookDB, d) env.eng.ExportDeliverDatabase(webhookDB, d)
require.True( require.True(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the delivery must leave the archive handle open", "the delivery must leave the archive handle open",
) )
env.sweeper.ExportSweep(context.Background()) env.sweeper.ExportSweep(context.Background())
require.True( require.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"the delivery's registry entry must survive the sweep", "the delivery's registry entry must survive the sweep",
) )
assert.False( assert.False(
t, env.eng.ExportArchiveHandleOpen(tgt.ID), t, env.eng.ExportArchiveHandleOpen(webhookID),
"the sweep must leave the archive closed", "the sweep must leave the archive closed",
) )
} }
// TestArchiveSweep_ClosesHandleBeforeReopening proves the sweep
// closes the handle it finds open before it reopens the file.
// TestArchiveSweep_LeavesArchiveClosed cannot see this: without the
// close, the reopen replaces the handle without closing it, the
// sweep then closes only the new one, and one connection leaks per
// archive per sweep.
func TestArchiveSweep_ClosesHandleBeforeReopening(t *testing.T) {
t.Parallel()
path := filepath.Join(t.TempDir(), "archive.db")
w := delivery.NewExportArchiveWriter(
path, archiveTestLogger(), 0,
)
require.NoError(t, w.Open(time.Hour))
before, err := w.DB().DB()
require.NoError(t, err)
require.NoError(t, w.SweepExpired(time.Hour))
assert.Error(
t, before.PingContext(t.Context()),
"the handle open before the sweep must be closed by it",
)
}
// TestArchiveSweep_CancelledSweepLogsNoError proves a sweep whose
// context is already cancelled, as when the app stops just as a
// sweep starts, returns without an error line: stopping is not a
// failure.
func TestArchiveSweep_CancelledSweepLogsNoError(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
var errorLines bytes.Buffer
sweeper := delivery.NewTestArchiveSweeper(
env.mainDB, env.eng,
slog.New(slog.NewTextHandler(
&errorLines,
&slog.HandlerOptions{Level: slog.LevelError},
)),
)
ctx, cancel := context.WithCancel(context.Background())
cancel()
sweeper.ExportSweep(ctx)
assert.Empty(
t, errorLines.String(),
"a cancelled sweep must not log at error level",
)
}
// TestArchiveSweep_NeverExpiryUntouched proves the sweep is a // TestArchiveSweep_NeverExpiryUntouched proves the sweep is a
// no-op for the default retention policy, so archives with no // no-op for the default retention policy, so archives with no
// expiry (or the literal "never") behave exactly as before. // expiry (or the literal "never") behave exactly as before.
@@ -765,11 +684,11 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
`{"expiry":""}`, `{"expiry":""}`,
"", "",
} { } {
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, configJSON) webhookID := env.seedDatabaseTarget(t, configJSON)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, t, webhookID,
time.Now().Add(-10000*time.Hour), time.Now().Add(-10000*time.Hour),
) )
@@ -780,7 +699,7 @@ func TestArchiveSweep_NeverExpiryUntouched(t *testing.T) {
"config %q must keep rows forever", configJSON, "config %q must keep rows forever", configJSON,
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, env.eng.ExportHasArchiveWriter(webhookID),
"config %q must leave no registry entry behind", "config %q must leave no registry entry behind",
configJSON, configJSON,
) )
@@ -803,10 +722,10 @@ func TestArchiveSweep_NeverExpirySkipsBeforeOpening(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"never"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"never"}`)
path := env.archivePath(tgt) path := env.archivePath(webhookID)
seedUnmigratedArchive(t, path) seedUnmigratedArchive(t, path)
require.False(t, archiveTableExists(t, path)) require.False(t, archiveTableExists(t, path))
@@ -849,16 +768,16 @@ func archiveTableExists(t *testing.T, path string) bool {
} }
// TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep // TestArchiveSweep_DoesNotCreateArchiveFile proves the sweep
// never conjures an archive: a database target that has never // never conjures an archive: a webhook with a database target
// received an event must still have no archive file (nor SQLite // that has never received an event must still have no archive
// sidecar) after a sweep, and no registry entry either. // file (nor SQLite sidecar) after a sweep.
func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) { func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.archivePath(tgt) path := env.archivePath(webhookID)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -870,11 +789,6 @@ func TestArchiveSweep_DoesNotCreateArchiveFile(t *testing.T) {
"the sweep must not create an archive file", "the sweep must not create an archive file",
) )
} }
assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID),
"the sweep must leave no registry entry behind",
)
} }
// TestArchiveSweep_DoesNotCreateAfterWriterExists covers the // TestArchiveSweep_DoesNotCreateAfterWriterExists covers the
@@ -886,11 +800,11 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path, err := env.eng.ExportEnsureArchiveWriter(tgt.ID) path, err := env.eng.ExportEnsureArchiveWriter(webhookID)
require.NoError(t, err) require.NoError(t, err)
require.NoFileExists(t, path) require.NoFileExists(t, path)
@@ -905,17 +819,17 @@ func TestArchiveSweep_DoesNotCreateAfterWriterExists(
func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) { func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
path := env.seedArchiveRows( path := env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
require.NoError( require.NoError(
t, t,
env.mainDB.DB(). env.mainDB.DB().
Where("webhook_id = ?", tgt.WebhookID). Where("webhook_id = ?", webhookID).
Delete(&database.Target{}).Error, Delete(&database.Target{}).Error,
) )
@@ -928,14 +842,14 @@ func TestArchiveSweep_SkipsDeletedWebhookTargets(t *testing.T) {
} }
// TestArchiveSweep_ConcurrentWrites proves the sweep serialises // TestArchiveSweep_ConcurrentWrites proves the sweep serialises
// against writes through the target's writer mutex. Run under // against writes through the per-webhook writer mutex. Run
// -race, an unsynchronised sweep would be caught here. // under -race, an unsynchronised sweep would be caught here.
func TestArchiveSweep_ConcurrentWrites(t *testing.T) { func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
webhookDB := testWebhookDB(t) webhookDB := testWebhookDB(t)
@@ -948,10 +862,13 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
for range sweepConcurrentWrites { for range sweepConcurrentWrites {
event := seedEvent(t, webhookDB, `{"n":1}`) event := seedEvent(t, webhookDB, `{"n":1}`)
event.WebhookID = webhookID
deliveries = append( deliveries = append(
deliveries, deliveries,
seedDatabaseTargetDelivery(t, webhookDB, event, tgt), seedDatabaseTargetDelivery(
t, webhookDB, event, `{"expiry":"1h"}`,
),
) )
} }
@@ -977,7 +894,7 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
wg.Wait() wg.Wait()
assert.FileExists(t, env.archivePath(tgt)) assert.FileExists(t, env.archivePath(webhookID))
} }
// TestArchiveSweeper_StopsCleanly proves the background loop // TestArchiveSweeper_StopsCleanly proves the background loop
@@ -985,11 +902,11 @@ func TestArchiveSweep_ConcurrentWrites(t *testing.T) {
func TestArchiveSweeper_StopsCleanly(t *testing.T) { func TestArchiveSweeper_StopsCleanly(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
tgt := env.seedDatabaseTarget(t, `{"expiry":"1h"}`) webhookID := env.seedDatabaseTarget(t, `{"expiry":"1h"}`)
env.seedArchiveRows( env.seedArchiveRows(
t, tgt, time.Now().Add(-48*time.Hour), t, webhookID, time.Now().Add(-48*time.Hour),
) )
env.sweeper.ExportSetInterval(time.Millisecond) env.sweeper.ExportSetInterval(time.Millisecond)
@@ -1013,7 +930,7 @@ func TestArchiveSweeper_StopHookHonoursStopTimeout(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) env := setupSweeperTest(t)
lc := &recordingLifecycle{} lc := &recordingLifecycle{}
env.sweeper.ExportRegisterHooks(lc) env.sweeper.ExportRegisterHooks(lc)
+2 -24
View File
@@ -76,20 +76,12 @@ func (cb *CircuitBreaker) Allow() bool {
} }
} }
// CooldownRemaining returns how long a delivery that Allow refused // CooldownRemaining returns how much time is left before
// should wait before it is tried again. Closed, it returns zero. // an open circuit transitions to half-open.
// 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.
func (cb *CircuitBreaker) CooldownRemaining() time.Duration { func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
cb.mu.Lock() cb.mu.Lock()
defer cb.mu.Unlock() defer cb.mu.Unlock()
if cb.state == CircuitHalfOpen {
return cb.cooldown
}
if cb.state != CircuitOpen { if cb.state != CircuitOpen {
return 0 return 0
} }
@@ -102,20 +94,6 @@ func (cb *CircuitBreaker) CooldownRemaining() time.Duration {
return remaining 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 // RecordSuccess records a successful delivery and resets
// the circuit breaker to closed state. // the circuit breaker to closed state.
func (cb *CircuitBreaker) RecordSuccess() { 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 *testing.T,
) { ) {
t.Parallel() t.Parallel()
@@ -282,11 +282,9 @@ func TestCircuitBreaker_CooldownRemaining_HalfOpenReturnsCooldown(
require.True(t, cb.Allow()) require.True(t, cb.Allow())
// The cooldown newShortCooldownCB gives the breaker. assert.Equal(t, time.Duration(0),
assert.Equal(t, 50*time.Millisecond,
cb.CooldownRemaining(), cb.CooldownRemaining(),
"a delivery refused while half-open should wait "+ "half-open circuit should have zero cooldown remaining",
"a whole cooldown",
) )
} }
+13 -51
View File
@@ -18,27 +18,26 @@ func newSSRFTestEngine() *delivery.Engine {
log := slog.New(slog.DiscardHandler) log := slog.New(slog.DiscardHandler)
client := &http.Client{ client := &http.Client{
Timeout: 30 * time.Second, Timeout: 30 * time.Second,
Transport: delivery.NewTestGuard(). Transport: delivery.NewSSRFSafeTransport(),
NewSSRFSafeTransport(),
} }
return delivery.NewTestEngine(log, client, 1) return delivery.NewTestEngine(log, client, 1)
} }
// TestClientForRequest_TimeoutKeepsSSRFGuard asserts that a // TestClientForConfig_TimeoutKeepsSSRFGuard asserts that a
// client returned by clientForRequest for a config with a // client returned by clientForConfig for a config with a
// per-target timeout still refuses connections to // per-target timeout still refuses connections to
// private/reserved addresses (the timeout must not drop the // private/reserved addresses (the timeout must not drop the
// SSRF-safe transport). // SSRF-safe transport).
func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) { func TestClientForConfig_TimeoutKeepsSSRFGuard(t *testing.T) {
t.Parallel() t.Parallel()
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
blocked := []string{ blocked := []string{
loopbackHookURL, "http://127.0.0.1/hook",
metadataURL, "http://169.254.169.254/latest/meta-data/",
"http://[fe80::1]/hook", "http://[fe80::1]/hook",
} }
@@ -51,7 +50,7 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
Timeout: 5, Timeout: 5,
} }
client := engine.ExportClientForRequest(cfg, nil) client := engine.ExportClientForConfig(cfg)
require.NotSame(t, engine.ExportClient(), client, require.NotSame(t, engine.ExportClient(), client,
"a per-target timeout must yield a "+ "a per-target timeout must yield a "+
@@ -92,11 +91,10 @@ func TestClientForRequest_TimeoutKeepsSSRFGuard(t *testing.T) {
} }
} }
// TestClientForRequest_NoTimeoutUnchanged asserts that a // TestClientForConfig_NoTimeoutUnchanged asserts that with
// request with neither a per-target timeout nor an origin-scoped // no per-target timeout the shared SSRF-safe client is
// header gets the shared SSRF-safe client unchanged: there is then // returned unchanged.
// nothing for a redirect policy to strip. func TestClientForConfig_NoTimeoutUnchanged(t *testing.T) {
func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
t.Parallel() t.Parallel()
engine := newSSRFTestEngine() engine := newSSRFTestEngine()
@@ -105,46 +103,10 @@ func TestClientForRequest_NoTimeoutUnchanged(t *testing.T) {
URL: "https://example.com/hook", URL: "https://example.com/hook",
} }
client := engine.ExportClientForRequest(cfg, nil) client := engine.ExportClientForConfig(cfg)
assert.Same(t, engine.ExportClient(), client, assert.Same(t, engine.ExportClient(), client,
"without a per-target timeout the shared client "+ "without a per-target timeout the shared client "+
"must be returned unchanged", "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",
)
}
-177
View File
@@ -1,177 +0,0 @@
package delivery_test
import (
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
)
// targetTotals reads one target's totals from a webhook database, all
// zero when it has no row.
func targetTotals(
t *testing.T, db *gorm.DB, targetID string,
) database.TargetTotals {
t.Helper()
var rows []database.TargetTotals
require.NoError(t, db.Where("target_id = ?", targetID).
Find(&rows).Error)
if len(rows) == 0 {
return database.TargetTotals{TargetID: targetID}
}
return rows[0]
}
// TestUpdateDeliveryStatus_FinishTimeAndTargetTotals pins what a status
// write records for the webhook page's statistics: the time a delivery
// finished, set only when it becomes delivered or failed, and one more
// on its target's delivered or failed total.
func TestUpdateDeliveryStatus_FinishTimeAndTargetTotals(t *testing.T) {
t.Parallel()
tests := []struct {
status database.DeliveryStatus
finished bool
delivered int64
failed int64
}{
{database.DeliveryStatusRetrying, false, 0, 0},
{database.DeliveryStatusDelivered, true, 1, 0},
{database.DeliveryStatusFailed, true, 0, 1},
}
for _, tt := range tests {
t.Run(string(tt.status), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
before := time.Now()
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, tt.status,
))
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, tt.status, stored.Status)
if tt.finished {
require.NotNil(t, stored.FinishedAt)
assert.False(t, stored.FinishedAt.Before(before))
} else {
assert.Nil(t, stored.FinishedAt)
}
assert.Equal(t, database.TargetTotals{
TargetID: targetID,
Delivered: tt.delivered,
Failed: tt.failed,
}, targetTotals(t, db, targetID))
})
}
}
// TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted covers a
// delivery retention deleted while the engine still held it. Failing
// it afterwards writes no row, so it adds no failure either: retention
// has already counted what it removed.
func TestUpdateDeliveryStatus_DeletedDeliveryIsNotCounted(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
require.NoError(t, db.Unscoped().
Delete(&database.Delivery{}, "id = ?", d.ID).Error)
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, database.DeliveryStatusFailed,
))
assert.Equal(t, database.TargetTotals{TargetID: targetID},
targetTotals(t, db, targetID))
}
// TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain covers a
// delivery settled a second time, as recovery can do when a worker has
// settled it since recovery read it. Neither status writes over the
// first, and the totals do not move.
func TestUpdateDeliveryStatus_FinishedDeliveryIsNotSettledAgain(
t *testing.T,
) {
t.Parallel()
finished := []database.DeliveryStatus{
database.DeliveryStatusDelivered,
database.DeliveryStatusFailed,
}
for _, first := range finished {
t.Run(string(first), func(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
event := seedEvent(t, db, `{}`)
targetID := uuid.New().String()
d := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusRetrying,
)
// The delivery as recovery read it, before the worker
// settled it.
readBefore := d
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &d, first,
))
var settled database.Delivery
require.NoError(t, db.First(&settled, "id = ?", d.ID).Error)
require.NotNil(t, settled.FinishedAt)
totals := targetTotals(t, db, targetID)
for _, again := range finished {
stale := readBefore
require.NoError(t, e.ExportUpdateDeliveryStatus(
db, &stale, again,
))
}
var stored database.Delivery
require.NoError(t, db.First(&stored, "id = ?", d.ID).Error)
assert.Equal(t, first, stored.Status)
require.NotNil(t, stored.FinishedAt)
assert.True(t, settled.FinishedAt.Equal(*stored.FinishedAt))
assert.Equal(t, totals, targetTotals(t, db, targetID))
})
}
}
File diff suppressed because it is too large Load Diff
+8 -306
View File
@@ -1,8 +1,8 @@
package delivery_test package delivery_test
import ( import (
"bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io" "io"
@@ -24,7 +24,6 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
// iSetup holds common integration test dependencies. // iSetup holds common integration test dependencies.
@@ -71,19 +70,17 @@ func iMainDB(t *testing.T) *gorm.DB {
t.TempDir(), "main-test.db", t.TempDir(), "main-test.db",
) )
// Opened the way the service opens the main database, so these dsn := fmt.Sprintf(
// tests cannot pass against journal and locking settings "file:%s?cache=shared&mode=rwc", dbPath,
// production does not use.
sqlDB, err := database.OpenSQLite(
dbPath, database.SQLiteModeCreate,
) )
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open( db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -358,14 +355,9 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -385,17 +377,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL) 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( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-target", cfg, 5, 2, &bodyStr, "retry-target", cfg, 5, 2, &bodyStr,
@@ -405,8 +386,6 @@ func TestProcessRetryTask_SuccessfulRetry(t *testing.T) {
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, event.Body, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -453,14 +432,9 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
s := newISetup(t) s := newISetup(t)
var receivedBody string
ts := httptest.NewServer( ts := httptest.NewServer(
http.HandlerFunc( http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) { func(w http.ResponseWriter, _ *http.Request) {
body, _ := io.ReadAll(r.Body)
receivedBody = string(body)
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)
}, },
), ),
@@ -482,12 +456,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
) )
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-large",
database.TargetTypeHTTP, cfg, 5,
)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-large", cfg, 5, 2, nil, "retry-large", cfg, 5, 2, nil,
@@ -497,8 +465,6 @@ func TestProcessRetryTask_LargeBody_FetchFromDB(
context.TODO(), &task, context.TODO(), &task,
) )
assert.Equal(t, largeBody, receivedBody)
iAssertStatus(t, s.WebhookDB, d.ID, iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
) )
@@ -592,12 +558,6 @@ func TestWorkerLifecycle_ProcessesRetryChannel(
bodyStr := event.Body bodyStr := event.Body
cfg := iHTTPConfig(ts.URL) cfg := iHTTPConfig(ts.URL)
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "retry-chan-test",
database.TargetTypeHTTP, cfg, 5,
)
task := iTask( task := iTask(
d, event, s.WebhookID, targetID, d, event, s.WebhookID, targetID,
"retry-chan-test", cfg, 5, 2, &bodyStr, "retry-chan-test", cfg, 5, 2, &bodyStr,
@@ -926,82 +886,6 @@ func TestSweepSingleRetry_TypeNoLongerRetries(
) )
} }
// 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.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed,
)
// The table exists in the per-webhook database because GORM
// migrates the Delivery relation's model alongside it. It must
// stay empty.
var targetRows int64
require.NoError(t, s.WebhookDB.
Table("targets").
Count(&targetRows).Error)
assert.Zero(t, targetRows,
"orphaned-retry terminal failure wrote a target row "+
"into the per-webhook event database",
)
var configs []string
require.NoError(t, s.WebhookDB.
Table("targets").
Pluck("config", &configs).Error)
assert.NotContains(
t, strings.Join(configs, " "), hookURL,
)
}
func TestRecoverSingleRetry_UnknownTargetType( func TestRecoverSingleRetry_UnknownTargetType(
t *testing.T, t *testing.T,
) { ) {
@@ -1137,85 +1021,6 @@ func TestRecoverInFlight_WithPendingDeliveries(
} }
} }
// TestRecoverInFlight_ReportsAMissingWebhookDatabase covers a webhook
// whose database file is gone, after a partial restore say. Restart
// recovery opens every webhook's database, so the empty one made in its
// place is reported at start, naming the file
// (https://git.eeqj.de/sneak/webhooker/issues/290).
func TestRecoverInFlight_ReportsAMissingWebhookDatabase(t *testing.T) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "lost-database")
var logs bytes.Buffer
dbMgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(), slog.New(slog.NewTextHandler(&logs, nil)),
)
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
assert.Contains(
t, logs.String(),
`level=WARN msg="created a new, empty database" webhook_id=`+
webhookID+" path="+dbMgr.DBPath(webhookID),
)
}
// TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead covers a
// webhook deleted from the web interface while restart recovery runs.
// Its database file is gone, and recovery must not create it again.
func TestRecoverInFlight_SkipsAWebhookDeletedAfterTheListIsRead(
t *testing.T,
) {
t.Parallel()
mainDB := iMainDB(t)
webhookID := uuid.New().String()
iCreateWebhook(t, mainDB, webhookID, "deleted-during-recovery")
// The first query to return is recovery's read of the list of
// webhooks. Deleting the webhook right after it puts the delete
// between that read and the opening of the webhook's database.
deleted := false
require.NoError(t, mainDB.Callback().Query().After("gorm:query").
Register("delete-after-list", func(*gorm.DB) {
if deleted {
return
}
deleted = true
require.NoError(t, mainDB.Delete(
&database.Webhook{}, "id = ?", webhookID,
).Error)
}))
dbMgr := database.NewTestWebhookDBManager(t.TempDir())
t.Cleanup(func() { _ = dbMgr.CloseAll() })
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB), dbMgr,
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 5 * time.Second}, 1,
)
engine.ExportRecoverInFlight(context.Background())
require.True(t, deleted)
assert.False(t, dbMgr.DBExists(webhookID))
}
// --- HTTP Config with custom headers --- // --- HTTP Config with custom headers ---
func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) { func TestDeliverHTTP_CustomTargetHeaders(t *testing.T) {
@@ -1320,83 +1125,6 @@ func TestDeliverHTTP_TargetTimeout(t *testing.T) {
iAssertResultFailed(t, db, del.ID) 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, 0, 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 // iSeedEventAndDelivery creates event + delivery
// for standalone tests. // for standalone tests.
func iSeedEventAndDelivery( func iSeedEventAndDelivery(
@@ -1507,32 +1235,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`, 0, 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 --- // --- Notify batching ---
func TestNotify_MultipleTasks(t *testing.T) { func TestNotify_MultipleTasks(t *testing.T) {
-100
View File
@@ -2,14 +2,12 @@ package delivery_test
import ( import (
"context" "context"
"path/filepath"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm/clause"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -271,101 +269,3 @@ func TestEngine_StopHookHonoursStopTimeout(t *testing.T) {
requireStopHookExpires(t, lc.hooks[0], "delivery engine") requireStopHookExpires(t, lc.hooks[0], "delivery engine")
} }
// deliverToArchive gives the setup's webhook a database target,
// runs one delivery to it through the running engine, and returns
// the target's ID and archive file path. The archive writer holds
// the file open afterwards.
func deliverToArchive(t *testing.T, s iSetup) (string, string) {
t.Helper()
iCreateWebhook(t, s.MainDB, s.WebhookID, "hook")
tgt := &database.Target{
WebhookID: s.WebhookID,
Name: "archive",
Type: database.TargetTypeDatabase,
}
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 tgt.ID, 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)
targetID, _ := deliverToArchive(t, s)
release := make(chan struct{})
t.Cleanup(func() {
close(release)
s.Engine.EvictWebhook(s.WebhookID)
})
s.Engine.ExportWedgeWorker(release)
requireStopHookExpires(t, lc.hooks[0], "delivery engine")
require.True(
t, s.Engine.ExportArchiveHandleOpen(targetID),
"a stop that timed out must not close archive writers",
)
}
+38 -328
View File
@@ -3,9 +3,9 @@ package delivery_test
import ( import (
"bytes" "bytes"
"context" "context"
"database/sql"
"encoding/json" "encoding/json"
"fmt" "fmt"
"io"
"log/slog" "log/slog"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -18,7 +18,6 @@ import (
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
@@ -26,8 +25,6 @@ import (
_ "modernc.org/sqlite" _ "modernc.org/sqlite"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
"sneak.berlin/go/webhooker/internal/metrics"
) )
// testContentType is the event content type used in tests. // testContentType is the event content type used in tests.
@@ -40,19 +37,17 @@ func testWebhookDB(t *testing.T) *gorm.DB {
t.TempDir(), "events-test.db", t.TempDir(), "events-test.db",
) )
// Opened the way the service opens a per-webhook database, so dsn := fmt.Sprintf(
// these tests cannot pass against journal and locking settings "file:%s?cache=shared&mode=rwc", dbPath,
// production does not use.
sqlDB, err := database.OpenSQLite(
dbPath, database.SQLiteModeCreate,
) )
sqlDB, err := sql.Open("sqlite", dsn)
require.NoError(t, err) require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() }) t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open( db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
&gorm.Config{Logger: gormlog.New(slog.New(slog.DiscardHandler))},
) )
require.NoError(t, err) require.NoError(t, err)
@@ -60,10 +55,7 @@ func testWebhookDB(t *testing.T) *gorm.DB {
&database.Event{}, &database.Event{},
&database.Delivery{}, &database.Delivery{},
&database.DeliveryResult{}, &database.DeliveryResult{},
&database.EventTotals{},
&database.TargetTotals{},
)) ))
require.NoError(t, db.Create(&database.EventTotals{}).Error)
return db return db
} }
@@ -354,15 +346,23 @@ func TestDeliverDatabase_ImmediateSuccess(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real, so the engine needs // The database target archives for real now, so the engine
// the target in the main database and a data directory. // needs a webhook DB manager to locate the data directory.
env := setupArchiveTest(t) e := delivery.NewTestEngineWithDB(
tgt := env.seedDatabaseTarget(t, "") nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
event := seedEvent(t, db, `{"db":"target"}`) event := seedEvent(t, db, `{"db":"target"}`)
d := seedDatabaseTargetDelivery(t, db, event, tgt) d := seedDatabaseTargetDelivery(t, db, event, "")
env.eng.ExportDeliverDatabase(db, d) e.ExportDeliverDatabase(db, d)
var updated database.Delivery var updated database.Delivery
@@ -894,100 +894,6 @@ func TestDeliverHTTP_CircuitBreakerBlocks(t *testing.T) {
) )
} }
// recordingScheduler keeps the delay of every retry it is asked to
// schedule, and schedules nothing.
type recordingScheduler struct {
delays []time.Duration
}
func (s *recordingScheduler) ScheduleRetry(
_ delivery.Task, delay time.Duration,
) {
s.delays = append(s.delays, delay)
}
// TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks proves that while a
// half-open breaker's one probe delivery is in flight, every other task
// for the target is put back with a whole cooldown as its delay rather
// than none, and that its status is written the first time the breaker
// turns it away and not on each pass after that.
func TestDeliverHTTP_HalfOpenBreakerDelaysQueuedTasks(t *testing.T) {
t.Parallel()
db := testWebhookDB(t)
e := testEngine(t, 1)
// Every write of retrying moves the retry counter, so on a registry
// this test owns the counter is the number of those writes.
reg := prometheus.NewRegistry()
e.ExportSetMetrics(metrics.New(reg))
targetID := uuid.New().String()
cb := newShortCooldownCB(t)
e.ExportSetCircuitBreaker(targetID, cb)
for range delivery.ExportDefaultFailureThreshold {
cb.RecordFailure()
}
time.Sleep(60 * time.Millisecond)
require.True(t, cb.Allow(), "the probe delivery should go through")
require.Equal(t, delivery.CircuitHalfOpen, cb.State())
cfg := newHTTPTargetConfig(
"http://will-not-be-called.invalid",
)
sched := &recordingScheduler{}
const queued, passes = 3, 4
for range queued {
event := seedEvent(t, db, `{"cb":"half-open"}`)
dlv := seedDelivery(
t, db, event.ID, targetID,
database.DeliveryStatusPending,
)
for range passes {
// Each pass starts from the stored row, as a retry does.
var row database.Delivery
require.NoError(t, db.First(
&row, "id = ?", dlv.ID,
).Error)
fix := buildHTTPFixture(
row, event, targetID,
"test-cb-half-open", cfg, 5, 1,
)
e.ExportDeliverHTTPWithScheduler(
context.TODO(), db, fix.Delivery, fix.Task, sched,
)
}
assertDeliveryStatus(t, db, dlv.ID,
database.DeliveryStatusRetrying,
)
}
require.Len(t, sched.delays, queued*passes)
for _, delay := range sched.delays {
// The cooldown newShortCooldownCB gives the breaker.
assert.Equal(t, 50*time.Millisecond, delay,
"a task turned away while half-open should wait "+
"a whole cooldown",
)
}
assert.InDelta(t, float64(queued),
mCounter(t, reg, mRetries, mTypeHTTP), 0,
"status should be written once per task, not once per pass",
)
}
func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) { func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1018,62 +924,6 @@ func TestGetCircuitBreaker_CreatesOnDemand(t *testing.T) {
) )
} }
// TestStateAndCooldown_ReadsHTTPAndSlackBreakers proves the engine
// reads the state of an http or a slack target's circuit breaker, with
// what is left of its cooldown while it is open, and no cooldown while
// it is half-open, once it closes, or for a target with no breaker.
func TestStateAndCooldown_ReadsHTTPAndSlackBreakers(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
httpID := uuid.New().String()
slackID := uuid.New().String()
state, cooldown := e.StateAndCooldown(httpID)
assert.Equal(t, delivery.CircuitClosed, state, "no breaker")
assert.Zero(t, cooldown, "no breaker")
httpCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetCircuitBreaker(httpID, httpCB)
slackCB := delivery.NewTestCircuitBreaker(1, time.Hour)
e.ExportSetSlackCircuitBreaker(slackID, slackCB)
httpCB.RecordFailure()
slackCB.RecordFailure()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitOpen, state)
assert.Greater(t, cooldown, 59*time.Minute)
assert.LessOrEqual(t, cooldown, time.Hour)
}
httpCB.RecordSuccess()
slackCB.RecordSuccess()
for _, id := range []string{httpID, slackID} {
state, cooldown := e.StateAndCooldown(id)
assert.Equal(t, delivery.CircuitClosed, state, "closed")
assert.Zero(t, cooldown, "closed")
}
// A breaker with no cooldown goes half-open on the first Allow
// after it trips, letting that one delivery through to test the
// target.
halfOpenID := uuid.New().String()
halfOpenCB := delivery.NewTestCircuitBreaker(1, 0)
e.ExportSetCircuitBreaker(halfOpenID, halfOpenCB)
halfOpenCB.RecordFailure()
require.True(t, halfOpenCB.Allow())
state, cooldown = e.StateAndCooldown(halfOpenID)
assert.Equal(t, delivery.CircuitHalfOpen, state)
assert.Zero(t, cooldown, "half-open")
}
func TestParseHTTPConfig_Valid(t *testing.T) { func TestParseHTTPConfig_Valid(t *testing.T) {
t.Parallel() t.Parallel()
@@ -1115,21 +965,6 @@ func TestParseHTTPConfig_MissingURL(t *testing.T) {
) )
} }
func TestParseHTTPConfig_Undecodable(t *testing.T) {
t.Parallel()
e := testEngine(t, 1)
_, err := e.ExportParseHTTPConfig(
`{"url":"https://example.com/hook","timeout":"soon"}`,
)
assert.Error(t, err,
"config that does not decode should return error, "+
"even when the part that did names a URL",
)
}
func TestScheduleRetry_SendsToRetryChannel( func TestScheduleRetry_SendsToRetryChannel(
t *testing.T, t *testing.T,
) { ) {
@@ -1235,10 +1070,6 @@ func TestIsForwardableHeader(t *testing.T) {
assert.False(t, assert.False(t,
delivery.ExportIsForwardableHeader("Content-Length"), delivery.ExportIsForwardableHeader("Content-Length"),
) )
assert.False(t,
delivery.ExportIsForwardableHeader("Content-Type"),
)
} }
func TestTruncate(t *testing.T) { func TestTruncate(t *testing.T) {
@@ -1313,137 +1144,11 @@ func TestDoHTTPRequest_ForwardsHeaders(t *testing.T) {
testContentType, testContentType,
receivedHeaders.Get("Content-Type"), receivedHeaders.Get("Content-Type"),
) )
}
// A response that ends before the length it announced is an error, not assert.Equal(t,
// a short body. "webhooker/1.0",
func TestDoHTTPRequest_CutShortResponseIsAnError(t *testing.T) { receivedHeaders.Get("User-Agent"),
t.Parallel()
ts := httptest.NewServer(
http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Length", "100")
_, _ = w.Write([]byte("cut short"))
},
),
) )
defer ts.Close()
e := testEngine(t, 1)
_, body, _, err := e.ExportDoHTTPRequest(
context.TODO(),
&delivery.HTTPTargetConfig{URL: ts.URL},
&database.Event{},
)
require.ErrorIs(t, err, io.ErrUnexpectedEOF)
assert.Empty(t, body)
}
// The event's stored inbound headers carry the same Content-Type the
// receiver saved as the event's ContentType, so a delivery could send
// it twice. It must go out exactly once, with a Content-Type configured
// on the target winning, then the event's ContentType.
func TestApplyRequestHeaders_SendsOneContentType(t *testing.T) {
t.Parallel()
cases := map[string]struct {
inbound string
event string
configured string
want []string
}{
"inbound and event agree": {
inbound: testContentType,
event: testContentType,
want: []string{testContentType},
},
"inbound and event disagree": {
inbound: "text/plain",
event: testContentType,
want: []string{testContentType},
},
"event has none": {
inbound: testContentType,
want: nil,
},
"target configures its own": {
inbound: testContentType,
event: testContentType,
configured: "application/xml",
want: []string{"application/xml"},
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
inbound, err := json.Marshal(map[string][]string{
headerContentType: {tc.inbound},
})
require.NoError(t, err)
cfg := &delivery.HTTPTargetConfig{}
if tc.configured != "" {
cfg.Headers = map[string]string{
headerContentType: tc.configured,
}
}
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: string(inbound),
ContentType: tc.event,
},
cfg,
"webhooker/dev",
)
assert.Equal(t,
tc.want, req.Header.Values(headerContentType),
)
})
}
}
// Stored inbound headers that do not decode forward nothing, not the
// part of them that happened to decode.
func TestApplyRequestHeaders_UndecodableInboundForwardsNothing(
t *testing.T,
) {
t.Parallel()
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: `{"X-Custom":["value1"],"X-Broken":"not a list"}`,
},
&delivery.HTTPTargetConfig{},
"webhooker/dev",
)
assert.Empty(t, names)
assert.Empty(t, req.Header.Get("X-Custom"))
} }
func TestProcessDelivery_RoutesToCorrectHandler( func TestProcessDelivery_RoutesToCorrectHandler(
@@ -1453,27 +1158,32 @@ func TestProcessDelivery_RoutesToCorrectHandler(
db := testWebhookDB(t) db := testWebhookDB(t)
// The database target archives for real, so the engine needs // The database target archives for real now, so the engine
// the target in the main database and a data directory. // needs a webhook DB manager to locate the data directory.
env := setupArchiveTest(t) e := delivery.NewTestEngineWithDB(
archive := env.seedDatabaseTarget(t, "") nil,
database.NewTestWebhookDBManager(t.TempDir()),
slog.New(slog.NewTextHandler(
os.Stderr,
&slog.HandlerOptions{Level: slog.LevelDebug},
)),
&http.Client{Timeout: 5 * time.Second},
1,
)
tests := []struct { tests := []struct {
name string name string
targetType database.TargetType targetType database.TargetType
targetID string
wantStatus database.DeliveryStatus wantStatus database.DeliveryStatus
}{ }{
{ {
"database target", "database target",
database.TargetTypeDatabase, database.TargetTypeDatabase,
archive.ID,
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
{ {
"log target", "log target",
database.TargetTypeLog, database.TargetTypeLog,
uuid.New().String(),
database.DeliveryStatusDelivered, database.DeliveryStatusDelivered,
}, },
} }
@@ -1483,7 +1193,7 @@ func TestProcessDelivery_RoutesToCorrectHandler(
t.Parallel() t.Parallel()
runRoutingSubtest( runRoutingSubtest(
t, db, env.eng, tt.targetType, tt.targetID, t, db, e, tt.targetType,
tt.wantStatus, tt.wantStatus,
) )
}) })
@@ -1495,7 +1205,6 @@ func runRoutingSubtest(
db *gorm.DB, db *gorm.DB,
e *delivery.Engine, e *delivery.Engine,
targetType database.TargetType, targetType database.TargetType,
targetID string,
wantStatus database.DeliveryStatus, wantStatus database.DeliveryStatus,
) { ) {
t.Helper() t.Helper()
@@ -1503,7 +1212,8 @@ func runRoutingSubtest(
event := seedEvent(t, db, `{"routing":"test"}`) event := seedEvent(t, db, `{"routing":"test"}`)
dlv := seedDelivery( dlv := seedDelivery(
t, db, event.ID, targetID, t, db, event.ID,
uuid.New().String(),
database.DeliveryStatusPending, database.DeliveryStatusPending,
) )
@@ -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, 0, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed,
)
assertNoTargetRows(t, s.DBMgr.DBPath(s.WebhookID))
}
-442
View File
@@ -1,442 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"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/delivery"
)
// tsEventCreatedAt is the receipt time seeded on the events these
// tests deliver. It is far enough from both the zero time and from
// now that neither can be mistaken for it.
func tsEventCreatedAt() time.Time {
return time.Date(
2026, time.March, 4, 5, 6, 7, 0, time.UTC,
)
}
// tsZeroStamp is what a Slack message renders when the event handed
// to FormatSlackMessage carries no CreatedAt.
const tsZeroStamp = "*Timestamp:* `0001-01-01T00:00:00Z`"
// tsEventBody is the body seeded on every event in this file. It is
// small enough that a Task can inline it.
const tsEventBody = `{"hello":"world"}`
// tsUndeliverableHook stands in for a Slack incoming webhook on the
// tests that never send: the config parser requires a URL, but no
// request is made.
const tsUndeliverableHook = "https://hooks.slack.com/services/T/B/x"
// tsSink is a stand-in Slack incoming webhook that records the raw
// body posted to it.
type tsSink struct {
*httptest.Server
bodies chan []byte
}
func newTSSink(t *testing.T) *tsSink {
t.Helper()
s := &tsSink{bodies: make(chan []byte, 8)}
s.Server = httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
select {
case s.bodies <- body:
default:
}
w.WriteHeader(http.StatusOK)
},
))
t.Cleanup(s.Close)
return s
}
// text returns the Slack message text from the single payload the
// sink received.
func (s *tsSink) text(t *testing.T) string {
t.Helper()
select {
case raw := <-s.bodies:
t.Logf("raw slack payload: %s", raw)
var payload struct {
Text string `json:"text"`
}
require.NoError(t, json.Unmarshal(raw, &payload))
return payload.Text
case <-time.After(5 * time.Second):
t.Fatal("slack sink received no payload")
return ""
}
}
func tsSlackConfig(t *testing.T, url string) string {
t.Helper()
data, err := json.Marshal(
delivery.SlackTargetConfig{WebhookURL: url},
)
require.NoError(t, err)
return string(data)
}
// tsSeedEvent writes an event whose CreatedAt is tsEventCreatedAt
// rather than the write time, so an assertion on the rendered
// timestamp cannot pass by accident against "roughly now".
func tsSeedEvent(
t *testing.T, db *gorm.DB, webhookID string,
) database.Event {
t.Helper()
event := database.Event{
WebhookID: webhookID,
EntrypointID: uuid.New().String(),
Method: http.MethodPost,
Headers: `{}`,
Body: tsEventBody,
ContentType: "application/json",
}
event.ID = uuid.New().String()
event.CreatedAt = tsEventCreatedAt()
event.UpdatedAt = tsEventCreatedAt()
require.NoError(t, db.Create(&event).Error)
var stored database.Event
require.NoError(t,
db.First(&stored, "id = ?", event.ID).Error,
)
require.Equal(t,
tsEventCreatedAt().UTC(), stored.CreatedAt.UTC(),
"seeded created_at did not round-trip",
)
return event
}
// tsSeedTarget writes the slack target row into the main database.
// The retry path confirms the target still exists before sending.
func tsSeedTarget(
t *testing.T, mainDB *gorm.DB, webhookID, config string,
) database.Target {
t.Helper()
target := database.Target{
WebhookID: webhookID,
Name: "slack-sink",
Type: database.TargetTypeSlack,
Config: config,
Active: true,
}
require.NoError(t, mainDB.Create(&target).Error)
return target
}
func tsTask(
d database.Delivery,
event database.Event,
webhookID string,
target database.Target,
attemptNum int,
body *string,
) delivery.Task {
return delivery.Task{
DeliveryID: d.ID,
EventID: event.ID,
WebhookID: webhookID,
EntrypointID: event.EntrypointID,
TargetID: target.ID,
TargetName: target.Name,
TargetType: database.TargetTypeSlack,
TargetConfig: target.Config,
MaxRetries: 0,
Method: event.Method,
Headers: event.Headers,
ContentType: event.ContentType,
Body: body,
AttemptNum: attemptNum,
}
}
func tsAssertRealTimestamp(t *testing.T, text string) {
t.Helper()
assert.NotContains(t, text, tsZeroStamp,
"slack message carries the zero timestamp",
)
assert.Contains(t, text,
"*Timestamp:* `"+
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
"slack message does not carry the event's receipt time",
)
}
// tsCase is one end-to-end delivery of a seeded event to a slack
// sink, over whichever engine path `process` names.
type tsCase struct {
// status is the delivery row's status before the engine runs.
// The retry path refuses a delivery that is not retrying.
status database.DeliveryStatus
// inlineBody mirrors a Task built for a body under
// MaxInlineBodySize. When false the engine reads the body back
// from the stored row.
inlineBody bool
attemptNum int
process func(
ctx context.Context, e *delivery.Engine, task *delivery.Task,
)
}
// run delivers one event through the named path and returns the
// Slack message text the sink received.
func (c tsCase) run(t *testing.T) (iSetup, database.Delivery, string) {
t.Helper()
s := newISetup(t)
sink := newTSSink(t)
cfg := tsSlackConfig(t, sink.URL)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID, c.status,
)
var body *string
if c.inlineBody {
bodyStr := event.Body
body = &bodyStr
}
task := tsTask(
d, event, s.WebhookID, target, c.attemptNum, body,
)
c.process(context.TODO(), s.Engine, &task)
return s, d, sink.text(t)
}
// TestSlackFirstAttemptCarriesEventTimestamp covers the path an
// event takes on its first delivery: the task comes from the
// receiver and the engine reconstructs the event from it.
func TestSlackFirstAttemptCarriesEventTimestamp(t *testing.T) {
t.Parallel()
s, d, text := tsCase{
status: database.DeliveryStatusPending,
inlineBody: true,
attemptNum: 1,
process: func(
ctx context.Context,
e *delivery.Engine,
task *delivery.Task,
) {
e.ExportProcessNewTask(ctx, task)
},
}.run(t)
tsAssertRealTimestamp(t, text)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
}
// TestSlackFirstAttemptLargeBodyCarriesEventTimestamp covers the
// first-attempt path for an event whose body exceeded
// MaxInlineBodySize, so the task carries no body and the engine
// reads it back from the stored row.
func TestSlackFirstAttemptLargeBodyCarriesEventTimestamp(
t *testing.T,
) {
t.Parallel()
_, _, text := tsCase{
status: database.DeliveryStatusPending,
inlineBody: false,
attemptNum: 1,
process: func(
ctx context.Context,
e *delivery.Engine,
task *delivery.Task,
) {
e.ExportProcessNewTask(ctx, task)
},
}.run(t)
tsAssertRealTimestamp(t, text)
}
// TestSlackRetryCarriesEventTimestamp covers the retry path, which
// reconstructs the event from the same task the first attempt used.
func TestSlackRetryCarriesEventTimestamp(t *testing.T) {
t.Parallel()
s, d, text := tsCase{
status: database.DeliveryStatusRetrying,
inlineBody: true,
attemptNum: 2,
process: func(
ctx context.Context,
e *delivery.Engine,
task *delivery.Task,
) {
e.ExportProcessRetryTask(ctx, task)
},
}.run(t)
tsAssertRealTimestamp(t, text)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
}
// TestFormatSlackMessageOverTaskReconstructedEvent asserts on the
// formatted message directly, over the event the delivery paths
// reconstruct from a Task. It is the unit-level guard under the
// end-to-end tests: revert the CreatedAt population in hydrateEvent
// and this fails on the zero timestamp.
func TestFormatSlackMessageOverTaskReconstructedEvent(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusPending,
)
bodyStr := event.Body
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
rebuilt, err := s.Engine.ExportEventForTask(
s.WebhookDB, &task,
)
require.NoError(t, err)
assert.False(t, rebuilt.CreatedAt.IsZero(),
"reconstructed event carries the zero time",
)
assert.Equal(t,
tsEventCreatedAt().UTC(), rebuilt.CreatedAt.UTC(),
)
tsAssertRealTimestamp(
t, delivery.FormatSlackMessage(&rebuilt),
)
}
// TestFormatSlackMessageZeroTimestamp asserts the rendering choice
// directly, without going through the engine: a zero CreatedAt (the
// shape a reaped-row fallback produces) renders as "unknown" rather
// than the year-1 zero time, while a real CreatedAt still renders as
// RFC3339.
func TestFormatSlackMessageZeroTimestamp(t *testing.T) {
t.Parallel()
zeroEvent := database.Event{
Method: http.MethodPost,
ContentType: testContentType,
Body: tsEventBody,
}
zeroText := delivery.FormatSlackMessage(&zeroEvent)
assert.NotContains(t, zeroText, "0001-01-01",
"slack message carries the zero-time year",
)
assert.Contains(t, zeroText, "*Timestamp:* `unknown`",
"slack message does not mark an unset receipt time as unknown",
)
nonZeroEvent := zeroEvent
nonZeroEvent.CreatedAt = tsEventCreatedAt()
nonZeroText := delivery.FormatSlackMessage(&nonZeroEvent)
assert.Contains(t, nonZeroText,
"*Timestamp:* `"+
tsEventCreatedAt().UTC().Format(time.RFC3339)+"`",
"slack message does not render a real receipt time as RFC3339",
)
}
// TestEventReconstructionSurvivesAReapedRow pins the fallback: an
// event row reaped by retention while its delivery still holds the
// body inline is still delivered, with the receipt time unset,
// rather than dropped.
func TestEventReconstructionSurvivesAReapedRow(t *testing.T) {
t.Parallel()
s := newISetup(t)
cfg := tsSlackConfig(t, tsUndeliverableHook)
target := tsSeedTarget(t, s.MainDB, s.WebhookID, cfg)
event := tsSeedEvent(t, s.WebhookDB, s.WebhookID)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, target.ID,
database.DeliveryStatusPending,
)
bodyStr := event.Body
task := tsTask(d, event, s.WebhookID, target, 1, &bodyStr)
require.NoError(t, s.WebhookDB.Unscoped().Delete(
&database.Event{}, "id = ?", event.ID,
).Error)
rebuilt, err := s.Engine.ExportEventForTask(
s.WebhookDB, &task,
)
require.NoError(t, err)
assert.Equal(t, bodyStr, rebuilt.Body)
assert.True(t, rebuilt.CreatedAt.IsZero())
// A task with no inlined body has nothing left to deliver, so
// the same reaped row is an error there.
noBody := task
noBody.Body = nil
_, err = s.Engine.ExportEventForTask(s.WebhookDB, &noBody)
require.Error(t, err)
}
+32 -223
View File
@@ -5,15 +5,11 @@ import (
"log/slog" "log/slog"
"net" "net"
"net/http" "net/http"
"net/netip"
"net/url"
"time" "time"
"github.com/prometheus/client_golang/prometheus"
"go.uber.org/fx" "go.uber.org/fx"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/metrics"
) )
// ErrExportArchiveWriterEvicted exposes the sentinel returned by // ErrExportArchiveWriterEvicted exposes the sentinel returned by
@@ -28,37 +24,11 @@ const (
ExportRetryChannelSize = retryChannelSize ExportRetryChannelSize = retryChannelSize
ExportDefaultFailureThreshold = defaultFailureThreshold ExportDefaultFailureThreshold = defaultFailureThreshold
ExportDefaultCooldown = defaultCooldown ExportDefaultCooldown = defaultCooldown
// ExportMaxBodyLog is the cap the engine applies to a
// recorded response body. The event log's handling of a cut
// response is written against this number, so a test has to
// be able to name it.
ExportMaxBodyLog = maxBodyLog
// ExportPendingSweepMinAge is how long a delivery must sit at
// pending before the sweep treats it as stranded. A test has to
// name it to age a row past the bound.
ExportPendingSweepMinAge = pendingSweepMinAge
) )
// NewTestGuard builds an SSRF Guard from an explicit egress // ExportIsBlockedIP exposes isBlockedIP for testing.
// allowlist, without going through config. Passing no prefixes func ExportIsBlockedIP(ip net.IP) bool {
// yields the default guard, which blocks every private/reserved return isBlockedIP(ip)
// range.
func NewTestGuard(allowed ...netip.Prefix) *Guard {
return &Guard{allowed: allowed}
}
// ExportCheckIP exposes the guard's single decision point, so a
// test can assert the policy both the validator and the dialer
// inherit without needing a live destination.
func (g *Guard) ExportCheckIP(ip net.IP) error {
return g.checkIP(ip)
}
// ExportAlwaysBlockedNetworks exposes alwaysBlockedNetworks.
func ExportAlwaysBlockedNetworks() []*net.IPNet {
return alwaysBlockedNetworks
} }
// ExportBlockedNetworks exposes blockedNetworks. // ExportBlockedNetworks exposes blockedNetworks.
@@ -66,28 +36,11 @@ func ExportBlockedNetworks() []*net.IPNet {
return blockedNetworks return blockedNetworks
} }
// ExportBlockedPublicNetworks exposes blockedPublicNetworks.
func ExportBlockedPublicNetworks() []*net.IPNet {
return blockedPublicNetworks
}
// ExportIsForwardableHeader exposes isForwardableHeader. // ExportIsForwardableHeader exposes isForwardableHeader.
func ExportIsForwardableHeader(name string) bool { func ExportIsForwardableHeader(name string) bool {
return isForwardableHeader(name) return isForwardableHeader(name)
} }
// ExportApplyRequestHeaders exposes applyRequestHeaders, so a test
// can inspect the header set an outbound delivery actually carries
// and the origin-scoped names it reports for the redirect policy.
func ExportApplyRequestHeaders(
req *http.Request,
event *database.Event,
cfg *HTTPTargetConfig,
userAgent string,
) []string {
return applyRequestHeaders(req, event, cfg, userAgent)
}
// ExportTruncate exposes truncate for testing. // ExportTruncate exposes truncate for testing.
func ExportTruncate(s string, maxLen int) string { func ExportTruncate(s string, maxLen int) string {
return truncate(s, maxLen) return truncate(s, maxLen)
@@ -103,19 +56,6 @@ func (e *Engine) ExportDeliverHTTP(
e.httpTarget.Deliver(ctx, webhookDB, d, task, e) e.httpTarget.Deliver(ctx, webhookDB, d, task, e)
} }
// ExportDeliverHTTPWithScheduler delivers via the http target, handing
// any retry to sched instead of the engine, so a test can see the
// delay each retry is given.
func (e *Engine) ExportDeliverHTTPWithScheduler(
ctx context.Context,
webhookDB *gorm.DB,
d *database.Delivery,
task *Task,
sched Scheduler,
) {
e.httpTarget.Deliver(ctx, webhookDB, d, task, sched)
}
// ExportDeliverDatabase delivers via the database target. // ExportDeliverDatabase delivers via the database target.
func (e *Engine) ExportDeliverDatabase( func (e *Engine) ExportDeliverDatabase(
webhookDB *gorm.DB, d *database.Delivery, webhookDB *gorm.DB, d *database.Delivery,
@@ -152,16 +92,6 @@ func (e *Engine) ExportDeliverSlack(
) )
} }
// ExportUpdateDeliveryStatus exposes updateDeliveryStatus. It passes no
// target type, so no metric moves.
func (e *Engine) ExportUpdateDeliveryStatus(
webhookDB *gorm.DB,
d *database.Delivery,
status database.DeliveryStatus,
) error {
return e.updateDeliveryStatus(webhookDB, d, "", status)
}
// ExportProcessNewTask exposes processNewTask. // ExportProcessNewTask exposes processNewTask.
func (e *Engine) ExportProcessNewTask( func (e *Engine) ExportProcessNewTask(
ctx context.Context, task *Task, ctx context.Context, task *Task,
@@ -176,16 +106,6 @@ func (e *Engine) ExportProcessRetryTask(
e.processRetryTask(ctx, task) e.processRetryTask(ctx, task)
} }
// ExportEventForTask exposes the event reconstruction the delivery
// paths run: buildEventFromTask followed by hydrateEvent.
func (e *Engine) ExportEventForTask(
webhookDB *gorm.DB, task *Task,
) (database.Event, error) {
return e.hydrateEvent(
webhookDB, buildEventFromTask(task), task,
)
}
// ExportProcessDelivery exposes processDelivery. // ExportProcessDelivery exposes processDelivery.
func (e *Engine) ExportProcessDelivery( func (e *Engine) ExportProcessDelivery(
ctx context.Context, ctx context.Context,
@@ -204,22 +124,6 @@ func (e *Engine) ExportGetCircuitBreaker(
return e.httpTarget.getCircuitBreaker(targetID) return e.httpTarget.getCircuitBreaker(targetID)
} }
// ExportSetCircuitBreaker makes cb the http target's circuit breaker
// for targetID, so a test can use one with a short cooldown.
func (e *Engine) ExportSetCircuitBreaker(
targetID string, cb *CircuitBreaker,
) {
e.httpTarget.circuitBreakers.Store(targetID, cb)
}
// ExportSetSlackCircuitBreaker is ExportSetCircuitBreaker for the
// slack target.
func (e *Engine) ExportSetSlackCircuitBreaker(
targetID string, cb *CircuitBreaker,
) {
e.slackTarget.circuitBreakers.Store(targetID, cb)
}
// ExportParseHTTPConfig exposes parseHTTPConfig. // ExportParseHTTPConfig exposes parseHTTPConfig.
func (e *Engine) ExportParseHTTPConfig( func (e *Engine) ExportParseHTTPConfig(
configJSON string, configJSON string,
@@ -244,27 +148,12 @@ func (e *Engine) ExportDoHTTPRequest(
return e.httpTarget.doHTTPRequest(ctx, cfg, event) return e.httpTarget.doHTTPRequest(ctx, cfg, event)
} }
// ExportClientForRequest exposes the http target's // ExportClientForConfig exposes the http target's
// clientForRequest. // clientForConfig.
func (e *Engine) ExportClientForRequest( func (e *Engine) ExportClientForConfig(
cfg *HTTPTargetConfig, cfg *HTTPTargetConfig,
originScoped []string,
) *http.Client { ) *http.Client {
return e.httpTarget.clientForRequest(cfg, originScoped) return e.httpTarget.clientForConfig(cfg)
}
// ErrExportTooManyRedirects exposes the sentinel the redirect
// policy returns once a chain exceeds the hop cap. It carries the
// Err prefix rather than this file's usual Export one because it
// is a sentinel error.
var ErrExportTooManyRedirects = errTooManyRedirects
// ExportMaxDeliveryRedirects exposes the redirect hop cap.
const ExportMaxDeliveryRedirects = maxDeliveryRedirects
// ExportSameDeliveryOrigin exposes sameDeliveryOrigin.
func ExportSameDeliveryOrigin(origin, dest *url.URL) bool {
return sameDeliveryOrigin(origin, dest)
} }
// ExportClient returns the http target's shared HTTP client. // ExportClient returns the http target's shared HTTP client.
@@ -342,51 +231,6 @@ func (e *Engine) ExportWedgeWorker(release <-chan struct{}) {
}) })
} }
// ExportInflightHeld reports how many deliveries the engine currently
// owns, so a test can prove ownership is released rather than leaked.
func (e *Engine) ExportInflightHeld() int {
return e.inflight.held()
}
// ExportRetainDelivery takes the first reference on a delivery, as the
// queueing side does. It lets a test put a delivery into the state a
// worker or a full channel would, without running the pool.
func (e *Engine) ExportRetainDelivery(deliveryID string) bool {
return e.inflight.retainIdle(deliveryID)
}
// ExportRecoverRetryingDeliveries exposes recoverRetryingDeliveries.
func (e *Engine) ExportRecoverRetryingDeliveries(
webhookDB *gorm.DB, webhookID string,
) {
e.recoverRetryingDeliveries(webhookDB, webhookID)
}
// ExportFailMissingTarget exposes failMissingTarget, so a test can hand
// it a delivery as a batch read it earlier.
func (e *Engine) ExportFailMissingTarget(
webhookDB *gorm.DB,
webhookID string,
d *database.Delivery,
) {
e.failMissingTarget(webhookDB, webhookID, d)
}
// ExportSendRecoveredDeliveries exposes sendRecoveredDeliveries, so a
// test can hand it a target map that lacks a delivery's target.
func (e *Engine) ExportSendRecoveredDeliveries(
ctx context.Context,
webhookDB *gorm.DB,
deliveries []database.Delivery,
webhookID string,
targetMap map[string]database.Target,
settled map[string]struct{},
) {
e.sendRecoveredDeliveries(
ctx, webhookDB, deliveries, webhookID, targetMap, settled,
)
}
// ExportDeliveryCh returns the delivery channel. // ExportDeliveryCh returns the delivery channel.
func (e *Engine) ExportDeliveryCh() chan Task { func (e *Engine) ExportDeliveryCh() chan Task {
return e.deliveryCh return e.deliveryCh
@@ -409,7 +253,6 @@ func NewTestEngine(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
@@ -424,7 +267,6 @@ func NewTestEngineSmallRetry(
e := &Engine{ e := &Engine{
log: log, log: log,
retryCh: make(chan Task, 1), retryCh: make(chan Task, 1),
mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(nil) e.initTargets(nil)
@@ -447,24 +289,12 @@ func NewTestEngineWithDB(
deliveryCh: make(chan Task, deliveryChannelSize), deliveryCh: make(chan Task, deliveryChannelSize),
retryCh: make(chan Task, retryChannelSize), retryCh: make(chan Task, retryChannelSize),
workers: workers, workers: workers,
mtr: metrics.New(prometheus.NewRegistry()),
} }
e.initTargets(client) e.initTargets(client)
return e return e
} }
// ExportSetMetrics substitutes the engine's metric set, so a test can
// assert on collectors registered on a registry it holds.
func (e *Engine) ExportSetMetrics(mtr *metrics.Set) {
e.mtr = mtr
}
// ExportSampleQueueDepths runs one queue depth sample synchronously.
func (e *Engine) ExportSampleQueueDepths(ctx context.Context) {
e.sampleQueueDepths(ctx)
}
// NewTestCircuitBreaker creates a CircuitBreaker with // NewTestCircuitBreaker creates a CircuitBreaker with
// custom settings for testing. // custom settings for testing.
func NewTestCircuitBreaker( func NewTestCircuitBreaker(
@@ -482,7 +312,7 @@ func NewTestCircuitBreaker(
type ExportArchivedEvent = archivedEvent type ExportArchivedEvent = archivedEvent
// ExportArchiveWriter wraps an archiveWriter so black-box tests // ExportArchiveWriter wraps an archiveWriter so black-box tests
// can exercise the archive file mechanics. // can exercise the per-webhook archive file mechanics.
type ExportArchiveWriter struct { type ExportArchiveWriter struct {
w *archiveWriter w *archiveWriter
} }
@@ -501,38 +331,23 @@ func NewExportArchiveWriter(
return &ExportArchiveWriter{w: w} return &ExportArchiveWriter{w: w}
} }
// Write archives a row through the writer, into the file named // Write archives a row through the writer.
// without a period.
func (e *ExportArchiveWriter) Write( func (e *ExportArchiveWriter) Write(
row ExportArchivedEvent, expiry time.Duration, row ExportArchivedEvent, expiry time.Duration,
) error { ) error {
return e.w.write(row, expiry, "") 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. // Open opens the archive file, pruning when expiry is positive.
func (e *ExportArchiveWriter) Open(expiry time.Duration) error { func (e *ExportArchiveWriter) Open(expiry time.Duration) error {
return e.w.open(e.w.path, expiry) return e.w.open(expiry)
} }
// Reopen closes and reopens the archive file. // Reopen closes and reopens the archive file.
func (e *ExportArchiveWriter) Reopen( func (e *ExportArchiveWriter) Reopen(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.reopen(e.w.path, expiry) return e.w.reopen(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. // Reopens reports how many times the file has been opened.
@@ -556,7 +371,7 @@ func (e *ExportArchiveWriter) Path() string {
func (e *ExportArchiveWriter) OpenExisting( func (e *ExportArchiveWriter) OpenExisting(
expiry time.Duration, expiry time.Duration,
) error { ) error {
return e.w.openMode(e.w.path, archiveModeExisting, expiry) return e.w.openMode(archiveModeExisting, expiry)
} }
// SweepExpired runs an idle sweep of the archive. // SweepExpired runs an idle sweep of the archive.
@@ -572,12 +387,6 @@ func (e *ExportArchiveWriter) Evict() {
e.w.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 // HandleOpen reports whether the writer currently holds an open
// archive handle. // archive handle.
func (e *ExportArchiveWriter) HandleOpen() bool { func (e *ExportArchiveWriter) HandleOpen() bool {
@@ -597,16 +406,16 @@ func (e *ExportArchiveWriter) Same(
} }
// ExportArchiveWriterFor returns the archive writer the registry // ExportArchiveWriterFor returns the archive writer the registry
// currently caches for a database target, or nil when none is // currently caches for a webhook, or nil when none is cached. It
// cached. It never creates one, so a test can hold a reference to // never creates one, so a test can hold a reference to the very
// the very writer an eviction is about to detach. // writer an eviction is about to detach.
func (e *Engine) ExportArchiveWriterFor( func (e *Engine) ExportArchiveWriterFor(
targetID string, webhookID string,
) *ExportArchiveWriter { ) *ExportArchiveWriter {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
w, ok := e.dbTarget.writers[targetID] w, ok := e.dbTarget.writers[webhookID]
if !ok { if !ok {
return nil return nil
} }
@@ -615,26 +424,26 @@ func (e *Engine) ExportArchiveWriterFor(
} }
// ExportHasArchiveWriter reports whether the database target // ExportHasArchiveWriter reports whether the database target
// type currently caches an archive writer for a target. // currently caches an archive writer for a webhook.
func (e *Engine) ExportHasArchiveWriter( func (e *Engine) ExportHasArchiveWriter(
targetID string, webhookID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
defer e.dbTarget.mu.Unlock() defer e.dbTarget.mu.Unlock()
_, ok := e.dbTarget.writers[targetID] _, ok := e.dbTarget.writers[webhookID]
return ok return ok
} }
// ExportArchiveHandleOpen reports whether the cached archive // ExportArchiveHandleOpen reports whether the cached archive
// writer for a target holds an open database handle. It // writer for a webhook holds an open database handle. It
// returns false when no writer is cached. // returns false when no writer is cached.
func (e *Engine) ExportArchiveHandleOpen( func (e *Engine) ExportArchiveHandleOpen(
targetID string, webhookID string,
) bool { ) bool {
e.dbTarget.mu.Lock() e.dbTarget.mu.Lock()
w, ok := e.dbTarget.writers[targetID] w, ok := e.dbTarget.writers[webhookID]
e.dbTarget.mu.Unlock() e.dbTarget.mu.Unlock()
if !ok { if !ok {
@@ -648,12 +457,12 @@ func (e *Engine) ExportArchiveHandleOpen(
} }
// ExportEnsureArchiveWriter creates (if needed) and returns the // ExportEnsureArchiveWriter creates (if needed) and returns the
// archive file path of the cached writer for a target, so a // archive file path of the cached writer for a webhook, so a
// test can prime the registry the way a delivery would. // test can prime the registry the way a delivery would.
func (e *Engine) ExportEnsureArchiveWriter( func (e *Engine) ExportEnsureArchiveWriter(
targetID string, webhookID string,
) (string, error) { ) (string, error) {
w, err := e.dbTarget.writerFor(targetID) w, err := e.dbTarget.writerFor(webhookID)
if err != nil { if err != nil {
return "", err return "", err
} }
@@ -661,14 +470,14 @@ func (e *Engine) ExportEnsureArchiveWriter(
return w.path, nil return w.path, nil
} }
// ExportSweepWriterFor takes a target's registry writer exactly // ExportSweepWriterFor takes a webhook's registry writer exactly
// as the idle sweep does, reporting whether the sweep had to // as the idle sweep does, reporting whether the sweep had to
// create the entry. It lets a test drive the registry through the // create the entry. It lets a test drive the registry through the
// sweep's own entry point instead of choreographing goroutines. // sweep's own entry point instead of choreographing goroutines.
func (e *Engine) ExportSweepWriterFor( func (e *Engine) ExportSweepWriterFor(
targetID string, webhookID string,
) (*ExportArchiveWriter, bool, error) { ) (*ExportArchiveWriter, bool, error) {
w, created, err := e.dbTarget.sweepWriterFor(targetID) w, created, err := e.dbTarget.sweepWriterFor(webhookID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -679,9 +488,9 @@ func (e *Engine) ExportSweepWriterFor(
// ExportReleaseSweepWriter releases a sweep-created registry entry // ExportReleaseSweepWriter releases a sweep-created registry entry
// exactly as a finished sweep does. // exactly as a finished sweep does.
func (e *Engine) ExportReleaseSweepWriter( func (e *Engine) ExportReleaseSweepWriter(
targetID string, w *ExportArchiveWriter, webhookID string, w *ExportArchiveWriter,
) { ) {
e.dbTarget.releaseSweepWriter(targetID, w.w) e.dbTarget.releaseSweepWriter(webhookID, w.w)
} }
// NewTestArchiveSweeper builds an ArchiveSweeper backed by the // NewTestArchiveSweeper builds an ArchiveSweeper backed by the
-110
View File
@@ -1,110 +0,0 @@
package delivery
import "sync"
// inflightSet records which deliveries the engine currently owns.
//
// A delivery is owned from the moment a task for it is handed to a
// channel or to a retry timer until the engine has no further plan for
// it in memory. Restart recovery and both arms of the periodic sweep
// re-dispatch only deliveries the set does not hold, which is what
// makes them exact rather than a guess about how long a row has sat at
// pending.
//
// This replaces reasoning from timestamps. A delivery's row says
// pending from creation until its outcome is written, which covers
// four different situations — never dispatched, waiting in a channel,
// being attempted right now, and genuinely stranded — and no column
// distinguishes them. Only the engine knows which, and it knows
// exactly. `deliveryChannelSize` is 10000 against 10 workers, so a
// perfectly healthy delivery can wait far longer than any age bound
// worth setting before its attempt even begins; an age bound alone
// re-sends it. See
// https://git.eeqj.de/sneak/webhooker/issues/256.
//
// In-memory state is sufficient because a data directory admits one
// process: internal/datadir takes an flock on it at startup and a
// second instance refuses to run. Deliveries owned by a process that
// died are not in any successor's set, and restart recovery is what
// picks those up.
//
// References are counted rather than held as a plain set because
// ownership outlives the worker that took it. A target that schedules
// a retry from inside Deliver adds a reference while the worker still
// holds one, so the delivery stays owned across the gap between the
// worker returning and the timer firing — the window in which a sweep
// would otherwise find the row at retrying and send it again.
//
// The zero value is ready to use, and the Engine holds one by value.
// That is deliberate: an engine built by a constructor that forgot to
// initialise this would not refuse to re-dispatch anything, and the
// symptom would be duplicate deliveries rather than a failure anybody
// notices.
type inflightSet struct {
mu sync.Mutex
ids map[string]int
}
// retain adds a reference to a delivery the caller already knows the
// engine owns, so that ownership survives the current holder letting
// go. It cannot fail.
func (s *inflightSet) retain(deliveryID string) {
s.mu.Lock()
defer s.mu.Unlock()
if s.ids == nil {
s.ids = make(map[string]int)
}
s.ids[deliveryID]++
}
// retainIdle takes the first reference on a delivery, and reports
// whether it got it. It fails when the engine already owns the
// delivery, which is what makes two claimants — restart recovery and
// the sweep run concurrently, or two sweep arms — mutually exclusive
// rather than merely atomic.
func (s *inflightSet) retainIdle(deliveryID string) bool {
s.mu.Lock()
defer s.mu.Unlock()
if s.ids[deliveryID] > 0 {
return false
}
if s.ids == nil {
s.ids = make(map[string]int)
}
s.ids[deliveryID] = 1
return true
}
// release drops one reference. The delivery becomes eligible for
// re-dispatch again once the last one goes.
func (s *inflightSet) release(deliveryID string) {
s.mu.Lock()
defer s.mu.Unlock()
n := s.ids[deliveryID] - 1
if n <= 0 {
delete(s.ids, deliveryID)
return
}
s.ids[deliveryID] = n
}
// held reports how many deliveries the engine currently owns. It
// exists so a test can assert that ownership is released rather than
// leaked: a reference that is never dropped hides its delivery from
// every sweep for the life of the process, which is the one way this
// mechanism can fail silently.
func (s *inflightSet) held() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.ids)
}
-495
View File
@@ -1,495 +0,0 @@
package delivery_test
import (
"context"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"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/delivery"
)
// These tests pin the rule that decides whether a delivery may be
// handed back to a worker: the engine re-dispatches only what it does
// not already own. Age alone is not that rule — a healthy delivery
// waiting in a 10000-deep channel is old and must not be re-sent. See
// https://git.eeqj.de/sneak/webhooker/issues/256.
// fSweepSetup seeds the main database with the webhook row the sweep
// enumerates, and returns the setup.
func fSweepSetup(
t *testing.T, targetID, name string,
) iSetup {
t.Helper()
s := newISetup(t)
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, name,
database.TargetTypeLog, "", 0,
)
require.NoError(t, s.MainDB.Create(&database.Webhook{
BaseModel: database.BaseModel{ID: s.WebhookID},
UserID: uuid.New().String(),
Name: name,
}).Error)
return s
}
// fDrain collects every task the engine has queued.
//
// Every caller drives the dispatch paths synchronously and has already
// waited for them to return, so anything they queued is in the channel
// by now. The short grace covers nothing but scheduler jitter, and is
// kept small because one of these tests runs the drain forty times.
func fDrain(e *delivery.Engine) []delivery.Task {
var out []delivery.Task
for {
select {
case task := <-e.ExportDeliveryCh():
out = append(out, task)
case task := <-e.ExportRetryCh():
out = append(out, task)
case <-time.After(25 * time.Millisecond):
return out
}
}
}
// 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 := delivery.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)
}
// TestSweepLeavesAQueuedDeliveryAlone is the case the age bound cannot
// see. The delivery is queued and untouched, so its row is arbitrarily
// old and still perfectly healthy; only ownership distinguishes it
// from a stranded one.
func TestSweepLeavesAQueuedDeliveryAlone(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "queued")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"queued":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rAgePending(t, s.WebhookDB, d.ID)
// Queued exactly as the receiver queues it, and never dequeued:
// no workers are running in this engine.
s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID,
EventID: event.ID,
WebhookID: s.WebhookID,
TargetID: targetID,
}})
require.Equal(t, 1, s.Engine.ExportInflightHeld())
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
tasks := fDrain(s.Engine)
assert.Len(
t, tasks, 1,
"the sweep must not queue a delivery that is "+
"already waiting for a worker",
)
}
// TestRecoveryAndSweepDoNotDoubleDispatch drives the two entry points
// the engine starts concurrently against one aged pending row. Before
// ownership they both dispatched it.
func TestRecoveryAndSweepDoNotDoubleDispatch(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "racing")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"racing":true}`,
)
ctx := context.Background()
for range 40 {
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rAgePending(t, s.WebhookDB, d.ID)
var wg sync.WaitGroup
wg.Go(func() {
s.Engine.ExportRecoverPendingDeliveries(
ctx, s.WebhookDB, s.WebhookID,
)
})
wg.Go(func() {
s.Engine.ExportSweepWebhookRetries(
ctx, s.WebhookID,
)
})
wg.Wait()
tasks := fDrain(s.Engine)
require.Len(
t, tasks, 1,
"delivery %s dispatched %d times",
d.ID, len(tasks),
)
// No worker runs in this engine, so the reference the winner
// took is never released and earlier iterations' deliveries
// stay owned — which is itself the property under test, since
// both paths see them on every subsequent pass.
}
}
// TestConcurrentClaimsOfOneDeliveryYieldOneOwner exercises the
// exclusion directly, rather than arguing it from a SQL predicate.
func TestConcurrentClaimsOfOneDeliveryYieldOneOwner(
t *testing.T,
) {
t.Parallel()
eng := newISetup(t).Engine
deliveryID := uuid.New().String()
var (
wg sync.WaitGroup
mu sync.Mutex
won int
)
for range 64 {
wg.Go(func() {
if eng.ExportRetainDelivery(deliveryID) {
mu.Lock()
won++
mu.Unlock()
}
})
}
wg.Wait()
assert.Equal(t, 1, won)
assert.Equal(t, 1, eng.ExportInflightHeld())
}
// TestOwnershipIsReleasedAfterDelivery guards the other direction: a
// leaked reference hides a delivery from every sweep for the life of
// the process.
func TestOwnershipIsReleasedAfterDelivery(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "released",
database.TargetTypeLog, "", 0,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"released":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
s.Engine.ExportStart()
defer func() {
require.NoError(
t, s.Engine.ExportStop(context.Background()),
)
}()
body := `{"released":true}`
s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID,
EventID: event.ID,
WebhookID: s.WebhookID,
TargetID: targetID,
TargetName: "released",
TargetType: database.TargetTypeLog,
Body: &body,
EntrypointID: event.EntrypointID,
}})
iWaitForDelivered(t, s.WebhookDB, d.ID)
assert.Eventually(
t,
func() bool {
return s.Engine.ExportInflightHeld() == 0
},
2*time.Second, 20*time.Millisecond,
"the delivery stayed owned after it was delivered",
)
}
// TestNotifyAfterRecoveryDoesNotSendAgain is the startup race of
// https://git.eeqj.de/sneak/webhooker/issues/299. The receiver has
// written a delivery, restart recovery finds it pending, sends it and
// releases it, and only then does the receiver's Notify for it arrive.
// Nothing owns the delivery by then, so Notify takes it.
func TestNotifyAfterRecoveryDoesNotSendAgain(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "recovered")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"recovered":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
s.Engine.ExportStart()
defer func() {
require.NoError(
t, s.Engine.ExportStop(context.Background()),
)
}()
// Restart recovery sends the delivery and lets it go.
iWaitForDelivered(t, s.WebhookDB, d.ID)
require.Eventually(
t,
func() bool {
return s.Engine.ExportInflightHeld() == 0
},
5*time.Second, 20*time.Millisecond,
)
body := event.Body
s.Engine.Notify([]delivery.Task{{
DeliveryID: d.ID,
EventID: event.ID,
WebhookID: s.WebhookID,
TargetID: targetID,
TargetName: "recovered",
TargetType: database.TargetTypeLog,
Body: &body,
EntrypointID: event.EntrypointID,
}})
// Notify took the delivery, and a worker releases it once it has
// run the task.
require.Eventually(
t,
func() bool {
return s.Engine.ExportInflightHeld() == 0
},
5*time.Second, 20*time.Millisecond,
)
assert.Len(
t, iResults(t, s.WebhookDB, d.ID), 1,
"the delivery was sent a second time",
)
}
// TestRetryingRecoverySkipsASuccessfulResult is the retrying-side twin
// of the pending reconcile. A second attempt that reached the receiver
// and whose status write then failed sits at retrying holding a
// successful result, and re-sending it is the same duplicate.
func TestRetryingRecoverySkipsASuccessfulResult(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "retry-settled")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"retry":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
s.Engine.ExportRecoverRetryingDeliveries(
s.WebhookDB, s.WebhookID,
)
assert.Empty(
t, fDrain(s.Engine),
"a retrying delivery holding a successful result "+
"must not be sent again",
)
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
}
// TestRetryingSweepSkipsASuccessfulResult is the same rule on the
// periodic sweep's retrying arm.
func TestRetryingSweepSkipsASuccessfulResult(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "retry-swept")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"swept":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
rSeedResult(t, s.WebhookDB, d.ID, 2, true)
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
assert.Empty(t, fDrain(s.Engine))
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
var attempts int64
require.NoError(t, s.WebhookDB.
Model(&database.DeliveryResult{}).
Where("delivery_id = ?", d.ID).
Count(&attempts).Error)
assert.Equal(
t, int64(2), attempts,
"settling must not invent an attempt",
)
}
// TestScheduledRetryIsNotSweptDuringBackoff closes the window between
// a target scheduling a retry and the timer firing. The row says
// retrying and nothing is running, which is exactly what an orphaned
// retry looks like from the database.
func TestScheduledRetryIsNotSweptDuringBackoff(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "backoff")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"backoff":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
s.Engine.ExportScheduleRetry(delivery.Task{
DeliveryID: d.ID,
EventID: event.ID,
WebhookID: s.WebhookID,
TargetID: targetID,
AttemptNum: 2,
}, time.Hour)
require.Equal(t, 1, s.Engine.ExportInflightHeld())
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
assert.Empty(
t, fDrain(s.Engine),
"the sweep must not duplicate a retry that is "+
"already scheduled",
)
}
// TestRedispatchStampsTheRow pins the cadence control: a stranded
// delivery that has just been handed out is not selected again by the
// next tick a minute later.
func TestRedispatchStampsTheRow(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "stamped")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"stamped":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rAgePending(t, s.WebhookDB, d.ID)
ctx := context.Background()
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
require.Len(t, fDrain(s.Engine), 1)
var row database.Delivery
require.NoError(t, s.WebhookDB.
First(&row, "id = ?", d.ID).Error)
assert.WithinDuration(
t, time.Now(), row.UpdatedAt, time.Minute,
"a re-dispatched delivery must be stamped so the "+
"next tick does not select it again",
)
}
-564
View File
@@ -1,564 +0,0 @@
package delivery_test
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/prometheus/client_golang/prometheus"
dto "github.com/prometheus/client_model/go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/metrics"
)
// Metric names as exposed on /metrics.
const (
mAttempts = "webhooker_delivery_attempts_total"
mSucceeded = "webhooker_deliveries_succeeded_total"
mFailed = "webhooker_deliveries_failed_total"
mRetries = "webhooker_delivery_retries_total"
mDuration = "webhooker_delivery_duration_seconds"
mPending = "webhooker_deliveries_pending"
mRetrying = "webhooker_deliveries_retrying"
mBreakers = "webhooker_circuit_breakers_open"
)
const (
mTypeHTTP = "http"
mTypeLog = "log"
mTypeUnknown = "unknown"
)
// mIsolate gives the setup's engine a metric set registered on a
// registry this test holds, so its exact assertions can gather from
// it.
func mIsolate(
t *testing.T, s iSetup,
) *prometheus.Registry {
t.Helper()
reg := prometheus.NewRegistry()
s.Engine.ExportSetMetrics(metrics.New(reg))
return reg
}
// mFind returns the series of the named metric carrying the given
// target_type label.
func mFind(
t *testing.T,
reg *prometheus.Registry,
name, targetType string,
) *dto.Metric {
t.Helper()
families, err := reg.Gather()
require.NoError(t, err)
for _, fam := range families {
if fam.GetName() != name {
continue
}
for _, m := range fam.GetMetric() {
for _, label := range m.GetLabel() {
if label.GetName() == "target_type" &&
label.GetValue() == targetType {
return m
}
}
}
}
t.Fatalf(
"metric %s{target_type=%q} not found",
name, targetType,
)
return nil
}
func mCounter(
t *testing.T,
reg *prometheus.Registry,
name, targetType string,
) float64 {
t.Helper()
return mFind(t, reg, name, targetType).
GetCounter().GetValue()
}
func mGauge(
t *testing.T,
reg *prometheus.Registry,
name, targetType string,
) float64 {
t.Helper()
return mFind(t, reg, name, targetType).
GetGauge().GetValue()
}
// mHTTPDurations returns how many samples the delivery duration
// histogram holds for the http target type, which is the type every
// test here times.
func mHTTPDurations(
t *testing.T, reg *prometheus.Registry,
) uint64 {
t.Helper()
return mFind(t, reg, mDuration, mTypeHTTP).
GetHistogram().GetSampleCount()
}
// TestDeliveryMetrics_SuccessAndRetryExhaustion drives one delivery
// that succeeds and one that fails every attempt until its retries
// are exhausted, and asserts every delivery counter across both.
func TestDeliveryMetrics_SuccessAndRetryExhaustion(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
mDeliverOK(t, s)
assert.InDelta(t, 1.0,
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
assert.InDelta(t, 1.0,
mCounter(t, reg, mSucceeded, mTypeHTTP), 0)
assert.InDelta(t, 0.0,
mCounter(t, reg, mFailed, mTypeHTTP), 0)
assert.InDelta(t, 0.0,
mCounter(t, reg, mRetries, mTypeHTTP), 0)
assert.Equal(t, uint64(1),
mHTTPDurations(t, reg))
mExhaustRetries(t, s)
// Two further attempts: the first is retried, the second is
// the last one allowed and fails the delivery terminally.
assert.InDelta(t, 3.0,
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
assert.InDelta(t, 1.0,
mCounter(t, reg, mSucceeded, mTypeHTTP), 0)
assert.InDelta(t, 1.0,
mCounter(t, reg, mRetries, mTypeHTTP), 0)
assert.InDelta(t, 1.0,
mCounter(t, reg, mFailed, mTypeHTTP), 0)
assert.Equal(t, uint64(3),
mHTTPDurations(t, reg))
// Two consecutive failures are below the trip threshold.
assert.InDelta(t, 0.0,
mGauge(t, reg, mBreakers, mTypeHTTP), 0)
// The label is the target type and nothing finer: two http
// targets shared one series, and no other type's moved.
assert.InDelta(t, 0.0,
mCounter(t, reg, mAttempts, mTypeLog), 0)
assert.InDelta(t, 0.0,
mCounter(t, reg, mFailed, mTypeLog), 0)
}
// mDeliverOK delivers one event to a target that answers 200.
func mDeliverOK(t *testing.T, s iSetup) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"ok":true}`,
)
targetID := uuid.New().String()
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
body := event.Body
task := iTask(
d, event, s.WebhookID, targetID,
"metrics-ok", iHTTPConfig(ts.URL), 3, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
}
// mExhaustRetries delivers to a target that answers 500 with a
// two-attempt budget, driving both attempts so the delivery ends
// terminally failed.
func mExhaustRetries(t *testing.T, s iSetup) {
t.Helper()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"ok":false}`,
)
targetID := uuid.New().String()
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
body := event.Body
cfg := iHTTPConfig(ts.URL)
// The retry below is only run if its target still exists; see
// https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-fail",
database.TargetTypeHTTP, cfg, 2,
)
first := iTask(
d, event, s.WebhookID, targetID,
"metrics-fail", cfg, 2, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &first)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusRetrying,
)
// The engine's own scheduler would re-enqueue this after the
// backoff; driving the second attempt directly keeps the test
// deterministic and off the wall clock.
second := iTask(
d, event, s.WebhookID, targetID,
"metrics-fail", cfg, 2, 2, &body,
)
s.Engine.ExportProcessRetryTask(
context.TODO(), &second,
)
iAssertStatus(t, s.WebhookDB, d.ID,
database.DeliveryStatusFailed,
)
}
// TestDeliveryMetrics_CircuitBreakerGauge proves the open-breaker
// gauge follows a breaker that trips.
func TestDeliveryMetrics_CircuitBreakerGauge(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"trip":true}`,
)
targetID := uuid.New().String()
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
body := event.Body
cfg := iHTTPConfig(ts.URL)
// A retry budget above the failure threshold, so the breaker
// rather than the budget is what stops the delivery.
maxRetries := delivery.ExportDefaultFailureThreshold + 5
// The retries below are only run if their target still exists;
// see https://git.eeqj.de/sneak/webhooker/issues/107.
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-trip",
database.TargetTypeHTTP, cfg, maxRetries,
)
first := iTask(
d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxRetries, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &first)
assert.InDelta(t, 0.0,
mGauge(t, reg, mBreakers, mTypeHTTP), 0)
for attempt := 2; attempt <= delivery.
ExportDefaultFailureThreshold; attempt++ {
task := iTask(
d, event, s.WebhookID, targetID,
"metrics-trip", cfg, maxRetries, attempt, &body,
)
s.Engine.ExportProcessRetryTask(
context.TODO(), &task,
)
}
assert.InDelta(t, 1.0,
mGauge(t, reg, mBreakers, mTypeHTTP), 0)
}
// TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt proves a delivery
// an open circuit breaker refuses is neither counted as an attempt
// nor observed in the duration histogram.
//
// It sends nothing and records no result row, so counting it would
// climb the attempts counter with no traffic behind it and pull the
// duration quantiles down with near-zero samples for as long as the
// breaker stayed open — the metric moving the wrong way during the
// outage it exists to reveal.
func TestDeliveryMetrics_BreakerBlockedIsNotAnAttempt(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"blocked":true}`,
)
targetID := uuid.New().String()
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
body := event.Body
cfg := iHTTPConfig(ts.URL)
maxRetries := delivery.ExportDefaultFailureThreshold + 5
iCreateTarget(
t, s.MainDB, targetID, s.WebhookID, "metrics-blocked",
database.TargetTypeHTTP, cfg, maxRetries,
)
first := iTask(
d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &first)
for attempt := 2; attempt <= delivery.
ExportDefaultFailureThreshold; attempt++ {
task := iTask(
d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries, attempt, &body,
)
s.Engine.ExportProcessRetryTask(context.TODO(), &task)
}
require.InDelta(t, 1.0,
mGauge(t, reg, mBreakers, mTypeHTTP), 0,
"breaker should be open before the blocked attempt")
threshold := float64(
delivery.ExportDefaultFailureThreshold,
)
assert.InDelta(t, threshold,
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
assert.Equal(t, uint64(threshold),
mHTTPDurations(t, reg))
retriesBefore := mCounter(t, reg, mRetries, mTypeHTTP)
blocked := iTask(
d, event, s.WebhookID, targetID,
"metrics-blocked", cfg, maxRetries,
delivery.ExportDefaultFailureThreshold+1, &body,
)
s.Engine.ExportProcessRetryTask(context.TODO(), &blocked)
// The breaker refused it: rescheduled without rewriting the
// retrying status it already had, so the retry counter did not
// move, and nothing was attempted or timed.
assert.InDelta(t, retriesBefore,
mCounter(t, reg, mRetries, mTypeHTTP), 0)
assert.InDelta(t, threshold,
mCounter(t, reg, mAttempts, mTypeHTTP), 0)
assert.Equal(t, uint64(threshold),
mHTTPDurations(t, reg))
}
// TestDeliveryMetrics_OrphanedRetryFailureLabelled proves the
// terminal failure of a delivery whose target no longer retries is
// counted against the target's real type, not against unknown. The
// type is threaded in as an argument because populating d.Target on
// that path would write the target row into the per-webhook database
// (https://git.eeqj.de/sneak/webhooker/issues/206).
func TestDeliveryMetrics_OrphanedRetryFailureLabelled(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "orphaned-label",
)
deliveryID := iSeedRetryingWithType(
t, s, database.TargetTypeLog,
)
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
iAssertStatus(t, s.WebhookDB, deliveryID,
database.DeliveryStatusFailed,
)
assert.InDelta(t, 1.0,
mCounter(t, reg, mFailed, mTypeLog), 0)
}
// TestDeliveryMetrics_QueueDepthGauges proves the sampler publishes
// the queued deliveries it finds in the per-webhook databases, and
// that a drained queue reads zero rather than keeping its last
// value.
func TestDeliveryMetrics_QueueDepthGauges(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "queue-depth",
)
targetID := uuid.New().String()
iCreateTarget(t, s.MainDB, targetID, s.WebhookID,
"queue-depth-target", database.TargetTypeHTTP,
iHTTPConfig("https://example.com/hook"), 3,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"queued":true}`,
)
pending := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
retrying := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
s.Engine.ExportSampleQueueDepths(context.Background())
assert.InDelta(t, 2.0,
mGauge(t, reg, mPending, mTypeHTTP), 0)
assert.InDelta(t, 1.0,
mGauge(t, reg, mRetrying, mTypeHTTP), 0)
assert.InDelta(t, 0.0,
mGauge(t, reg, mPending, mTypeLog), 0)
require.NoError(t, s.WebhookDB.
Model(&database.Delivery{}).
Where("id IN ?", []string{pending.ID, retrying.ID}).
Update(
"status", database.DeliveryStatusDelivered,
).Error)
s.Engine.ExportSampleQueueDepths(context.Background())
assert.InDelta(t, 1.0,
mGauge(t, reg, mPending, mTypeHTTP), 0)
assert.InDelta(t, 0.0,
mGauge(t, reg, mRetrying, mTypeHTTP), 0)
}
// TestDeliveryMetrics_QueueDepthDeletedTarget proves a backlog queued
// against a target that has since been deleted stays visible, in the
// unknown series, instead of being dropped. That backlog is the one
// nobody is watching, so losing it would defeat the queue-depth
// alerting this metric exists for.
func TestDeliveryMetrics_QueueDepthDeletedTarget(t *testing.T) {
t.Parallel()
s := newISetup(t)
reg := mIsolate(t, s)
iCreateWebhook(
t, s.MainDB, s.WebhookID, "deleted-target",
)
// No target row is created: this is a delivery whose target was
// deleted out from under it.
targetID := uuid.New().String()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"orphan":true}`,
)
iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
s.Engine.ExportSampleQueueDepths(context.Background())
assert.InDelta(t, 1.0,
mGauge(t, reg, mPending, mTypeUnknown), 0)
assert.InDelta(t, 1.0,
mGauge(t, reg, mRetrying, mTypeUnknown), 0)
assert.InDelta(t, 0.0,
mGauge(t, reg, mPending, mTypeHTTP), 0)
}
-196
View File
@@ -1,196 +0,0 @@
package delivery
import (
"context"
"fmt"
"time"
"sneak.berlin/go/webhooker/internal/database"
)
// queueDepthSampleInterval is how often the pending and retrying
// queue depths are counted and published as gauges.
const queueDepthSampleInterval = 30 * time.Second
// queueDepthSampler publishes the pending and retrying queue depths
// on a timer for as long as the engine runs.
//
// The depths are counted out of the databases rather than tracked as
// deltas alongside the status transitions. A delta counter would have
// to be seeded correctly at startup from rows written by a previous
// process, and would drift permanently on any transition that failed
// to persist. Counting is the measurement that cannot go wrong, and
// it is the same whole-database walk the retry sweep already makes.
func (e *Engine) queueDepthSampler(ctx context.Context) {
defer e.wg.Done()
ticker := time.NewTicker(queueDepthSampleInterval)
defer ticker.Stop()
e.sampleQueueDepths(ctx)
for {
select {
case <-ctx.Done():
return
case <-ticker.C:
e.sampleQueueDepths(ctx)
}
}
}
// sampleQueueDepths counts every queued delivery across all
// per-webhook databases and publishes the result.
func (e *Engine) sampleQueueDepths(ctx context.Context) {
if e.database == nil || e.dbManager == nil {
return
}
types, err := e.targetTypesByID()
if err != nil {
e.log.Error(
"queue depth sample: failed to load target types",
"error", err,
)
return
}
var webhookIDs []string
err = e.database.DB().
Model(&database.Webhook{}).
Pluck("id", &webhookIDs).Error
if err != nil {
e.log.Error(
"queue depth sample: failed to query webhook IDs",
"error", err,
)
return
}
pending := make(map[database.TargetType]int)
retrying := make(map[database.TargetType]int)
for _, webhookID := range webhookIDs {
select {
case <-ctx.Done():
return
default:
}
if !e.dbManager.DBExists(webhookID) {
continue
}
e.sampleWebhookQueueDepths(
webhookID, types, pending, retrying,
)
}
e.mtr.SetQueueDepths(pending, retrying)
}
// targetTypesByID maps every configured target id to its type. The
// deliveries live in the per-webhook databases but carry only a
// target id, so the type label has to come from the main database.
//
// Find rather than Scan: see sampleWebhookQueueDepths.
func (e *Engine) targetTypesByID() (
map[string]database.TargetType, error,
) {
var rows []struct {
ID string
Type database.TargetType
}
err := e.database.DB().
Model(&database.Target{}).
Select("id", "type").
Find(&rows).Error
if err != nil {
return nil, fmt.Errorf("loading targets: %w", err)
}
types := make(map[string]database.TargetType, len(rows))
for _, row := range rows {
types[row.ID] = row.Type
}
return types, nil
}
// sampleWebhookQueueDepths adds one webhook's queued deliveries into
// the running totals.
//
// A delivery whose target has since been deleted is not in the type
// map and so counts under the empty target type. Set.SetQueueDepths
// folds that into the unknown series rather than dropping it: a
// backlog stuck behind a deleted target is a backlog that still needs
// to be alertable.
//
// The aggregate is read with Find, not Scan. (*gorm.DB).Scan swaps
// GORM's own trace recorder in for the logging adapter, and that
// recorder does not implement gorm.ParamsFilter, so the statement
// reaches the log with its bound values interpolated — here, the
// status list. Find goes through the normal query callback, which is
// filtered. See internal/gormlog and its scan_guard_test.go.
func (e *Engine) sampleWebhookQueueDepths(
webhookID string,
types map[string]database.TargetType,
pending, retrying map[database.TargetType]int,
) {
webhookDB, err := e.dbManager.GetDB(webhookID)
if err != nil {
e.log.Error(
"queue depth sample: failed to get webhook database",
"webhook_id", webhookID,
"error", err,
)
return
}
var rows []struct {
TargetID string
Status database.DeliveryStatus
Depth int
}
err = webhookDB.
Model(&database.Delivery{}).
Select("target_id", "status", "count(*) as depth").
Where("status IN ?", []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
}).
Group("target_id, status").
Find(&rows).Error
if err != nil {
e.log.Error(
"queue depth sample: "+
"failed to count queued deliveries",
"webhook_id", webhookID,
"error", err,
)
return
}
for _, row := range rows {
targetType := types[row.TargetID]
switch row.Status {
case database.DeliveryStatusPending:
pending[targetType] += row.Depth
case database.DeliveryStatusRetrying:
retrying[targetType] += row.Depth
case database.DeliveryStatusDelivered,
database.DeliveryStatusFailed:
// Excluded by the query above: a delivery that has
// reached a terminal state is not queued.
}
}
}
@@ -1,175 +0,0 @@
package delivery_test
import (
"bytes"
"context"
"log/slog"
"net/http"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
"sneak.berlin/go/webhooker/internal/gormlog"
)
// qdAggregateMarker identifies the queue-depth aggregate in the
// captured SQL. It is the one statement in this test that binds
// anything, and the raw count() expression appears in no other.
const qdAggregateMarker = "count(*)"
// qdSyncBuf collects log output from whichever goroutine GORM writes
// on.
type qdSyncBuf struct {
mu sync.Mutex
b bytes.Buffer
}
func (q *qdSyncBuf) Write(p []byte) (int, error) {
q.mu.Lock()
defer q.mu.Unlock()
return q.b.Write(p)
}
func (q *qdSyncBuf) String() string {
q.mu.Lock()
defer q.mu.Unlock()
return q.b.String()
}
// qdMainDB opens a main database whose GORM logger is the service's
// adapter, writing through log.
func qdMainDB(t *testing.T, log *slog.Logger) *gorm.DB {
t.Helper()
sqlDB, err := database.OpenSQLite(
filepath.Join(t.TempDir(), "main-gormlog.db"),
database.SQLiteModeCreate,
)
require.NoError(t, err)
t.Cleanup(func() { _ = sqlDB.Close() })
db, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB},
&gorm.Config{Logger: gormlog.New(log)},
)
require.NoError(t, err)
require.NoError(t, db.AutoMigrate(
&database.Webhook{},
&database.Target{},
))
return db
}
// qdLinesContaining returns every captured line carrying marker.
func qdLinesContaining(out, marker string) []string {
var found []string
for line := range strings.SplitSeq(out, "\n") {
if strings.Contains(line, marker) {
found = append(found, line)
}
}
return found
}
// TestQueueDepthSample_LogsNoBoundValue holds the queue-depth sampler
// to the values-off property internal/gormlog exists to provide.
//
// The aggregate binds the delivery status list. Read with
// (*gorm.DB).Scan it was logged with those values interpolated, because
// Scan records the statement through GORM's own traceRecorder, which
// does not implement gorm.ParamsFilter. Read with Find it goes through
// the normal query callback and the adapter's filter applies. Restore
// the Scan call in queue_depth.go and this fails on the status literals
// below; scan_guard_test.go catches the same regression statically.
func TestQueueDepthSample_LogsNoBoundValue(t *testing.T) {
t.Parallel()
buf := &qdSyncBuf{}
log := slog.New(slog.NewTextHandler(
buf, &slog.HandlerOptions{Level: slog.LevelDebug},
))
mainDB := qdMainDB(t, log)
dbMgr := database.NewTestWebhookDBManagerWithLogger(
t.TempDir(), log,
)
webhookID := uuid.New().String()
webhookDB := iSeedWebhookDB(t, dbMgr, webhookID)
iCreateWebhook(t, mainDB, webhookID, "queue-depth-gormlog")
targetID := uuid.New().String()
iCreateTarget(t, mainDB, targetID, webhookID,
"queue-depth-gormlog-target", database.TargetTypeHTTP,
iHTTPConfig("https://example.com/hook"), 3,
)
event := iSeedEvent(
t, webhookDB, webhookID, `{"queued":true}`,
)
iSeedDelivery(
t, webhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
iSeedDelivery(
t, webhookDB, event.ID, targetID,
database.DeliveryStatusRetrying,
)
engine := delivery.NewTestEngineWithDB(
database.NewTestDatabase(mainDB),
dbMgr,
log,
&http.Client{Timeout: 5 * time.Second},
2,
)
engine.ExportSampleQueueDepths(context.Background())
out := buf.String()
lines := qdLinesContaining(out, qdAggregateMarker)
require.NotEmpty(
t, lines,
"the queue-depth aggregate was never logged, so the "+
"assertions below are vacuous",
)
for _, line := range lines {
assert.Contains(
t, line, "?",
"the aggregate was logged without its placeholders: %s",
line,
)
for _, status := range []database.DeliveryStatus{
database.DeliveryStatusPending,
database.DeliveryStatusRetrying,
} {
assert.NotContains(
t, line, string(status),
"a bound status value was interpolated into the "+
"logged statement: %s", line,
)
}
}
}
@@ -1,472 +0,0 @@
package delivery_test
import (
"context"
"net/http"
"net/http/httptest"
"sync/atomic"
"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/delivery"
)
// These tests cover the delivery half of
// https://git.eeqj.de/sneak/webhooker/issues/256: a delivery that
// reached its receiver but whose bookkeeping write failed used to be
// left at pending and re-sent on the next restart, giving the receiver
// a second copy while the event log recorded one attempt.
// rSeedResult records a DeliveryResult against a delivery, standing in
// for the attempt row the send path writes before the status.
func rSeedResult(
t *testing.T,
db *gorm.DB,
deliveryID string,
attemptNum int,
success bool,
) {
t.Helper()
require.NoError(t, db.Create(&database.DeliveryResult{
DeliveryID: deliveryID,
AttemptNum: attemptNum,
Success: success,
}).Error)
}
// rAgePending backdates a delivery past the sweep's age bound, which is
// what separates a stranded delivery from one a worker still holds.
func rAgePending(
t *testing.T, db *gorm.DB, deliveryID string,
) {
t.Helper()
old := time.Now().Add(
-2 * delivery.ExportPendingSweepMinAge,
)
require.NoError(t, db.Model(&database.Delivery{}).
Where("id = ?", deliveryID).
UpdateColumn("updated_at", old).Error)
}
func TestRecoverySkipsPendingWithSuccessfulResult(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "already-delivered",
database.TargetTypeLog, "", 0,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"delivered":true}`,
)
// The delivery whose send succeeded and whose result row landed:
// only the status write failed, so it sits at pending.
done := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rSeedResult(t, s.WebhookDB, done.ID, 1, true)
// A delivery that was genuinely never attempted.
fresh := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID,
)
select {
case task := <-s.Engine.ExportDeliveryCh():
assert.Equal(
t, fresh.ID, task.DeliveryID,
"only the unattempted delivery may be re-sent",
)
case <-time.After(2 * time.Second):
t.Fatal("expected the unattempted delivery")
}
select {
case task := <-s.Engine.ExportDeliveryCh():
t.Fatalf(
"re-sent an already delivered delivery: %s",
task.DeliveryID,
)
case <-time.After(200 * time.Millisecond):
}
// It is settled rather than merely skipped: leaving it pending
// would strand it again on the next sweep.
iAssertStatus(
t, s.WebhookDB, done.ID,
database.DeliveryStatusDelivered,
)
}
// TestRecoveryContinuesTheAttemptNumbering pins the audit trail: a
// recovered delivery that already recorded two attempts is re-sent as
// attempt three, not as attempt one again.
func TestRecoveryContinuesTheAttemptNumbering(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
iCreateTarget(t, s.MainDB, targetID,
s.WebhookID, "numbering",
database.TargetTypeLog, "", 0,
)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"numbering":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rSeedResult(t, s.WebhookDB, d.ID, 1, false)
rSeedResult(t, s.WebhookDB, d.ID, 2, false)
s.Engine.ExportRecoverPendingDeliveries(
context.Background(), s.WebhookDB, s.WebhookID,
)
select {
case task := <-s.Engine.ExportDeliveryCh():
assert.Equal(t, d.ID, task.DeliveryID)
assert.Equal(t, 3, task.AttemptNum)
case <-time.After(2 * time.Second):
t.Fatal("expected the delivery to be recovered")
}
}
// TestSweepRecoversStrandedPending is the half that removes the
// restart requirement: a delivery left at pending is picked up by the
// periodic sweep.
func TestSweepRecoversStrandedPending(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "stranded")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"stranded":true}`,
)
stranded := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rAgePending(t, s.WebhookDB, stranded.ID)
// A delivery a worker may still be holding: young, and therefore
// none of the sweep's business.
inFlight := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
select {
case task := <-s.Engine.ExportDeliveryCh():
assert.Equal(t, stranded.ID, task.DeliveryID)
case <-time.After(2 * time.Second):
t.Fatal("expected the stranded delivery")
}
select {
case task := <-s.Engine.ExportDeliveryCh():
t.Fatalf(
"swept an in-flight delivery: %s",
task.DeliveryID,
)
case <-time.After(200 * time.Millisecond):
}
iAssertStatus(
t, s.WebhookDB, inFlight.ID,
database.DeliveryStatusPending,
)
}
// TestSweepClaimsAStrandedDeliveryOnlyOnce guards the repeat the sweep
// would otherwise be: the row stays pending for as long as the attempt
// runs, and a sweep a minute later must not send it a second time.
func TestSweepClaimsAStrandedDeliveryOnlyOnce(t *testing.T) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "claimed")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"claimed":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rAgePending(t, s.WebhookDB, d.ID)
ctx := context.Background()
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
select {
case task := <-s.Engine.ExportDeliveryCh():
assert.Equal(t, d.ID, task.DeliveryID)
case <-time.After(2 * time.Second):
t.Fatal("expected the stranded delivery")
}
// The delivery is still pending — nothing has run it yet — but
// the claim must keep the next sweep off it.
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusPending,
)
s.Engine.ExportSweepWebhookRetries(ctx, s.WebhookID)
select {
case task := <-s.Engine.ExportDeliveryCh():
t.Fatalf(
"sent a claimed delivery again: %s",
task.DeliveryID,
)
case <-time.After(200 * time.Millisecond):
}
}
// TestSweepSettlesStrandedPendingWithoutResending is the sweep's own
// version of the reconcile: a stranded delivery holding a successful
// result is settled where it stands, and the receiver hears nothing.
func TestSweepSettlesStrandedPendingWithoutResending(
t *testing.T,
) {
t.Parallel()
targetID := uuid.New().String()
s := fSweepSetup(t, targetID, "settled")
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"settled":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
rSeedResult(t, s.WebhookDB, d.ID, 1, true)
rAgePending(t, s.WebhookDB, d.ID)
s.Engine.ExportSweepWebhookRetries(
context.Background(), s.WebhookID,
)
select {
case task := <-s.Engine.ExportDeliveryCh():
t.Fatalf(
"re-sent a delivery that already succeeded: %s",
task.DeliveryID,
)
case <-time.After(200 * time.Millisecond):
}
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusDelivered,
)
var attempts int64
require.NoError(t, s.WebhookDB.
Model(&database.DeliveryResult{}).
Where("delivery_id = ?", d.ID).
Count(&attempts).Error)
assert.Equal(
t, int64(1), attempts,
"settling must not invent an attempt",
)
}
// TestFailedResultWriteLeavesDeliveryRecoverable is the rule the
// targets now follow: a bookkeeping write that fails must not advance
// the status, because pending and retrying are the states the sweeps
// recover and delivered is a claim the database refused to record.
func TestFailedResultWriteLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
var hits atomic.Int64
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
hits.Add(1)
w.WriteHeader(http.StatusOK)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
// Drop the table the attempt row goes in, so the send succeeds
// and only the bookkeeping write fails.
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
},
}
full.ID = d.ID
s.Engine.ExportDeliverHTTP(
context.Background(), s.WebhookDB, full,
&delivery.Task{DeliveryID: d.ID, AttemptNum: 1},
)
assert.Equal(
t, int64(1), hits.Load(),
"the send itself must still happen",
)
iAssertStatus(
t, s.WebhookDB, d.ID,
database.DeliveryStatusPending,
)
}
// TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable is the same
// rule for a target with retries: whatever the receiver answered, the
// delivery stays pending and no retry is scheduled. The circuit breaker
// still learns the answer, because it describes the target's health,
// not the database's.
func TestFailedResultWriteWithRetriesLeavesDeliveryRecoverable(
t *testing.T,
) {
t.Parallel()
// The "send succeeded" case starts with the breaker tripped open,
// so the delivery goes out as its probe and only a recorded
// success closes it again.
tests := []struct {
name string
answer int
tripped bool
wantBreaker delivery.CircuitState
}{
{"send succeeded", http.StatusOK, true, delivery.CircuitClosed},
{"send failed", http.StatusBadGateway, false, delivery.CircuitOpen},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
s := newISetup(t)
targetID := uuid.New().String()
ts := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(tc.answer)
},
))
defer ts.Close()
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"unwritable":true}`,
)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
require.NoError(
t,
s.WebhookDB.Exec("drop table delivery_results").Error,
)
// A single failure opens this breaker, and with no
// cooldown an open breaker lets the next delivery
// through as a probe.
cb := delivery.NewTestCircuitBreaker(1, 0)
if tc.tripped {
cb.RecordFailure()
}
s.Engine.ExportSetCircuitBreaker(targetID, cb)
full := &database.Delivery{
EventID: event.ID,
TargetID: targetID,
Status: database.DeliveryStatusPending,
Event: event,
Target: database.Target{
Name: "unwritable",
Type: database.TargetTypeHTTP,
Config: iHTTPConfig(ts.URL),
MaxRetries: 3,
},
}
full.ID = d.ID
sched := &recordingScheduler{}
s.Engine.ExportDeliverHTTPWithScheduler(
context.Background(), s.WebhookDB, full,
&delivery.Task{
DeliveryID: d.ID,
TargetID: targetID,
AttemptNum: 1,
},
sched,
)
iAssertStatus(t, s.WebhookDB, d.ID, database.DeliveryStatusPending)
assert.Empty(t, sched.delays, "no retry may be scheduled")
assert.Equal(t, tc.wantBreaker, cb.State())
})
}
}
-107
View File
@@ -1,107 +0,0 @@
package delivery
import (
"errors"
"fmt"
"net"
"net/http"
"net/url"
"strings"
)
// maxDeliveryRedirects caps a redirect chain. Installing a
// CheckRedirect replaces net/http's default policy including its
// own limit, so the limit is restated rather than dropped.
const maxDeliveryRedirects = 10
// schemeHTTPS names the scheme the origin comparison treats
// specially: a step down from it is never the same origin.
const schemeHTTPS = "https"
var errTooManyRedirects = errors.New("too many redirects")
// offOriginHeaderPolicy returns a CheckRedirect that drops every
// origin-scoped header once a redirect leaves the origin the
// operator configured. names is the set applyRequestHeaders
// reports: the operator's configured headers and the inbound event
// headers this delivery forwarded, under one rule rather than two.
//
// net/http withholds Authorization and Cookie across a host change
// and forwards everything else. A target header is routinely a
// credential under another name — X-Api-Key, PRIVATE-TOKEN,
// X-Auth-Token — and a forwarded inbound header is routinely a
// sender's signature — X-Hub-Signature — so an open redirect at an
// otherwise trusted destination would hand either to a host the
// operator never named. Redirects are still followed: refusing them
// would break every destination that legitimately redirects and
// would record the 3xx as the delivery's result.
//
// The strip is per hop, not permanent: net/http re-copies the
// initial request's headers for every hop, so a chain that returns
// to the configured origin carries them again, exactly as net/http
// treats Authorization.
//
// Each hop is dialled through the same SSRF-safe transport, whose
// guard runs per connection, so a redirect aimed at a private or
// reserved address is still refused at connect time.
func offOriginHeaderPolicy(
names []string,
) func(*http.Request, []*http.Request) error {
return func(req *http.Request, via []*http.Request) error {
if len(via) >= maxDeliveryRedirects {
return fmt.Errorf(
"%w: stopped after %d",
errTooManyRedirects, maxDeliveryRedirects,
)
}
if sameDeliveryOrigin(via[0].URL, req.URL) {
return nil
}
for _, name := range names {
req.Header.Del(name)
}
return nil
}
}
// sameDeliveryOrigin reports whether dest is close enough to the
// configured target URL to keep carrying its origin-scoped headers.
//
// This is stricter than the rule net/http applies to Authorization:
// the port is part of the comparison (a different port is a
// different service), and a subdomain of the configured host is not
// the same origin. An https origin stepping down to http is never
// the same origin whatever the hosts are, because that puts the
// header on the wire in clear.
func sameDeliveryOrigin(origin, dest *url.URL) bool {
if origin.Scheme == schemeHTTPS && dest.Scheme != schemeHTTPS {
return false
}
return originHostPort(origin) == originHostPort(dest)
}
// originHostPort renders a URL's host for comparison, lowercased
// and with the scheme's default port normalised away so that
// "https://h" and "https://h:443" are one origin.
//
// The port is joined with net.JoinHostPort rather than a bare
// colon: Hostname() unwraps an IPv6 literal's brackets, so
// "[2001:db8::1]:8080" and "[2001:db8::1:8080]" — a different
// address on a different port — would otherwise render the same
// string and pass as one origin.
func originHostPort(u *url.URL) string {
host := strings.ToLower(u.Hostname())
port := u.Port()
if port == "" ||
(u.Scheme == "http" && port == "80") ||
(u.Scheme == schemeHTTPS && port == "443") {
return host
}
return net.JoinHostPort(host, port)
}
-391
View File
@@ -1,391 +0,0 @@
package delivery_test
import (
"context"
"encoding/json"
"log/slog"
"net/http"
"net/http/httptest"
"net/url"
"sync"
"sync/atomic"
"testing"
"time"
"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/delivery"
)
// The headers these tests drive stand in for the two classes the
// off-origin rule covers: an operator-configured credential and an
// inbound header the delivery path forwards. net/http withholds
// Authorization and Cookie across a host change, and nothing else.
const (
probeHeaderName = "X-Api-Key"
probeHeaderValue = "QQNEVERONTHEWIREQQ"
inboundHeaderName = "X-Hub-Signature"
inboundHeaderValue = "sha1=QQINBOUNDQQ"
)
// redirectProbe records what the last hop of a redirect chain
// actually received.
type redirectProbe struct {
mu sync.Mutex
seen http.Header
hits int
}
func (p *redirectProbe) serve(
w http.ResponseWriter, r *http.Request,
) {
p.mu.Lock()
p.seen = r.Header.Clone()
p.hits++
p.mu.Unlock()
w.WriteHeader(http.StatusOK)
}
func (p *redirectProbe) result() (http.Header, int) {
p.mu.Lock()
defer p.mu.Unlock()
return p.seen, p.hits
}
// deliverWithProbeHeaders runs one real delivery of a new task
// through the engine to targetURL, carrying both probe headers —
// probeHeaderName configured on the target, inboundHeaderName
// forwarded from the event — and returns the delivery status the
// engine recorded.
func deliverWithProbeHeaders(
t *testing.T, targetURL string,
) database.DeliveryStatus {
t.Helper()
s := newISetup(t)
event := iSeedEvent(
t, s.WebhookDB, s.WebhookID, `{"hello":"world"}`,
)
targetID := uuid.New().String()
inbound, err := json.Marshal(map[string][]string{
inboundHeaderName: {inboundHeaderValue},
})
require.NoError(t, err)
event.Headers = string(inbound)
d := iSeedDelivery(
t, s.WebhookDB, event.ID, targetID,
database.DeliveryStatusPending,
)
cfg, err := json.Marshal(delivery.HTTPTargetConfig{
URL: targetURL,
Headers: map[string]string{
probeHeaderName: probeHeaderValue,
},
})
require.NoError(t, err)
body := event.Body
task := iTask(
d, event, s.WebhookID, targetID,
"redirect-target", string(cfg), 0, 1, &body,
)
s.Engine.ExportProcessNewTask(context.TODO(), &task)
var updated database.Delivery
require.NoError(t, s.WebhookDB.First(
&updated, "id = ?", d.ID,
).Error)
return updated.Status
}
// A 302 to an origin the operator never configured must not carry
// the credential they configured for the one they did, nor the
// inbound header this delivery forwarded — one rule for both
// classes. The chain is still followed, so the delivery is recorded
// from the final hop.
func TestDelivery_CrossOriginRedirectDropsOriginScopedHeaders(
t *testing.T,
) {
t.Parallel()
var probe redirectProbe
final := httptest.NewServer(
http.HandlerFunc(probe.serve),
)
defer final.Close()
// httptest listens on loopback, so reach the second server
// under loopback's other name: the hop then differs in
// hostname as well as port and is cross-host by any reading.
finalURL, err := url.Parse(final.URL)
require.NoError(t, err)
finalURL.Host = "localhost:" + finalURL.Port()
finalURL.Path = "/moved"
origin := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
http.Redirect(
w, r, finalURL.String(),
http.StatusFound,
)
},
))
defer origin.Close()
status := deliverWithProbeHeaders(t, origin.URL)
seen, hits := probe.result()
assert.Equal(t, 1, hits,
"the redirect must still be followed",
)
assert.Empty(t, seen.Get(probeHeaderName),
"a configured credential header must not reach an "+
"origin the operator did not configure",
)
assert.Empty(t, seen.Get(inboundHeaderName),
"a forwarded inbound header must not reach an origin "+
"the operator did not configure",
)
assert.Equal(t,
database.DeliveryStatusDelivered, status,
"the final hop's 200 is the delivery's result",
)
}
// Stripping must not fire within the configured origin, or every
// destination that redirects its own path would lose its
// credential and start answering 401 — and would lose the inbound
// signature header the target endpoint verifies. webhooker's own
// receiver verifies no signature; it only forwards the header.
func TestDelivery_SameOriginRedirectKeepsOriginScopedHeaders(
t *testing.T,
) {
t.Parallel()
var probe redirectProbe
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/moved" {
probe.serve(w, r)
return
}
http.Redirect(
w, r, "/moved", http.StatusFound,
)
},
))
defer srv.Close()
status := deliverWithProbeHeaders(t, srv.URL+"/hook")
seen, hits := probe.result()
assert.Equal(t, 1, hits)
assert.Equal(t, probeHeaderValue, seen.Get(probeHeaderName),
"a redirect within the configured origin must keep "+
"the configured header",
)
assert.Equal(t,
inboundHeaderValue, seen.Get(inboundHeaderName),
"a redirect within the configured origin must keep "+
"the forwarded inbound header",
)
assert.Equal(t,
database.DeliveryStatusDelivered, status,
)
}
// The origin comparison is deliberately stricter than the one
// net/http applies to Authorization: the port counts and a
// subdomain does not inherit. Only the default-port spellings of
// one origin are the same origin.
func TestSameDeliveryOrigin(t *testing.T) {
t.Parallel()
// The configured target URL every case redirects away from.
// Destination paths differ only so that no literal repeats.
const configured = "https://h/a"
cases := map[string]struct {
origin string
dest string
want bool
}{
"other path": {configured, "https://h/b", true},
"default port spelled": {configured, "https://h:443/c", true},
"host in another case": {configured, "https://H/d", true},
"http default port": {"http://h:80/a", "http://h/e", true},
"upgrade to https": {"http://h/a", "https://h/f", true},
"downgrade to http": {configured, "http://h/g", false},
"another host": {configured, "https://i/h", false},
"a subdomain": {configured, "https://x.h/i", false},
"the parent domain": {"https://x.h/a", "https://h/j", false},
"another port": {configured, "https://h:8443/k", false},
// Hostname() unwraps an IPv6 literal's brackets, so a
// bracketed host whose last group is the origin's port
// renders identically to the origin unless the port is
// re-joined with brackets. Each dest below differs from
// its origin in address AND in port.
"ipv6 port as final group": {
"https://[2001:db8::1]:8080/a",
"https://[2001:db8::1:8080]/l",
false,
},
"ipv6 loopback port as final group": {
"https://[::1]:8080/a",
"https://[::1:8080]/m",
false,
},
"ipv6 same origin": {
"https://[2001:db8::1]:8080/a",
"https://[2001:DB8::1]:8080/n",
true,
},
}
for name, tc := range cases {
t.Run(name, func(t *testing.T) {
t.Parallel()
origin, err := url.Parse(tc.origin)
require.NoError(t, err)
dest, err := url.Parse(tc.dest)
require.NoError(t, err)
assert.Equal(t, tc.want,
delivery.ExportSameDeliveryOrigin(
origin, dest,
),
)
})
}
}
// Installing a CheckRedirect discards net/http's own redirect
// limit, so the cap this policy restates is the only thing between
// a self-redirecting destination and an unbounded chain. A
// destination that always redirects must be cut off after exactly
// maxDeliveryRedirects requests, with the sentinel surfacing to the
// caller rather than a generic net/http error.
func TestRedirectPolicy_StopsAtHopCap(t *testing.T) {
t.Parallel()
var hits atomic.Int64
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
http.Redirect(
w, r, "/loop", http.StatusFound,
)
},
))
defer srv.Close()
engine := delivery.NewTestEngine(
slog.New(slog.DiscardHandler),
&http.Client{Timeout: 10 * time.Second},
1,
)
client := engine.ExportClientForRequest(
&delivery.HTTPTargetConfig{URL: srv.URL},
[]string{probeHeaderName},
)
require.NotNil(t, client.CheckRedirect)
req, err := http.NewRequestWithContext(
context.Background(), http.MethodGet, srv.URL, http.NoBody,
)
require.NoError(t, err)
resp, doErr := client.Do(req)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, doErr,
"an endless redirect chain must not be followed forever",
)
require.ErrorIs(t, doErr, delivery.ErrExportTooManyRedirects)
assert.Equal(t,
int64(delivery.ExportMaxDeliveryRedirects), hits.Load(),
"the chain must stop after exactly %d hops",
delivery.ExportMaxDeliveryRedirects,
)
}
// The set the redirect policy strips is whatever the delivery path
// actually put on the wire, so a header added to the forward set is
// covered without a second edit. A header the event never carried
// is not in the set, and neither is the inbound Content-Type, because
// it is not forwarded. Two more are deliberately excluded: a
// Content-Type configured on the target describes the body, which a
// 307 carries across hosts, and the inbound User-Agent every real
// sender supplies is overwritten before the request goes out.
func TestApplyRequestHeaders_ReportsOriginScopedNames(t *testing.T) {
t.Parallel()
inbound, err := json.Marshal(map[string][]string{
inboundHeaderName: {inboundHeaderValue},
"Content-Type": {testContentType},
"User-Agent": {"curl/8.7.1"},
"Host": {"inbound.example.com"},
})
require.NoError(t, err)
req, err := http.NewRequestWithContext(
context.Background(),
http.MethodPost,
"https://target.example.com/hook",
http.NoBody,
)
require.NoError(t, err)
names := delivery.ExportApplyRequestHeaders(
req,
&database.Event{
Headers: string(inbound),
ContentType: testContentType,
},
&delivery.HTTPTargetConfig{
Headers: map[string]string{
probeHeaderName: probeHeaderValue,
"Content-Type": testContentType,
},
},
"webhooker/dev",
)
assert.Equal(t,
[]string{probeHeaderName, inboundHeaderName}, names,
"both header classes are reported, and only those: "+
"Host and the inbound Content-Type are never "+
"forwarded, User-Agent is the delivery path's own",
)
assert.NotContains(t, names, "Content-Type",
"a Content-Type configured on the target must survive "+
"a cross-origin 307/308 with the body it describes",
)
}
+51 -332
View File
@@ -6,11 +6,8 @@ import (
"fmt" "fmt"
"net" "net"
"net/http" "net/http"
"net/netip"
"net/url" "net/url"
"time" "time"
"sneak.berlin/go/webhooker/internal/config"
) )
const ( const (
@@ -25,243 +22,41 @@ var (
errNoIPs = errors.New( errNoIPs = errors.New(
"hostname resolved to no IP addresses", "hostname resolved to no IP addresses",
) )
// ErrBlockedPrivateOrReservedIP reports an address in the errBlockedIP = errors.New(
// default blocklist's private and reserved ranges, "blocked private/reserved IP range",
// blockedNetworks.
ErrBlockedPrivateOrReservedIP = errors.New(
"blocked private or reserved address",
)
// errBlockedPublicMetadata reports a public address on the
// default blocklist, one in blockedPublicNetworks.
errBlockedPublicMetadata = errors.New(
"blocked cloud metadata address",
)
errBlockedMetadata = errors.New(
"blocked link-local, cloud instance metadata or " +
"unspecified address: ALLOWED_EGRESS_CIDRS cannot open it",
) )
errInvalidScheme = errors.New( errInvalidScheme = errors.New(
"only http and https are allowed", "only http and https are allowed",
) )
) )
// blockedNetworks and blockedPublicNetworks together are the // blockedNetworks contains all private/reserved IP ranges
// default blocklist: the private and reserved IP ranges, plus // that should be blocked to prevent SSRF attacks.
// the public cloud metadata addresses, that are blocked to
// prevent SSRF attacks. An operator can permit specific blocks
// out of this set with ALLOWED_EGRESS_CIDRS; see Guard.
//
// blockedNetworks holds the private and reserved IP ranges.
// //
//nolint:gochecknoglobals // package-level network list is appropriate here //nolint:gochecknoglobals // package-level network list is appropriate here
var blockedNetworks []*net.IPNet var blockedNetworks []*net.IPNet
// blockedPublicNetworks holds the default blocklist's public
// addresses, kept apart from blockedNetworks so that they are
// refused as cloud metadata addresses, never as private or
// reserved ones.
//
// A public address belongs on the default blocklist only if it
// hands credentials, user data or bootstrap material to whatever
// can reach it, without the caller presenting anything; it goes
// in this list. A provider's other public addresses are not
// refused, since reaching them can be legitimate and no list of
// them could be complete.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var blockedPublicNetworks []*net.IPNet
// alwaysBlockedNetworks are the ranges no configuration can
// open, so a supplied CIDR that covers one still leaves it
// blocked. An entry is here for one of two reasons: it is a
// metadata endpoint (the link-local blocks and the cloud
// instance metadata endpoints that live outside them), or it is
// an unspecified address. Reaching a metadata endpoint is
// credential or user-data theft rather than delivery to an
// internal service.
//
// Inclusion criterion for metadata endpoints — one belongs here
// only if BOTH hold, and every metadata entry below satisfies
// both:
//
// 1. It is a fixed address assigned by the provider, or a
// range reserved by IANA — never one the operator chose.
// That is what makes a host route free: it cannot collide
// with anything the operator runs.
// 2. Reaching it discloses credentials, or user data or
// bootstrap material — something granting onward access, or
// not cheaply rotated.
//
// Both halves are load-bearing, so use them to refuse a
// metadata candidate and say why. An endpoint disclosing only
// the operator's own inventory (instance id, region, disks, NICs)
// fails (2): letting a delivery target reach the operator's own
// infrastructure is the feature ALLOWED_EGRESS_CIDRS exists to
// provide. But (2) is not "IAM credentials only" either —
// fd00:42::42 serves /user_data and /conf rather than tokens,
// and user data routinely carries bootstrap secrets. An address
// stays out if it fails (1) however well it clears (2): a host
// route inside a block operators really assign from, such as
// 10.0.0.0/8, can collide with a real internal service and
// forfeits the justification in (1).
//
// A publicly routable unicast address does not belong here even
// when it clears both halves. Nothing in this list can be
// reopened, so putting a public address here leaves the operator
// no escape hatch at all — the condition ALLOWED_EGRESS_CIDRS
// exists to remove. Default-block it in blockedPublicNetworks
// instead, which an allowlist can override.
//
// This is a criterion, not an enumeration of every metadata
// address in existence.
//
// The unspecified addresses 0.0.0.0 and :: are here for a
// separate reason: they disclose nothing, but no host can have
// either, and on Linux a connection to one reaches this host's
// own loopback. Listing them means an allowlist reaches loopback
// only through an entry that covers a loopback address
// (127.0.0.0/8, ::1/128, 0.0.0.0/0), never through one that
// covers only 0.0.0.0 or :: (0.0.0.0/8, for example). Nothing
// else lives at either address, so refusing them costs nothing.
//
// Every entry is either already in blockedNetworks — this list is
// what makes it unconditional — or an alternate encoding of
// 169.254.169.254 that Contains does not match against
// 169.254.0.0/16. Every entry outside the link-local blocks is a
// /32 or /128 host route, so blocking it costs an operator
// nothing else on the surrounding network.
//
// Derive membership from the address, never from the vendor's
// prose. Several providers call these endpoints "link-local" or
// even "localhost" in their own documentation while the address
// is a ULA outside fe80::/10, so a set derived from the docs
// comes out wrong.
//
//nolint:gochecknoglobals // package-level network list is appropriate here
var alwaysBlockedNetworks []*net.IPNet
//nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup //nolint:gochecknoinits // init is the idiomatic way to parse CIDRs once at startup
func init() { func init() {
blockedNetworks = mustParseCIDRs([]string{ cidrs := []string{
// IPv4 loopback.
"127.0.0.0/8", "127.0.0.0/8",
// RFC 1918 private network.
"10.0.0.0/8", "10.0.0.0/8",
// RFC 1918 private network.
"172.16.0.0/12", "172.16.0.0/12",
// RFC 1918 private network.
"192.168.0.0/16", "192.168.0.0/16",
// IPv4 link-local.
"169.254.0.0/16", "169.254.0.0/16",
// "This network", holding the IPv4 unspecified address 0.0.0.0.
"0.0.0.0/8", "0.0.0.0/8",
// Carrier-grade NAT shared address space.
"100.64.0.0/10", "100.64.0.0/10",
// IETF protocol assignments.
"192.0.0.0/24", "192.0.0.0/24",
// IPv4 documentation (TEST-NET-1).
"192.0.2.0/24", "192.0.2.0/24",
// Benchmarking.
"198.18.0.0/15", "198.18.0.0/15",
// IPv4 documentation (TEST-NET-2).
"198.51.100.0/24", "198.51.100.0/24",
// IPv4 documentation (TEST-NET-3).
"203.0.113.0/24", "203.0.113.0/24",
// IPv4 multicast.
"224.0.0.0/4", "224.0.0.0/4",
// Reserved, including the broadcast address.
"240.0.0.0/4", "240.0.0.0/4",
// IPv6 loopback.
"::1/128", "::1/128",
// IPv6 unspecified address.
"::/128",
// IPv6 unique local addresses.
"fc00::/7", "fc00::/7",
// IPv6 link-local.
"fe80::/10", "fe80::/10",
// IPv6 multicast. }
"ff00::/8",
// IPv6 documentation.
"2001:db8::/32",
})
blockedPublicNetworks = mustParseCIDRs([]string{
// Azure WireServer, a public address that serves VM credentials.
"168.63.129.16/32",
})
// Every entry is named. The set must not grow or shrink
// without a matching change to
// TestAlwaysBlockedNetworks_PinnedSet.
//
// The IPv4-mapped form ::ffff:169.254.169.254 needs no
// entry: net.IPNet.Contains normalises it via To4() before
// comparing, so 169.254.0.0/16 already matches it. To4()
// does not normalise the IPv4-compatible or NAT64 forms,
// which is why those are listed separately.
alwaysBlockedNetworks = mustParseCIDRs([]string{
// IPv4 link-local, carrying the 169.254.169.254
// metadata service used by AWS, Azure, DigitalOcean,
// Hetzner, OpenStack and others. Not Alibaba, which uses
// 100.100.100.200 below exclusively.
"169.254.0.0/16",
// IPv6 link-local, its IPv6 counterpart.
"fe80::/10",
// IPv6 metadata endpoints in ULA space. Each is a host
// route, and fd00::/8 is an ordinary block for an
// operator to allowlist, so without these entries that
// one allowlist line hands out cloud credentials on
// every provider below.
//
// AWS IPv6 IMDS.
"fd00:ec2::254/128",
// AWS EKS Pod Identity Agent, which issues pod identity
// credentials. A second AWS endpoint, distinct from
// IMDS above. AWS's own docs call it "localhost".
"fd00:ec2::23/128",
// GCP metadata server for IPv6-only instances.
"fd20:ce::254/128",
// Oracle OCI IMDS, serving /opc/v2 instance principals.
"fd00:c1::a9fe:a9fe/128",
// Scaleway metadata, serving /user_data and /conf.
"fd00:42::42/128",
// Linode/Akamai metadata. Akamai's docs call it
// "link-local"; it is not.
"fd00:a9fe:a9fe::1/128",
// IPv4 metadata endpoints outside link-local.
//
// Alibaba Cloud metadata. It sits in CGNAT
// 100.64.0.0/10, which Tailscale also uses, so an
// operator allowlisting a Tailscale peer's range would
// otherwise reopen it.
"100.100.100.200/32",
// Oracle Cloud Classic metadata. Inside the blocked
// 192.0.0.0/24, so this entry is what stops an
// allowlist from opening it.
"192.0.0.192/32",
// The unspecified addresses, each of which reaches this
// host's loopback on Linux.
//
// IPv4 unspecified address, inside the blocked 0.0.0.0/8.
"0.0.0.0/32",
// IPv6 unspecified address.
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
"64:ff9b::a9fe:a9fe/128",
})
}
// mustParseCIDRs parses a list of CIDR literals, panicking on a
// bad one. The inputs are compile-time constants, so a failure
// is a programming error rather than a runtime condition.
func mustParseCIDRs(cidrs []string) []*net.IPNet {
networks := make([]*net.IPNet, 0, len(cidrs))
for _, cidr := range cidrs { for _, cidr := range cidrs {
_, network, err := net.ParseCIDR(cidr) _, network, err := net.ParseCIDR(cidr)
@@ -272,15 +67,16 @@ func mustParseCIDRs(cidrs []string) []*net.IPNet {
)) ))
} }
networks = append(networks, network) blockedNetworks = append(
blockedNetworks, network,
)
} }
return networks
} }
// matchesAny reports whether ip falls inside any of networks. // isBlockedIP checks whether an IP address falls within
func matchesAny(networks []*net.IPNet, ip net.IP) bool { // any blocked private/reserved network range.
for _, network := range networks { func isBlockedIP(ip net.IP) bool {
for _, network := range blockedNetworks {
if network.Contains(ip) { if network.Contains(ip) {
return true return true
} }
@@ -289,33 +85,9 @@ func matchesAny(networks []*net.IPNet, ip net.IP) bool {
return false return false
} }
// Guard makes every SSRF decision in the process.
//
// It holds the operator's ALLOWED_EGRESS_CIDRS allowlist and
// applies it in exactly one place, checkIP, which both the
// target-creation validator (ValidateTargetURL) and the delivery
// dialer call. Routing both through the same function is the
// point: when the two paths decided separately they drifted and
// disagreed, which is what made a target creatable but
// undeliverable.
//
// The guard is always on. The allowlist only ever adds specific
// networks to what the default blocklist refuses, and no
// configuration turns the guard off wholesale.
type Guard struct {
// allowed is the operator's ALLOWED_EGRESS_CIDRS. Empty
// (the default) means the default blocklist stands as-is.
allowed []netip.Prefix
}
// NewGuard builds the process-wide SSRF guard from configuration.
func NewGuard(cfg *config.Config) *Guard {
return &Guard{allowed: cfg.AllowedEgressCIDRs}
}
// ValidateTargetURL checks that an HTTP delivery target // ValidateTargetURL checks that an HTTP delivery target
// URL is safe from SSRF attacks. // URL is safe from SSRF attacks.
func (g *Guard) ValidateTargetURL( func ValidateTargetURL(
ctx context.Context, targetURL string, ctx context.Context, targetURL string,
) error { ) error {
parsed, err := url.Parse(targetURL) parsed, err := url.Parse(targetURL)
@@ -339,87 +111,36 @@ func (g *Guard) ValidateTargetURL(
} }
if ip := net.ParseIP(host); ip != nil { if ip := net.ParseIP(host); ip != nil {
return g.checkIP(ip) return checkBlockedIP(ip)
} }
return g.validateHostname(ctx, host) return validateHostname(ctx, host)
} }
// NewSSRFSafeTransport creates an http.Transport with a func validateScheme(scheme string) error {
// custom DialContext that refuses connections to any address if scheme != "http" && scheme != "https" {
// this guard blocks. It resolves and checks at dial time, so a
// name that passed validation but now answers with a blocked
// address (DNS rebinding) is still refused.
func (g *Guard) NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: g.ssrfDialContext,
}
}
// allows reports whether ip falls inside the operator's
// configured egress allowlist.
func (g *Guard) allows(ip net.IP) bool {
if len(g.allowed) == 0 {
return false
}
addr, ok := netip.AddrFromSlice(ip)
if !ok {
return false
}
// Config unmaps every parsed prefix, so an IPv4-mapped
// address has to be unmapped too or it would never match.
addr = addr.Unmap()
for _, prefix := range g.allowed {
if prefix.Contains(addr) {
return true
}
}
return false
}
// checkIP is the single point at which SSRF policy is decided.
//
// The order is the policy:
//
// 1. alwaysBlockedNetworks is refused before the allowlist is
// consulted, so no configured CIDR reaches link-local, a
// cloud metadata endpoint at a non-public address, or an
// unspecified address.
// 2. The allowlist is consulted next, so a listed private
// network, or a listed public address on the default
// blocklist, becomes reachable.
// 3. Everything else keeps the default blocklist's answer.
func (g *Guard) checkIP(ip net.IP) error {
if matchesAny(alwaysBlockedNetworks, ip) {
return fmt.Errorf( return fmt.Errorf(
"target IP %s: %w", ip, errBlockedMetadata, "unsupported URL scheme %q: %w",
) scheme, errInvalidScheme,
}
if g.allows(ip) {
return nil
}
if matchesAny(blockedNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, ErrBlockedPrivateOrReservedIP,
)
}
if matchesAny(blockedPublicNetworks, ip) {
return fmt.Errorf(
"target IP %s: %w", ip, errBlockedPublicMetadata,
) )
} }
return nil return nil
} }
func (g *Guard) validateHostname( func checkBlockedIP(ip net.IP) error {
if isBlockedIP(ip) {
return fmt.Errorf(
"target IP %s is in a blocked "+
"private/reserved range: %w",
ip, errBlockedIP,
)
}
return nil
}
func validateHostname(
ctx context.Context, host string, ctx context.Context, host string,
) error { ) error {
dnsCtx, cancel := context.WithTimeout( dnsCtx, cancel := context.WithTimeout(
@@ -444,11 +165,11 @@ func (g *Guard) validateHostname(
} }
for _, ipAddr := range ips { for _, ipAddr := range ips {
err = g.checkIP(ipAddr.IP) if isBlockedIP(ipAddr.IP) {
if err != nil {
return fmt.Errorf( return fmt.Errorf(
"hostname %q resolves to a blocked address: %w", "hostname %q resolves to blocked "+
host, err, "IP %s: %w",
host, ipAddr.IP, errBlockedIP,
) )
} }
} }
@@ -456,7 +177,16 @@ func (g *Guard) validateHostname(
return nil return nil
} }
func (g *Guard) ssrfDialContext( // NewSSRFSafeTransport creates an http.Transport with a
// custom DialContext that blocks connections to
// private/reserved IP addresses.
func NewSSRFSafeTransport() *http.Transport {
return &http.Transport{
DialContext: ssrfDialContext,
}
}
func ssrfDialContext(
ctx context.Context, ctx context.Context,
network, addr string, network, addr string,
) (net.Conn, error) { ) (net.Conn, error) {
@@ -479,11 +209,11 @@ func (g *Guard) ssrfDialContext(
} }
for _, ipAddr := range ips { for _, ipAddr := range ips {
err = g.checkIP(ipAddr.IP) if isBlockedIP(ipAddr.IP) {
if err != nil {
return nil, fmt.Errorf( return nil, fmt.Errorf(
"ssrf: connection to %s blocked: %w", "ssrf: connection to %s (%s) "+
host, err, "blocked: %w",
host, ipAddr.IP, errBlockedIP,
) )
} }
} }
@@ -495,14 +225,3 @@ func (g *Guard) ssrfDialContext(
net.JoinHostPort(ips[0].IP.String(), port), net.JoinHostPort(ips[0].IP.String(), port),
) )
} }
func validateScheme(scheme string) error {
if scheme != "http" && scheme != "https" {
return fmt.Errorf(
"unsupported URL scheme %q: %w",
scheme, errInvalidScheme,
)
}
return nil
}
-716
View File
@@ -1,716 +0,0 @@
package delivery_test
import (
"context"
"net"
"net/http"
"net/http/httptest"
"net/netip"
"net/url"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/delivery"
)
// Addresses the SSRF tests in this package share.
const (
// metadataIP is the cloud instance metadata address, and
// metadataURL an endpoint on it. The guard must never reach
// either, whatever an operator lists.
metadataIP = "169.254.169.254"
metadataURL = "http://" + metadataIP + "/latest/meta-data/"
// linkLocalIPv4 is the IPv4 link-local block, which holds
// metadataIP.
linkLocalIPv4 = "169.254.0.0/16"
// loopbackHookURL is a target on this host: blocked by
// default, reachable only once an operator allowlists
// loopback.
loopbackHookURL = "http://127.0.0.1/hook"
// publicIP is an ordinary public address, which the guard
// permits with or without an allowlist.
publicIP = "93.184.216.34"
// allowAllIPv4 and allowAllIPv6 are the widest allowlist
// entries expressible: the whole internet, in each family.
// Nothing unconditionally blocked may be reachable under
// them.
allowAllIPv4 = "0.0.0.0/0"
allowAllIPv6 = "::/0"
// allowAllULA is the ordinary ULA block an operator lists to
// reach their own IPv6 network. Several providers park a
// metadata endpoint inside it.
allowAllULA = "fd00::/8"
// metadataRefusalClause is the part of the refusal that only
// alwaysBlockedNetworks produces. Asserting it, rather than
// the bare word "blocked", is what proves the unconditional
// set did the refusing and not the default blocklist.
metadataRefusalClause = "ALLOWED_EGRESS_CIDRS cannot open it"
)
// TestGuardAllowlist_PermittedCIDRDelivers proves the escape
// hatch actually works end to end: with 127.0.0.0/8 allowed, the
// guard's own transport connects to a loopback server and gets a
// response back. The default guard, given the identical URL,
// refuses it — so the delivery succeeds because of the allowlist
// and nothing else.
func TestGuardAllowlist_PermittedCIDRDelivers(t *testing.T) {
t.Parallel()
srv := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusNoContent)
},
))
t.Cleanup(srv.Close)
// httptest listens on loopback, which the default blocklist
// covers: exactly the "forward to a service on this host"
// case the allowlist exists for.
requireLoopback(t, srv.URL)
guard := delivery.NewTestGuard(
netip.MustParsePrefix("127.0.0.0/8"),
)
require.NoError(t,
guard.ValidateTargetURL(context.Background(), srv.URL),
"an allowlisted loopback target must pass validation",
)
client := &http.Client{
Timeout: 5 * time.Second,
Transport: guard.NewSSRFSafeTransport(),
}
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, srv.URL, nil,
)
require.NoError(t, err)
resp, err := client.Do(req)
require.NoError(t,
err, "an allowlisted loopback target must be deliverable",
)
defer func() { _ = resp.Body.Close() }()
assert.Equal(t, http.StatusNoContent, resp.StatusCode)
// The same URL through the default guard must still fail, or
// this test would pass without the allowlist doing anything.
assert.Error(t,
delivery.NewTestGuard().ValidateTargetURL(
context.Background(), srv.URL,
),
"without the allowlist the same target must be refused",
)
}
// TestGuardAllowlist_UnlistedPrivateStillRefused proves the
// allowlist grants only what it names. A guard that opens one
// private block must keep refusing every other one, at both the
// validation and the delivery entry point.
func TestGuardAllowlist_UnlistedPrivateStillRefused(t *testing.T) {
t.Parallel()
// Only 10.1.0.0/16 is open — a narrow block inside a much
// wider private range, so the test can tell "permits the
// listed block" from "permits anything private".
guard := delivery.NewTestGuard(
netip.MustParsePrefix("10.1.0.0/16"),
)
refused := []string{
"http://192.168.1.10/hook",
"http://172.16.0.1/hook",
loopbackHookURL,
"http://[fc00::1]/hook",
"http://100.64.0.1/hook",
// Private, adjacent to the allowed block, outside it.
"http://10.2.0.1/hook",
}
for _, target := range refused {
t.Run(target, func(t *testing.T) {
t.Parallel()
err := guard.ValidateTargetURL(
context.Background(), target,
)
require.Error(t,
err, "%s is not allowlisted and must be refused",
target,
)
assert.Contains(t, err.Error(), "blocked")
assertDialRefused(t, guard, target)
})
}
// The block that is listed must in fact be permitted, so the
// refusals above are selective rather than a guard that
// ignores its allowlist entirely.
assert.NoError(t,
guard.ValidateTargetURL(
context.Background(), "http://10.1.2.3/hook",
),
"the allowlisted block must be permitted",
)
}
// TestGuardAllowlist_MetadataAlwaysRefused is the load-bearing
// case: cloud instance metadata endpoints are credential theft
// rather than delivery to an internal service, and the
// unspecified addresses 0.0.0.0 and :: reach this host's loopback
// on Linux, so no allowlist reaches any of them. Every guard
// below names a CIDR that covers its target — including
// 0.0.0.0/0, ::/0, and the ordinary ULA and CGNAT blocks an
// operator would really list — and the address must stay
// refused anyway, on both the validation and the delivery path.
func TestGuardAllowlist_MetadataAlwaysRefused(t *testing.T) {
t.Parallel()
for _, tt := range metadataAlwaysRefusedCases() {
t.Run(tt.name, func(t *testing.T) {
t.Parallel()
guard := delivery.NewTestGuard(
netip.MustParsePrefix(tt.allow),
)
err := guard.ValidateTargetURL(
context.Background(), tt.target,
)
require.Error(t,
err,
"%s must stay blocked even though %s covers it",
tt.target, tt.allow,
)
assert.Contains(t,
err.Error(),
metadataRefusalClause,
"the refusal must say why it cannot be opened",
)
// The metadata clause, not just "blocked": that is
// what distinguishes the unconditional set from the
// ordinary blocklist.
assertDialRefusedWith(
t, guard, tt.target, metadataRefusalClause,
)
})
}
}
// metadataAlwaysRefusedCase is one (allowlist, target) pair that
// must be refused: allow covers target, and target must stay
// blocked regardless.
type metadataAlwaysRefusedCase struct {
name string
allow string
target string
}
// metadataAlwaysRefusedCases enumerates every unconditionally
// blocked address (link-local, the cloud metadata endpoints and
// the unspecified addresses) together with an allowlist entry
// that would otherwise reach it. Split by family of address only
// to stay under the function-length limit.
func metadataAlwaysRefusedCases() []metadataAlwaysRefusedCase {
cases := linkLocalRefusedCases()
cases = append(cases, ulaMetadataRefusedCases()...)
cases = append(cases, ipv4MetadataRefusedCases()...)
cases = append(cases, encodedMetadataRefusedCases()...)
return append(cases, unspecifiedRefusedCases()...)
}
// linkLocalRefusedCases covers the link-local blocks, including
// an operator naming the metadata address outright.
func linkLocalRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "exact metadata host",
allow: "169.254.169.254/32",
target: metadataURL,
},
{
name: "whole link-local block",
allow: linkLocalIPv4,
target: metadataURL,
},
{
name: "supernet covering link-local",
allow: "169.0.0.0/8",
target: metadataURL,
},
{
name: "the entire IPv4 internet",
allow: allowAllIPv4,
target: metadataURL,
},
{
name: "other link-local address",
allow: allowAllIPv4,
target: "http://169.254.1.1/",
},
{
name: "IPv6 link-local",
allow: allowAllIPv6,
target: "http://[fe80::1]/",
},
}
}
// ulaMetadataRefusedCases covers the metadata endpoints parked
// in ULA space. Every one is opened by the single ordinary
// allowlist entry fd00::/8, which is the whole reason they need
// their own /128 host routes: fe80::/10 does not cover a ULA,
// whatever the vendor's documentation calls the address.
func ulaMetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "AWS IPv6 IMDS under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:ec2::254]/latest/meta-data/",
},
{
// A second AWS credential endpoint, distinct from
// IMDS. AWS's own docs call this one "localhost".
name: "AWS EKS Pod Identity under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:ec2::23]/v1/credentials",
},
{
name: "GCP IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd20:ce::254]/computeMetadata/v1/",
},
{
name: "Oracle OCI IPv6 IMDS under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:c1::a9fe:a9fe]/opc/v2/instance/",
},
{
name: "Scaleway IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:42::42]/conf",
},
{
// Akamai's docs call this "link-local"; it is a ULA,
// so fe80::/10 does not cover it.
name: "Linode IPv6 metadata under an allowlisted ULA block",
allow: allowAllULA,
target: "http://[fd00:a9fe:a9fe::1]/v1/instance",
},
}
}
// ipv4MetadataRefusedCases covers the IPv4 metadata endpoints
// that sit outside link-local: one in CGNAT and one in the
// blocked 192.0.0.0/24, each reachable only through an allowlist
// that this set overrides.
func ipv4MetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
// Tailscale uses 100.64.0.0/10, so an operator
// forwarding to a Tailscale peer lists exactly this.
name: "Alibaba metadata under allowlisted CGNAT",
allow: "100.64.0.0/10",
target: "http://100.100.100.200/latest/meta-data/",
},
{
// Inside the already-blocked 192.0.0.0/24, so only
// an allowlist can reach it — and must not.
name: "Oracle Cloud Classic metadata under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://192.0.0.192/latest/meta-data/",
},
}
}
// encodedMetadataRefusedCases covers the alternate IPv6
// encodings of 169.254.169.254.
func encodedMetadataRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
// To4() does not normalise the IPv4-compatible form,
// so this needs its own always-blocked entry.
name: "IPv4-compatible IPv6 form of the metadata IP",
allow: allowAllIPv6,
target: "http://[::a9fe:a9fe]/latest/meta-data/",
},
{
// Nor the NAT64 well-known prefix form.
name: "NAT64 form of the metadata IP",
allow: allowAllIPv6,
target: "http://[64:ff9b::a9fe:a9fe]/latest/meta-data/",
},
{
// Already refused before this change: IPNet.Contains
// calls To4() first, so the mapped form matches
// 169.254.0.0/16. Pinned so it cannot regress.
//
// Allowed under 0.0.0.0/0 rather than ::/0: allows()
// unmaps before matching, so ::/0 would not cover the
// unmapped v4 address and the case would not prove
// the allowlist was overridden.
name: "IPv4-mapped IPv6 form of the metadata IP",
allow: allowAllIPv4,
target: "http://[::ffff:169.254.169.254]/latest/meta-data/",
},
}
}
// unspecifiedRefusedCases covers the unspecified addresses, each
// of which reaches this host's loopback on Linux.
func unspecifiedRefusedCases() []metadataAlwaysRefusedCase {
return []metadataAlwaysRefusedCase{
{
name: "IPv4 unspecified address under 0.0.0.0/0",
allow: allowAllIPv4,
target: "http://0.0.0.0:8080/hook",
},
{
name: "IPv6 unspecified address under ::/0",
allow: allowAllIPv6,
target: "http://[::]:8080/hook",
},
}
}
// TestGuardAllowlist_PublicUnaffected asserts the allowlist does
// not narrow anything: public addresses were reachable before it
// existed and stay reachable, whether or not a list is set.
func TestGuardAllowlist_PublicUnaffected(t *testing.T) {
t.Parallel()
guards := map[string]*delivery.Guard{
"default": delivery.NewTestGuard(),
"with allowlist": delivery.NewTestGuard(
netip.MustParsePrefix("10.0.0.0/8"),
),
}
for name, guard := range guards {
t.Run(name, func(t *testing.T) {
t.Parallel()
assert.NoError(t,
guard.ValidateTargetURL(
context.Background(),
"http://"+publicIP+"/webhook",
),
)
})
}
}
// TestGuardAllowlist_AzureWireServerReopenable covers Azure's
// WireServer, a public address that serves VM credentials. The
// default guard refuses it, but because it is public it sits in
// the default blocklist rather than the unconditional set, so an
// operator who lists it can reach it.
func TestGuardAllowlist_AzureWireServerReopenable(t *testing.T) {
t.Parallel()
const wireServerIP = "168.63.129.16"
target := "http://" + wireServerIP + "/?comp=versions"
defaultGuard := delivery.NewTestGuard()
err := defaultGuard.ValidateTargetURL(context.Background(), target)
require.Error(t, err,
"WireServer must be refused with no allowlist set",
)
assert.NotContains(t, err.Error(), metadataRefusalClause,
"WireServer must be refused by the default blocklist, "+
"which an allowlist can override",
)
require.NotErrorIs(t, err, delivery.ErrBlockedPrivateOrReservedIP,
"WireServer is public, not private or reserved",
)
assertDialRefused(t, defaultGuard, target)
listed := delivery.NewTestGuard(
netip.MustParsePrefix(wireServerIP + "/32"),
)
assert.NoError(t,
listed.ValidateTargetURL(context.Background(), target),
"an operator who lists WireServer must be able to reach it",
)
}
// TestGuardCheckIP_BothPathsShareOneDecision asserts that the
// validator and the dialer are not two policies that happen to
// agree: both are defined in terms of checkIP, so the exported
// decision function is the whole answer for a given address.
func TestGuardCheckIP_BothPathsShareOneDecision(t *testing.T) {
t.Parallel()
guard := delivery.NewTestGuard(
netip.MustParsePrefix("10.0.0.0/8"),
)
tests := []struct {
ip string
allowed bool
}{
{"10.1.2.3", true},
{publicIP, true},
{"192.168.1.1", false},
{"127.0.0.1", false},
{metadataIP, false},
}
for _, tt := range tests {
t.Run(tt.ip, func(t *testing.T) {
t.Parallel()
ip := net.ParseIP(tt.ip)
require.NotNil(t, ip)
decision := guard.ExportCheckIP(ip)
validation := guard.ValidateTargetURL(
context.Background(), "http://"+hostFor(tt.ip)+"/x",
)
if tt.allowed {
require.NoError(t, decision)
require.NoError(t, validation)
return
}
require.Error(t, decision)
require.Error(t, validation,
"validation must refuse what checkIP refuses",
)
})
}
}
// TestAlwaysBlockedNetworks_PinnedSet pins the unconditional set
// exactly, so it cannot quietly grow or shrink.
//
// It stays deliberately small. Everything else in the default
// blocklist is an operator's own network and must remain
// openable, or the escape hatch would not work — which is why
// the metadata endpoints outside the link-local range are host
// routes rather than the blocks that contain them.
func TestAlwaysBlockedNetworks_PinnedSet(t *testing.T) {
t.Parallel()
nets := delivery.ExportAlwaysBlockedNetworks()
got := make([]string, 0, len(nets))
for _, n := range nets {
got = append(got, n.String())
}
want := []string{
// IPv4 link-local: the 169.254.169.254 metadata
// service on AWS, Azure and others.
linkLocalIPv4,
// IPv6 link-local.
"fe80::/10",
// AWS IPv6 IMDS, inside the ULA space an operator may
// legitimately allowlist.
"fd00:ec2::254/128",
// AWS EKS Pod Identity Agent, likewise ULA.
"fd00:ec2::23/128",
// GCP metadata for IPv6-only instances, likewise ULA.
"fd20:ce::254/128",
// Oracle OCI IMDS over IPv6, likewise ULA.
"fd00:c1::a9fe:a9fe/128",
// Scaleway metadata over IPv6, likewise ULA.
"fd00:42::42/128",
// Linode/Akamai metadata over IPv6, likewise ULA.
"fd00:a9fe:a9fe::1/128",
// Alibaba Cloud metadata, inside CGNAT.
"100.100.100.200/32",
// Oracle Cloud Classic metadata, inside the blocked
// 192.0.0.0/24.
"192.0.0.192/32",
// The IPv4 and IPv6 unspecified addresses, each of
// which reaches this host's loopback on Linux.
"0.0.0.0/32",
"::/128",
// 169.254.169.254 as an IPv4-compatible IPv6 address.
"::a9fe:a9fe/128",
// 169.254.169.254 behind the NAT64 well-known prefix.
"64:ff9b::a9fe:a9fe/128",
}
assert.Equal(t, want, got)
}
// TestDefaultBlocklist_PinnedSet pins each list of the default
// blocklist on its own, the private and reserved ranges in
// blockedNetworks and the public addresses in
// blockedPublicNetworks, so moving an entry from one list to the
// other fails it. For the first address of each entry it then
// checks that the default guard refuses it, and that listing the
// entry in ALLOWED_EGRESS_CIDRS opens it unless the unconditional
// set holds that address.
func TestDefaultBlocklist_PinnedSet(t *testing.T) {
t.Parallel()
// public marks an entry of blockedPublicNetworks; every other
// entry belongs in blockedNetworks.
tests := []struct {
cidr string
public bool
reopenable bool
}{
{cidr: "127.0.0.0/8", reopenable: true},
{cidr: "10.0.0.0/8", reopenable: true},
{cidr: "172.16.0.0/12", reopenable: true},
{cidr: "192.168.0.0/16", reopenable: true},
{cidr: linkLocalIPv4, reopenable: false},
// Its first address, 0.0.0.0, is in the unconditional set.
{cidr: "0.0.0.0/8", reopenable: false},
{cidr: "100.64.0.0/10", reopenable: true},
{cidr: "192.0.0.0/24", reopenable: true},
{cidr: "192.0.2.0/24", reopenable: true},
{cidr: "198.18.0.0/15", reopenable: true},
{cidr: "198.51.100.0/24", reopenable: true},
{cidr: "203.0.113.0/24", reopenable: true},
{cidr: "224.0.0.0/4", reopenable: true},
{cidr: "240.0.0.0/4", reopenable: true},
{cidr: "::1/128", reopenable: true},
{cidr: "::/128", reopenable: false},
{cidr: "fc00::/7", reopenable: true},
{cidr: "fe80::/10", reopenable: false},
{cidr: "ff00::/8", reopenable: true},
{cidr: "2001:db8::/32", reopenable: true},
{cidr: "168.63.129.16/32", public: true, reopenable: true},
}
wantPrivate := make([]string, 0, len(tests))
wantPublic := make([]string, 0, len(tests))
for _, tt := range tests {
if tt.public {
wantPublic = append(wantPublic, tt.cidr)
} else {
wantPrivate = append(wantPrivate, tt.cidr)
}
}
gotPrivate := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedNetworks() {
gotPrivate = append(gotPrivate, n.String())
}
gotPublic := make([]string, 0, len(tests))
for _, n := range delivery.ExportBlockedPublicNetworks() {
gotPublic = append(gotPublic, n.String())
}
assert.ElementsMatch(t, wantPrivate, gotPrivate, "blockedNetworks")
assert.ElementsMatch(t, wantPublic, gotPublic, "blockedPublicNetworks")
for _, tt := range tests {
t.Run(tt.cidr, func(t *testing.T) {
t.Parallel()
prefix := netip.MustParsePrefix(tt.cidr)
ip := net.IP(prefix.Addr().AsSlice())
require.Error(t,
delivery.NewTestGuard().ExportCheckIP(ip),
"the default guard must refuse %s", ip,
)
err := delivery.NewTestGuard(prefix).ExportCheckIP(ip)
if tt.reopenable {
assert.NoError(t, err, "listing %s must open it", tt.cidr)
} else {
assert.Error(t, err, "listing %s must not open it", tt.cidr)
}
})
}
}
// requireLoopback fails the test unless rawURL's host is a
// loopback address, so the allowlist test cannot silently stop
// exercising a blocked range.
func requireLoopback(t *testing.T, rawURL string) {
t.Helper()
parsed, err := url.Parse(rawURL)
require.NoError(t, err)
ip := net.ParseIP(parsed.Hostname())
require.NotNil(t, ip, "test server host must be an IP literal")
require.True(t, ip.IsLoopback(),
"test server must listen on loopback, got %s", ip,
)
}
// assertDialRefused asserts the guard's transport refuses to
// connect to target, which is the delivery-time half of the
// policy. It never reaches the network: the guard checks the
// resolved address before dialling.
func assertDialRefused(
t *testing.T, guard *delivery.Guard, target string,
) {
t.Helper()
assertDialRefusedWith(t, guard, target, "blocked")
}
// assertDialRefusedWith is assertDialRefused with the refusal
// text pinned. Callers testing the unconditional set pass
// metadataRefusalClause so the subtest cannot pass on an
// ordinary blocklist refusal instead.
func assertDialRefusedWith(
t *testing.T, guard *delivery.Guard, target, clause string,
) {
t.Helper()
client := &http.Client{
Timeout: 5 * time.Second,
Transport: guard.NewSSRFSafeTransport(),
}
req, err := http.NewRequestWithContext(
context.Background(), http.MethodPost, target, nil,
)
require.NoError(t, err)
resp, err := client.Do(req)
if resp != nil {
_ = resp.Body.Close()
}
require.Error(t, err,
"delivery to %s must be refused by the dialer", target,
)
assert.Contains(t, err.Error(), clause,
"the refusal must come from the SSRF guard",
)
}
// hostFor renders an IP as it appears in a URL host, bracketing
// IPv6 literals.
func hostFor(ip string) string {
if net.ParseIP(ip).To4() == nil {
return "[" + ip + "]"
}
return ip
}
+13 -51
View File
@@ -10,7 +10,7 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
func TestGuardCheckIP_PrivateRanges(t *testing.T) { func TestIsBlockedIP_PrivateRanges(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
@@ -31,10 +31,10 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
{"192.168.0.1", "192.168.0.1", true}, {"192.168.0.1", "192.168.0.1", true},
{"192.168.255.255", "192.168.255.255", true}, {"192.168.255.255", "192.168.255.255", true},
{"169.254.0.1", "169.254.0.1", true}, {"169.254.0.1", "169.254.0.1", true},
{metadataIP, metadataIP, true}, {"169.254.169.254", "169.254.169.254", true},
{"8.8.8.8", "8.8.8.8", false}, {"8.8.8.8", "8.8.8.8", false},
{"1.1.1.1", "1.1.1.1", false}, {"1.1.1.1", "1.1.1.1", false},
{publicIP, publicIP, false}, {"93.184.216.34", "93.184.216.34", false},
{"::1", "::1", true}, {"::1", "::1", true},
{"fd00::1", "fd00::1", true}, {"fd00::1", "fd00::1", true},
{"fc00::1", "fc00::1", true}, {"fc00::1", "fc00::1", true},
@@ -56,14 +56,12 @@ func TestGuardCheckIP_PrivateRanges(t *testing.T) {
"failed to parse IP %s", tt.ip, "failed to parse IP %s", tt.ip,
) )
refused := delivery.NewTestGuard().ExportCheckIP(ip) != nil
assert.Equal(t, assert.Equal(t,
tt.blocked, tt.blocked,
refused, delivery.ExportIsBlockedIP(ip),
"default guard refuses %s = %v, want %v", "isBlockedIP(%s) = %v, want %v",
tt.ip, tt.ip,
refused, delivery.ExportIsBlockedIP(ip),
tt.blocked, tt.blocked,
) )
}) })
@@ -74,12 +72,12 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
t.Parallel() t.Parallel()
blockedURLs := []string{ blockedURLs := []string{
loopbackHookURL, "http://127.0.0.1/hook",
"http://127.0.0.1:8080/hook", "http://127.0.0.1:8080/hook",
"https://10.0.0.1/hook", "https://10.0.0.1/hook",
"http://192.168.1.1/webhook", "http://192.168.1.1/webhook",
"http://172.16.0.1/api", "http://172.16.0.1/api",
metadataURL, "http://169.254.169.254/latest/meta-data/",
"http://[::1]/hook", "http://[::1]/hook",
"http://[fc00::1]/hook", "http://[fc00::1]/hook",
"http://[fe80::1]/hook", "http://[fe80::1]/hook",
@@ -90,7 +88,7 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
t.Run(u, func(t *testing.T) { t.Run(u, func(t *testing.T) {
t.Parallel() t.Parallel()
err := delivery.NewTestGuard().ValidateTargetURL( err := delivery.ValidateTargetURL(
context.Background(), u, context.Background(), u,
) )
@@ -101,42 +99,6 @@ func TestValidateTargetURL_Blocked(t *testing.T) {
} }
} }
// TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation
// covers the unspecified addresses and the IPv6 multicast and
// documentation ranges: with no allowlist set, each is refused
// both when a target is created and when a delivery dials it.
func TestDefaultGuard_RefusesUnspecifiedMulticastAndDocumentation(
t *testing.T,
) {
t.Parallel()
guard := delivery.NewTestGuard()
targets := []string{
// The unspecified addresses. On Linux a connection to
// either reaches this host's loopback.
"http://0.0.0.0:8080/hook",
"http://[::]:8080/hook",
// IPv6 multicast, all nodes.
"http://[ff02::1]/hook",
// IPv6 documentation.
"http://[2001:db8::1]/hook",
}
for _, target := range targets {
t.Run(target, func(t *testing.T) {
t.Parallel()
require.Error(t,
guard.ValidateTargetURL(context.Background(), target),
"%s must be refused at target creation", target,
)
assertDialRefused(t, guard, target)
})
}
}
func TestValidateTargetURL_Allowed(t *testing.T) { func TestValidateTargetURL_Allowed(t *testing.T) {
t.Parallel() t.Parallel()
@@ -150,7 +112,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
t.Run(u, func(t *testing.T) { t.Run(u, func(t *testing.T) {
t.Parallel() t.Parallel()
err := delivery.NewTestGuard().ValidateTargetURL( err := delivery.ValidateTargetURL(
context.Background(), u, context.Background(), u,
) )
@@ -164,7 +126,7 @@ func TestValidateTargetURL_Allowed(t *testing.T) {
func TestValidateTargetURL_InvalidScheme(t *testing.T) { func TestValidateTargetURL_InvalidScheme(t *testing.T) {
t.Parallel() t.Parallel()
err := delivery.NewTestGuard().ValidateTargetURL( err := delivery.ValidateTargetURL(
context.Background(), "ftp://example.com/hook", context.Background(), "ftp://example.com/hook",
) )
@@ -178,7 +140,7 @@ func TestValidateTargetURL_InvalidScheme(t *testing.T) {
func TestValidateTargetURL_EmptyHost(t *testing.T) { func TestValidateTargetURL_EmptyHost(t *testing.T) {
t.Parallel() t.Parallel()
err := delivery.NewTestGuard().ValidateTargetURL( err := delivery.ValidateTargetURL(
context.Background(), "http:///path", context.Background(), "http:///path",
) )
@@ -188,7 +150,7 @@ func TestValidateTargetURL_EmptyHost(t *testing.T) {
func TestValidateTargetURL_InvalidURL(t *testing.T) { func TestValidateTargetURL_InvalidURL(t *testing.T) {
t.Parallel() t.Parallel()
err := delivery.NewTestGuard().ValidateTargetURL( err := delivery.ValidateTargetURL(
context.Background(), "://invalid", context.Background(), "://invalid",
) )
-13
View File
@@ -27,12 +27,6 @@ type Scheduler interface {
// own circuit breaker, and reschedules via the injected // own circuit breaker, and reschedules via the injected
// Scheduler. Fire-and-forget targets simply record a single // Scheduler. Fire-and-forget targets simply record a single
// attempt. // attempt.
//
// An implementation reports each attempt it actually dispatches to
// Engine.observeAttempt, alongside the DeliveryResult it records for
// it. Deliver is also entered for attempts that never happen — an
// open circuit breaker refuses one — so the count cannot be taken
// from around this call.
type Target interface { type Target interface {
Deliver( Deliver(
ctx context.Context, ctx context.Context,
@@ -80,12 +74,6 @@ type attemptResult struct {
errMsg string errMsg string
} }
// elapsed returns how long the attempt took. The field is stored in
// milliseconds because that is what DeliveryResult persists.
func (r attemptResult) elapsed() time.Duration {
return time.Duration(r.duration) * time.Millisecond
}
// initTargets builds the target registry, wiring each target // initTargets builds the target registry, wiring each target
// to the engine's persistence helpers and giving the HTTP and // to the engine's persistence helpers and giving the HTTP and
// Slack targets the shared SSRF-safe client. It is called by // Slack targets the shared SSRF-safe client. It is called by
@@ -105,7 +93,6 @@ func (e *Engine) initTargets(client *http.Client) {
dbT := &databaseTarget{eng: e} dbT := &databaseTarget{eng: e}
e.httpTarget = httpT e.httpTarget = httpT
e.slackTarget = slackT
e.dbTarget = dbT e.dbTarget = dbT
e.targets = map[database.TargetType]Target{ e.targets = map[database.TargetType]Target{
-132
View File
@@ -1,132 +0,0 @@
package delivery
import (
"encoding/json"
"errors"
"fmt"
"sneak.berlin/go/webhooker/internal/database"
)
// errUnknownTargetTypeForEdit is returned when a stored target has a
// type the edit form has no field set for.
var errUnknownTargetTypeForEdit = errors.New(
"unknown target type",
)
// TargetConfigForm is the UNMASKED projection of a target's stored
// configuration, for pre-filling the target edit form.
//
// It is the deliberate exception to the rule TargetView enforces
// everywhere else: TargetView exists so that no template can render
// a target's stored blob, because a destination URL's path segments
// and a header value are both routinely the credential. An operator
// cannot correct a value they cannot see, so the edit form — and
// only the edit form — is shown the full value.
//
// Everything that keeps that exception narrow lives at the call
// site: the route is behind RequireAuth and the webhook's ownership
// check, and its group sets NoCache so the rendered secret is not
// written to a shared cache. Do not reach for this type from any
// other page.
type TargetConfigForm struct {
// URL is the destination for an HTTP target and the webhook
// URL for a Slack target.
URL string
// Headers is the HTTP target's configured headers in the
// textarea representation, one "Name: value" per line.
Headers string
// Timeout is the HTTP target's per-request timeout in seconds,
// empty when unset.
Timeout string
// Expiry is the database (archive) target's row expiry.
Expiry string
// Rotation is the database (archive) target's rotation.
Rotation string
}
// NewTargetConfigForm parses a target's stored configuration into
// the edit form's fields.
//
// A configuration that does not parse is an error rather than a
// zero-valued form that silently looks like a target with no
// settings. The caller shows the operator that the stored value
// could not be read, so that saving the form is understood as
// replacing it rather than preserving it.
func NewTargetConfigForm(
t *database.Target,
) (TargetConfigForm, error) {
switch t.Type {
case database.TargetTypeHTTP:
cfg, err := parseHTTPConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{
URL: cfg.URL,
Headers: FormatTargetHeaders(cfg.Headers),
Timeout: FormatTargetTimeout(cfg.Timeout),
}, nil
case database.TargetTypeSlack:
cfg, err := parseSlackConfig(t.Config)
if err != nil {
return TargetConfigForm{}, err
}
return TargetConfigForm{URL: cfg.WebhookURL}, nil
case database.TargetTypeDatabase:
return databaseConfigForm(t.Config)
case database.TargetTypeLog:
// The log target takes no configuration.
return TargetConfigForm{}, nil
default:
return TargetConfigForm{}, fmt.Errorf(
"%w: %q", errUnknownTargetTypeForEdit, t.Type,
)
}
}
// databaseConfigForm parses an archive target's optional expiry and
// rotation. An absent, empty or never expiry yields an empty expiry,
// on which the edit form starts at never; saving it unchanged stores
// never, which means the same as an empty expiry. An absent rotation
// is empty too, and the form starts at none. An expiry that is set
// but not a valid duration, or a rotation that is not one of the
// four, is an error, not a blank field.
func databaseConfigForm(
configJSON string,
) (TargetConfigForm, error) {
if configJSON == "" {
return TargetConfigForm{}, nil
}
var cfg databaseTargetConfig
err := json.Unmarshal([]byte(configJSON), &cfg)
if err != nil {
return TargetConfigForm{}, fmt.Errorf(
"parsing config JSON: %w", err,
)
}
err = ValidateArchiveRotation(cfg.Rotation)
if err != nil {
return TargetConfigForm{}, err
}
form := TargetConfigForm{Rotation: cfg.Rotation}
if cfg.Expiry == "" || cfg.Expiry == archiveExpiryNever {
return form, nil
}
err = ValidateArchiveExpiry(cfg.Expiry)
if err != nil {
return TargetConfigForm{}, err
}
form.Expiry = cfg.Expiry
return form, nil
}
+58 -117
View File
@@ -1,9 +1,9 @@
package delivery package delivery
import ( import (
"encoding/json"
"fmt" "fmt"
"strconv" "strconv"
"time"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
@@ -23,51 +23,21 @@ type ConfigField struct {
Value string Value string
} }
// deletedNameSuffix marks the name of a target that no longer
// exists. Deletes are soft and delivery history outlives the
// target, so the event log shows names of targets that are gone;
// an operator reading one needs to know it cannot be delivered
// to, replayed to, or configured.
const deletedNameSuffix = " (deleted)"
// TargetView is the display-safe projection of a target for // TargetView is the display-safe projection of a target for
// the UI. It deliberately has no raw configuration field, so // the UI. It deliberately has no raw configuration field, so
// no template — present or future — can render the stored // no template — present or future — can render the stored
// blob. // blob.
type TargetView struct { type TargetView struct {
ID string ID string
Name string Name string
// Deleted reports that this target's row is soft deleted.
// Only views built for historical display carry it set:
// every other projection is of a live row.
Deleted bool
Type database.TargetType Type database.TargetType
Active bool Active bool
Config []ConfigField Config []ConfigField
} }
// DisplayName is the name to render, marked when the target has
// been deleted. Templates showing a name against historical data
// must use it rather than Name, which stays the stored name.
func (v TargetView) DisplayName() string {
if v.Deleted {
return v.Name + deletedNameSuffix
}
return v.Name
}
// NewTargetViews projects targets for rendering, replacing // NewTargetViews projects targets for rendering, replacing
// each stored configuration blob with named, display-safe // each stored configuration blob with named, display-safe
// fields. // fields.
//
// A soft-deleted row projects exactly as a live one does, minus
// the deleted marker on its name: masking is a property of the
// projection, not of the row's state, so a deleted target's
// credential is as unreachable from a template as a live
// target's.
func NewTargetViews( func NewTargetViews(
targets []database.Target, targets []database.Target,
) []TargetView { ) []TargetView {
@@ -77,12 +47,11 @@ func NewTargetViews(
t := &targets[i] t := &targets[i]
views = append(views, TargetView{ views = append(views, TargetView{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Deleted: t.DeletedAt.Valid, Type: t.Type,
Type: t.Type, Active: t.Active,
Active: t.Active, Config: targetConfigFields(t),
Config: targetConfigFields(t),
}) })
} }
@@ -97,7 +66,7 @@ func targetConfigFields(
) []ConfigField { ) []ConfigField {
switch t.Type { switch t.Type {
case database.TargetTypeSlack: case database.TargetTypeSlack:
return slackConfigFields(t) return slackConfigFields(t.Config)
case database.TargetTypeHTTP: case database.TargetTypeHTTP:
return httpConfigFields(t) return httpConfigFields(t)
case database.TargetTypeDatabase: case database.TargetTypeDatabase:
@@ -119,11 +88,10 @@ func unavailableConfigFields() []ConfigField {
}} }}
} }
// slackConfigFields describes a Slack target: its masked // slackConfigFields describes a Slack target. Only the masked
// webhook URL and its retry count. Only the masked URL is // webhook URL is shown; the full URL is the credential.
// shown; the full URL is the credential. func slackConfigFields(configJSON string) []ConfigField {
func slackConfigFields(t *database.Target) []ConfigField { cfg, err := parseSlackConfig(configJSON)
cfg, err := parseSlackConfig(t.Config)
if err != nil { if err != nil {
return unavailableConfigFields() return unavailableConfigFields()
} }
@@ -131,7 +99,7 @@ func slackConfigFields(t *database.Target) []ConfigField {
return []ConfigField{{ return []ConfigField{{
Label: "Webhook URL", Label: "Webhook URL",
Value: cfg.MaskedWebhookURL(), Value: cfg.MaskedWebhookURL(),
}, maxRetriesField(t)} }}
} }
// httpConfigFields describes an HTTP target: its destination // httpConfigFields describes an HTTP target: its destination
@@ -171,90 +139,63 @@ func httpConfigFields(t *database.Target) []ConfigField {
}) })
} }
fields = append(fields, maxRetriesField(t)) return append(fields, retryFields(t)...)
}
// retryFields describes a target's retry settings, which live
// on the target row rather than in its configuration blob.
func retryFields(t *database.Target) []ConfigField {
retries := strconv.Itoa(t.MaxRetries)
if t.MaxRetries == 0 {
retries += " (fire-and-forget)"
}
fields := []ConfigField{{
Label: "Max Retries",
Value: retries,
}}
if t.MaxQueueSize > 0 {
fields = append(fields, ConfigField{
Label: "Max Queue Size",
Value: strconv.Itoa(t.MaxQueueSize),
})
}
return fields return fields
} }
// maxRetriesField describes a target's retry count, which lives // databaseConfigFields describes an archive target. Its
// on the target row rather than in its configuration blob. A // configuration is optional, and an absent or empty expiry
// stored 0 makes a single attempt, so it is shown as 1. // means the archive is kept forever. An expiry that is set
func maxRetriesField(t *database.Target) ConfigField { // but not a valid duration is reported as unavailable rather
attempts := strconv.Itoa(t.MaxRetries) // than echoed back.
if t.MaxRetries == 0 {
attempts = "1 (fire-and-forget: no retries, no circuit breaker)"
}
return ConfigField{
Label: "Delivery attempts",
Value: attempts,
}
}
// databaseConfigFields describes an archive target by its
// expiry in plain units, such as "30 days", or "never" when
// the archive is kept forever, and by its rotation. An expiry
// that is set but not a valid duration, or a rotation that is
// not one of the four, is reported as unavailable rather than
// echoed back.
func databaseConfigFields(configJSON string) []ConfigField { func databaseConfigFields(configJSON string) []ConfigField {
expiry, err := parseArchiveExpiry(configJSON) expiry := archiveExpiryNever
if err != nil {
return unavailableConfigFields()
}
rotation, err := parseArchiveRotation(configJSON) if configJSON != "" {
if err != nil { var cfg databaseTargetConfig
return unavailableConfigFields()
}
value := archiveExpiryNever err := json.Unmarshal([]byte(configJSON), &cfg)
if expiry > 0 { if err != nil {
value = plainDuration(expiry) return unavailableConfigFields()
}
if cfg.Expiry != "" {
if ValidateArchiveExpiry(cfg.Expiry) != nil {
return unavailableConfigFields()
}
expiry = cfg.Expiry
}
} }
return []ConfigField{{ return []ConfigField{{
Label: "Archive expiry", Label: "Archive Expiry",
Value: value, Value: expiry,
}, {
Label: "Archive rotation",
Value: rotation,
}} }}
} }
// plainDuration writes a positive duration as a count of the
// largest whole unit it divides into: "30 days", "12 hours",
// "1 minute". A duration with a fraction of a second is
// written as Go writes it.
func plainDuration(d time.Duration) string {
const day = 24 * time.Hour
units := []struct {
size time.Duration
name string
}{
{day, "day"},
{time.Hour, "hour"},
{time.Minute, "minute"},
{time.Second, "second"},
}
for _, unit := range units {
if d%unit.size != 0 {
continue
}
count := int64(d / unit.size)
if count == 1 {
return "1 " + unit.name
}
return fmt.Sprintf("%d %ss", count, unit.name)
}
return d.String()
}
// MaskedWebhookURL returns the Slack webhook URL reduced to // MaskedWebhookURL returns the Slack webhook URL reduced to
// its scheme and host, with the path, query and any userinfo // its scheme and host, with the path, query and any userinfo
// elided. The path segments are the credential, so none of // elided. The path segments are the credential, so none of
+18 -132
View File
@@ -2,11 +2,9 @@ package delivery_test
import ( import (
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
@@ -19,20 +17,11 @@ const (
slackWebhookURL = "https://hooks.slack.com" + slackWebhookURL = "https://hooks.slack.com" +
slackSecretPath slackSecretPath
// slackMaskedURL is what a Slack webhook URL renders as
// once masked: scheme and host, path elided.
slackMaskedURL = "https://hooks.slack.com/..."
// slackTargetName is the target name the Slack projection
// tests use.
slackTargetName = "slack-target"
viewExampleOrigin = "https://example.com" viewExampleOrigin = "https://example.com"
viewExampleHook = viewExampleOrigin + "/hook" viewExampleHook = viewExampleOrigin + "/hook"
viewMaskedOrigin = viewExampleOrigin + "/..." viewMaskedOrigin = viewExampleOrigin + "/..."
viewUnavailable = "(unavailable)" viewUnavailable = "(unavailable)"
viewExpiryNever = "never" viewExpiryNever = "never"
viewMaxRetries = "Delivery attempts"
) )
func TestMaskedWebhookURL(t *testing.T) { func TestMaskedWebhookURL(t *testing.T) {
@@ -44,7 +33,7 @@ func TestMaskedWebhookURL(t *testing.T) {
}{ }{
"slack webhook": { "slack webhook": {
url: slackWebhookURL, url: slackWebhookURL,
want: slackMaskedURL, want: "https://hooks.slack.com/...",
}, },
"query string dropped": { "query string dropped": {
url: viewExampleOrigin + "/a?token=secret", url: viewExampleOrigin + "/a?token=secret",
@@ -136,81 +125,22 @@ func viewFor(
return views[0] return views[0]
} }
// TestNewTargetViews_DeletedTarget proves the projection marks func TestNewTargetViews_Slack(t *testing.T) {
// a soft-deleted target's name and masks its configuration by
// the same rules a live target's is. Delivery history outlives
// the target it names, so this projection is what an operator
// reads about a target that no longer exists.
func TestNewTargetViews_DeletedTarget(t *testing.T) {
t.Parallel() t.Parallel()
target := slackTarget() view := viewFor(t, database.Target{
target.DeletedAt = gorm.DeletedAt{ Name: "slack-target",
Time: time.Now(),
Valid: true,
}
view := viewFor(t, target)
assert.True(t, view.Deleted)
assert.Equal(t, slackTargetName, view.Name)
assert.Equal(
t, slackTargetName+" (deleted)", view.DisplayName(),
)
assert.Equal(
t, viewFor(t, slackTarget()).Config, view.Config,
)
}
// slackTarget is the live Slack target the projection tests
// share.
func slackTarget() database.Target {
return database.Target{
Name: slackTargetName,
Type: database.TargetTypeSlack, Type: database.TargetTypeSlack,
Active: true, Active: true,
Config: `{"webhookUrl":"` + Config: `{"webhookUrl":"` +
slackWebhookURL + `"}`, slackWebhookURL + `"}`,
} })
}
func TestNewTargetViews_Slack(t *testing.T) {
t.Parallel()
view := viewFor(t, slackTarget())
assert.Equal(t, slackTargetName, view.Name)
// A live target is never marked, so the marker cannot
// reach a name that still exists.
assert.False(t, view.Deleted)
assert.Equal(t, slackTargetName, view.DisplayName())
assert.Equal(t, "slack-target", view.Name)
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{
"Webhook URL": slackMaskedURL, "Webhook URL": "https://hooks.slack.com/...",
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)",
},
fieldMap(view.Config),
)
}
// TestNewTargetViews_SlackRetries proves a Slack target shows
// its retry count the same way an HTTP target does.
func TestNewTargetViews_SlackRetries(t *testing.T) {
t.Parallel()
target := slackTarget()
target.MaxRetries = 2
view := viewFor(t, target)
assert.Equal(
t,
map[string]string{
"Webhook URL": slackMaskedURL,
viewMaxRetries: "2",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -224,7 +154,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
Config: `{"url":"` + viewExampleHook + `",` + Config: `{"url":"` + viewExampleHook + `",` +
`"timeout":30,` + `"timeout":30,` +
`"headers":{"Authorization":"Bearer sekrit"}}`, `"headers":{"Authorization":"Bearer sekrit"}}`,
MaxRetries: 5, MaxRetries: 5,
MaxQueueSize: 100,
}) })
fields := fieldMap(view.Config) fields := fieldMap(view.Config)
@@ -235,7 +166,8 @@ func TestNewTargetViews_HTTP(t *testing.T) {
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
"Timeout": "30s", "Timeout": "30s",
"Headers": "1 configured", "Headers": "1 configured",
viewMaxRetries: "5", "Max Retries": "5",
"Max Queue Size": "100",
}, },
fields, fields,
) )
@@ -258,7 +190,7 @@ func TestNewTargetViews_HTTPFireAndForget(t *testing.T) {
t, t,
map[string]string{ map[string]string{
"Destination URL": viewMaskedOrigin, "Destination URL": viewMaskedOrigin,
viewMaxRetries: "1 (fire-and-forget: no retries, no circuit breaker)", "Max Retries": "0 (fire-and-forget)",
}, },
fieldMap(view.Config), fieldMap(view.Config),
) )
@@ -280,7 +212,7 @@ func TestNewTargetViews_HTTPMasksDestinationURL(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
slackMaskedURL, "https://hooks.slack.com/...",
fields["Destination URL"], fields["Destination URL"],
) )
@@ -301,20 +233,14 @@ func TestNewTargetViews_Database(t *testing.T) {
}{ }{
"empty config": {config: "", want: viewExpiryNever}, "empty config": {config: "", want: viewExpiryNever},
"empty expiry": {config: `{}`, want: viewExpiryNever}, "empty expiry": {config: `{}`, want: viewExpiryNever},
"explicit": {
config: `{"expiry":"720h"}`,
want: "720h",
},
"never literal": { "never literal": {
config: `{"expiry":"` + viewExpiryNever + `"}`, config: `{"expiry":"` + viewExpiryNever + `"}`,
want: viewExpiryNever, want: viewExpiryNever,
}, },
"1h": {config: `{"expiry":"1h"}`, want: "1 hour"},
"12h": {config: `{"expiry":"12h"}`, want: "12 hours"},
"24h": {config: `{"expiry":"24h"}`, want: "1 day"},
"720h": {config: `{"expiry":"720h"}`, want: "30 days"},
"2160h": {config: `{"expiry":"2160h"}`, want: "90 days"},
"8760h": {config: `{"expiry":"8760h"}`, want: "365 days"},
"36h": {config: `{"expiry":"36h"}`, want: "36 hours"},
"1h30m": {config: `{"expiry":"1h30m"}`, want: "90 minutes"},
"45s": {config: `{"expiry":"45s"}`, want: "45 seconds"},
"1.5s": {config: `{"expiry":"1.5s"}`, want: "1.5s"},
} }
for name, tc := range tests { for name, tc := range tests {
@@ -328,49 +254,13 @@ func TestNewTargetViews_Database(t *testing.T) {
assert.Equal( assert.Equal(
t, t,
map[string]string{ map[string]string{"Archive Expiry": tc.want},
"Archive expiry": tc.want,
"Archive rotation": rotationNone,
},
fieldMap(view.Config), fieldMap(view.Config),
) )
}) })
} }
} }
// TestNewTargetViews_DatabaseRotation proves the target list shows a
// database target's rotation, none when it has none stored.
func TestNewTargetViews_DatabaseRotation(t *testing.T) {
t.Parallel()
// Each stored config, and the rotation the list shows for it.
tests := map[string]string{
"": rotationNone,
`{"rotation":""}`: rotationNone,
}
for _, rotation := range []string{
rotationNone, rotationMonthly, rotationDaily, rotationHourly,
} {
tests[`{"rotation":"`+rotation+`"}`] = rotation
}
for config, want := range tests {
t.Run(config, func(t *testing.T) {
t.Parallel()
view := viewFor(t, database.Target{
Type: database.TargetTypeDatabase,
Config: config,
})
assert.Equal(
t, want, fieldMap(view.Config)["Archive rotation"],
)
})
}
}
func TestNewTargetViews_Log(t *testing.T) { func TestNewTargetViews_Log(t *testing.T) {
t.Parallel() t.Parallel()
@@ -418,10 +308,6 @@ func TestNewTargetViews_Unpresentable(t *testing.T) {
Type: database.TargetTypeDatabase, Type: database.TargetTypeDatabase,
Config: `{"expiry":"a fortnight"}`, Config: `{"expiry":"a fortnight"}`,
}, },
"invalid archive rotation": {
Type: database.TargetTypeDatabase,
Config: weeklyConfig,
},
} }
for name, target := range tests { for name, target := range tests {
+104 -260
View File
@@ -3,7 +3,7 @@ package delivery
import ( import (
"context" "context"
"fmt" "fmt"
"strings" "path/filepath"
"sync" "sync"
"time" "time"
@@ -11,76 +11,22 @@ import (
"sneak.berlin/go/webhooker/internal/database" "sneak.berlin/go/webhooker/internal/database"
) )
// archiveNameMaxLen is how many characters of a webhook or target // databaseTarget is a no-retry target that archives the
// name an archive file name keeps. // full inbound event into a per-webhook archive SQLite file,
const archiveNameMaxLen = 40 // separate from the per-webhook event database. The event is
// already persisted in the per-webhook event DB by the time
// databaseTarget is a no-retry target that archives the full // delivery runs; the database target additionally writes a
// inbound event into the target's own archive SQLite file, separate // durable long-term copy into archive-{webhookID}.db and then
// from the per-webhook event database. The event is already // records a single attempt whose outcome reflects whether the
// persisted in the per-webhook event DB by the time delivery runs; // archive write succeeded. See archiveWriter for the
// the database target additionally writes a durable long-term copy // close/reopen, auto-recreate, and expiry semantics.
// into the file ArchiveFileName names, with a period added when the
// target rotates (see archivePeriodPath), and then records a single
// attempt whose outcome reflects whether the archive write
// succeeded. See archiveWriter for the close/reopen, auto-recreate,
// and expiry semantics.
type databaseTarget struct { type databaseTarget struct {
eng *Engine eng *Engine
// writers holds one archive writer per database target, keyed
// by target ID.
mu sync.Mutex mu sync.Mutex
writers map[string]*archiveWriter writers map[string]*archiveWriter
} }
// ArchiveFileName 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 ArchiveFileName(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
}
// Deliver implements Target. It archives the event, then // Deliver implements Target. It archives the event, then
// records one successful attempt and marks the delivery // records one successful attempt and marks the delivery
// delivered. An archiving error fails the delivery: the // delivered. An archiving error fails the delivery: the
@@ -96,14 +42,7 @@ func (t *databaseTarget) Deliver(
_ *Task, _ *Task,
_ Scheduler, _ Scheduler,
) { ) {
start := time.Now()
err := t.archive(d) err := t.archive(d)
elapsed := time.Since(start)
t.eng.observeAttempt(d.Target.Type, elapsed)
if err != nil { if err != nil {
t.eng.log.Error( t.eng.log.Error(
"failed to archive event to database target", "failed to archive event to database target",
@@ -112,44 +51,30 @@ func (t *databaseTarget) Deliver(
"error", err, "error", err,
) )
recErr := t.eng.recordResult( t.eng.recordResult(
webhookDB, d, 1, false, 0, "", webhookDB, d, 1, false, 0, "",
err.Error(), elapsed.Milliseconds(), err.Error(), 0,
) )
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return t.eng.updateDeliveryStatus(
} webhookDB, d, database.DeliveryStatusFailed,
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusFailed,
) )
return return
} }
recErr := t.eng.recordResult( t.eng.recordResult(
webhookDB, d, 1, true, 0, "", "", webhookDB, d, 1, true, 0, "", "", 0,
elapsed.Milliseconds(),
) )
if recErr != nil {
t.eng.bookkeepingFailed(d, recErr)
return t.eng.updateDeliveryStatus(
} webhookDB, d, database.DeliveryStatusDelivered,
t.eng.settleStatus(
webhookDB, d, d.Target.Type,
database.DeliveryStatusDelivered,
) )
} }
// archive writes the full event as a row into the target's // archive writes the full event as a row into the webhook's
// archive database, honouring the optional per-target expiry and // archive database, honouring the optional per-target expiry
// rotation parsed from the target config JSON. With rotation, the // parsed from the target config JSON.
// event goes to the file for the period of its receive time.
func (t *databaseTarget) archive(d *database.Delivery) error { func (t *databaseTarget) archive(d *database.Delivery) error {
webhookID := d.Event.WebhookID webhookID := d.Event.WebhookID
if webhookID == "" { if webhookID == "" {
@@ -161,20 +86,7 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
return err return err
} }
rotation, err := parseArchiveRotation(d.Target.Config) w, err := t.writerFor(webhookID)
if err != nil {
return err
}
// An event whose stored row was gone before its delivery ran has
// no receive time (see Engine.hydrateEvent), and goes to the
// file for now.
receivedAt := d.Event.CreatedAt
if receivedAt.IsZero() {
receivedAt = time.Now()
}
w, err := t.writerFor(d.TargetID)
if err != nil { if err != nil {
return err return err
} }
@@ -189,34 +101,33 @@ func (t *databaseTarget) archive(d *database.Delivery) error {
ContentType: d.Event.ContentType, ContentType: d.Event.ContentType,
} }
return w.write(row, expiry, archivePeriod(rotation, receivedAt)) return w.write(row, expiry)
} }
// writerFor returns the archive writer for a database target, // writerFor returns the archiveWriter for a webhook, creating
// creating and caching it on first use. Each target has one writer // and caching it on first use. Each webhook has one writer so
// so its close/reopen debounce state is shared across concurrent // its close/reopen debounce state is shared across concurrent
// deliveries, and so a rename and the idle sweep take the same lock // deliveries. The archive file lives beside the per-webhook
// as its writes. // event database in the data directory.
func (t *databaseTarget) writerFor( func (t *databaseTarget) writerFor(
targetID string, webhookID string,
) (*archiveWriter, error) { ) (*archiveWriter, error) {
path, err := t.archivePath(webhookID)
if err != nil {
return nil, err
}
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
w, ok := t.writers[targetID] if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
w, ok := t.writers[webhookID]
if !ok { if !ok {
var err error w = newArchiveWriter(path, t.eng.log)
t.writers[webhookID] = w
w, err = t.newWriter(targetID)
if err != nil {
return nil, err
}
if t.writers == nil {
t.writers = make(map[string]*archiveWriter)
}
t.writers[targetID] = w
} }
// A delivery claims the entry: even if the idle sweep created // A delivery claims the entry: even if the idle sweep created
@@ -228,39 +139,40 @@ func (t *databaseTarget) writerFor(
} }
// sweepWriterFor returns the archive writer the idle sweep should // sweepWriterFor returns the archive writer the idle sweep should
// prune a target's archive through, together with whether the sweep // prune a webhook through, together with whether the sweep itself
// itself created the registry entry. // created the registry entry.
// //
// The sweep must route its prune through the registered writer so // The sweep must route its prune through the registered writer so
// the writer's mutex orders it against concurrent writes, but it // the writer's mutex orders it against concurrent writes, but it
// must never leave a registry entry behind: a sweep that ran // must never leave a registry entry behind: a sweep that ran
// concurrently with the target's deletion would otherwise // concurrently with the webhook's deletion would otherwise
// re-create an entry that nothing will ever evict again, which is // re-create an entry that nothing will ever evict again, which is
// exactly the leak eviction exists to prevent. An entry the sweep // exactly the leak eviction exists to prevent. An entry the sweep
// creates is therefore marked sweep-owned and handed back to // creates is therefore marked sweep-owned and handed back to
// releaseSweepWriter when the sweep is done. // releaseSweepWriter when the sweep is done.
func (t *databaseTarget) sweepWriterFor( func (t *databaseTarget) sweepWriterFor(
targetID string, webhookID string,
) (*archiveWriter, bool, error) { ) (*archiveWriter, bool, error) {
t.mu.Lock() path, err := t.archivePath(webhookID)
defer t.mu.Unlock()
w, ok := t.writers[targetID]
if ok {
return w, false, nil
}
w, err := t.newWriter(targetID)
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
t.mu.Lock()
defer t.mu.Unlock()
if t.writers == nil { if t.writers == nil {
t.writers = make(map[string]*archiveWriter) t.writers = make(map[string]*archiveWriter)
} }
w, ok := t.writers[webhookID]
if ok {
return w, false, nil
}
w = newArchiveWriter(path, t.eng.log)
w.sweepOwned = true w.sweepOwned = true
t.writers[targetID] = w t.writers[webhookID] = w
return w, true, nil return w, true, nil
} }
@@ -277,92 +189,57 @@ func (t *databaseTarget) sweepWriterFor(
// delivery that adopted the writer keeps a registered, evictable // delivery that adopted the writer keeps a registered, evictable
// one. // one.
func (t *databaseTarget) releaseSweepWriter( func (t *databaseTarget) releaseSweepWriter(
targetID string, w *archiveWriter, webhookID string, w *archiveWriter,
) { ) {
t.mu.Lock() t.mu.Lock()
defer t.mu.Unlock() defer t.mu.Unlock()
cur, ok := t.writers[targetID] cur, ok := t.writers[webhookID]
if !ok || cur != w || !cur.sweepOwned { if !ok || cur != w || !cur.sweepOwned {
return return
} }
delete(t.writers, targetID) delete(t.writers, webhookID)
} }
// newWriter builds the writer for a database target's archive. Its // archivePath returns the archive file path for a webhook: it
// path is the one ArchivePath gives for the webhook and the target as // lives beside the per-webhook event database in the data
// the main database names them now; from then on only rename changes // directory. It does not touch the filesystem.
// the name the writer uses. It does not touch the archive files. func (t *databaseTarget) archivePath(
func (t *databaseTarget) newWriter( webhookID string,
targetID string, ) (string, error) {
) (*archiveWriter, error) {
if t.eng.dbManager == nil { if t.eng.dbManager == nil {
return nil, errArchiveNoDataDir return "", errArchiveNoDataDir
} }
var target database.Target dir := filepath.Dir(t.eng.dbManager.DBPath(webhookID))
err := t.eng.database.DB(). return filepath.Join(
Preload("Webhook"). dir, fmt.Sprintf("archive-%s.db", webhookID),
First(&target, "id = ?", targetID).Error ), nil
if err != nil {
return nil, fmt.Errorf(
"loading database target %s: %w", targetID, err,
)
}
w := newArchiveWriter(
ArchivePath(t.eng.dbManager, &target.Webhook, &target),
t.eng.log,
)
w.webhookID = target.WebhookID
return w, nil
} }
// rename moves every one of a database target's archive files to the // evict drops a webhook's archive writer from the registry and
// name for webhookName and targetName. It goes through the target's // closes its handle, so a deleted webhook does not leave a
// writer, so the move holds the lock that writes and the idle sweep // writer (and an open archive handle within its debounce
// take, and later writes use the new name. // window) alive for the process lifetime.
//
// 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 (t *databaseTarget) rename(
targetID, webhookName, targetName string,
) error {
w, err := t.writerFor(targetID)
if err != nil {
return err
}
return w.rename(ArchiveFileName(webhookName, targetName, targetID))
}
// evict 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 // The map entry is removed under the registry lock, which is
// then released before the handle is closed under the writer's // then released before the handle is closed under the writer's
// own lock: that ordering keeps the registry available to other // own lock: that ordering keeps the registry available to other
// targets while an in-flight write on this one drains, and // webhooks while an in-flight write on this one drains, and
// closing under the writer's lock means eviction can never race // closing under the writer's lock means eviction can never race
// a write. // a write.
// //
// Eviction is idempotent and silent for a target with no writer, // Eviction is idempotent and silent for a webhook with no
// which is the common case: only a database target that has // writer, which is the common case: a webhook with no database
// received an event or been renamed has one. It never deletes the // target never creates one. It never deletes the archive file.
// archive file. func (t *databaseTarget) evict(webhookID string) {
func (t *databaseTarget) evict(targetID string) {
t.mu.Lock() t.mu.Lock()
w, ok := t.writers[targetID] w, ok := t.writers[webhookID]
if ok { if ok {
delete(t.writers, targetID) delete(t.writers, webhookID)
} }
t.mu.Unlock() t.mu.Unlock()
@@ -375,76 +252,43 @@ func (t *databaseTarget) evict(targetID string) {
t.eng.log.Info( t.eng.log.Info(
"evicted archive writer", "evicted archive writer",
"target_id", targetID, "webhook_id", webhookID,
"path", w.path, "path", w.path,
) )
} }
// evictWebhook evicts, exactly as evict does, the writer of every // sweepWebhook prunes one webhook's archive of rows older than
// database target of a webhook. // expiry, without requiring a write. It returns nil (nothing to
func (t *databaseTarget) evictWebhook(webhookID string) { // do) when the archive file does not exist, so a sweep never
t.mu.Lock() // creates an archive for a webhook that has a database target
// but has never received an event.
var gone []*archiveWriter
for targetID, w := range t.writers {
if w.webhookID == webhookID {
delete(t.writers, targetID)
gone = append(gone, w)
}
}
t.mu.Unlock()
for _, w := range gone {
w.evict()
t.eng.log.Info(
"evicted archive writer",
"webhook_id", webhookID,
"path", w.path,
)
}
}
// evictAll evicts every cached archive writer, exactly as evict
// does for one target. The 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 (t *databaseTarget) evictAll() {
t.mu.Lock()
writers := t.writers
t.writers = nil
t.mu.Unlock()
for _, w := range writers {
w.evict()
}
}
// 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 // It also never leaves a registry entry behind: an entry it had
// to create to reach the writer's mutex is released again once // to create to reach the writer's mutex is released again once
// the prune is done, so a sweep racing a target deletion cannot // the prune is done, so a sweep racing a webhook deletion cannot
// resurrect the writer the eviction just dropped. // resurrect the writer the eviction just dropped.
func (t *databaseTarget) sweepArchive( func (t *databaseTarget) sweepWebhook(
targetID string, expiry time.Duration, webhookID string, expiry time.Duration,
) error { ) error {
w, created, err := t.sweepWriterFor(targetID) path, err := t.archivePath(webhookID)
if err != nil {
return err
}
// Check before taking a writer at all: a webhook whose
// archive has never been created gets no writer, no handle,
// and no file.
if !fileExists(path) {
return nil
}
w, created, err := t.sweepWriterFor(webhookID)
if err != nil { if err != nil {
return err return err
} }
if created { if created {
defer t.releaseSweepWriter(targetID, w) defer t.releaseSweepWriter(webhookID, w)
} }
return w.sweepExpired(expiry) return w.sweepExpired(expiry)
+61 -318
View File
@@ -1,20 +1,17 @@
package delivery package delivery
import ( import (
"database/sql"
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"io/fs"
"log/slog" "log/slog"
"os" "os"
"path/filepath"
"sync" "sync"
"time" "time"
"gorm.io/driver/sqlite" "gorm.io/driver/sqlite"
"gorm.io/gorm" "gorm.io/gorm"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/gormlog"
) )
// archiveExpiryNever is the expiry sentinel (and default) that // archiveExpiryNever is the expiry sentinel (and default) that
@@ -32,18 +29,18 @@ const (
// path: open the archive file, creating it if missing, so a // path: open the archive file, creating it if missing, so a
// first write (or a write after the operator moved the file // first write (or a write after the operator moved the file
// away) recreates it. // away) recreates it.
archiveModeCreate = database.SQLiteModeCreate archiveModeCreate = "rwc"
// archiveModeExisting is the SQLite URI mode used by the idle // archiveModeExisting is the SQLite URI mode used by the idle
// sweep: open read-write but never create. A sweep must never // sweep: open read-write but never create. A sweep must never
// conjure an empty archive file for a webhook that has a // conjure an empty archive file for a webhook that has a
// database target but has never received an event. // database target but has never received an event.
archiveModeExisting = database.SQLiteModeExisting archiveModeExisting = "rw"
) )
var ( var (
// errArchiveMissingWebhookID is returned when an event to // errArchiveMissingWebhookID is returned when an event to
// archive has no webhook id to record in its archive row. // archive has no webhook id to key its archive file on.
errArchiveMissingWebhookID = errors.New( errArchiveMissingWebhookID = errors.New(
"cannot archive event without a webhook id", "cannot archive event without a webhook id",
) )
@@ -63,19 +60,13 @@ var (
) )
// errArchiveWriterEvicted is returned when a writer that has // errArchiveWriterEvicted is returned when a writer that has
// been evicted (its target or its webhook was deleted) is used // been evicted (its webhook was deleted, or its last database
// again. An evicted writer is no longer in the registry, so // target was removed) is used again. An evicted writer is no
// reopening its file would leak a handle nothing owns. // longer in the registry, so reopening its file would leak a
// handle nothing owns.
errArchiveWriterEvicted = errors.New( errArchiveWriterEvicted = errors.New(
"archive writer has been evicted", "archive writer has been evicted",
) )
// ErrArchiveNameTaken 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.
ErrArchiveNameTaken = errors.New(
"a file already has the archive's new name",
)
) )
// databaseTargetConfig is the optional per-target JSON config // databaseTargetConfig is the optional per-target JSON config
@@ -85,14 +76,10 @@ type databaseTargetConfig struct {
// archived rows are pruned, or "never" (the default) to // archived rows are pruned, or "never" (the default) to
// keep them forever. // keep them forever.
Expiry string `json:"expiry"` 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 // archivedEvent is one fully captured webhook event stored in a
// database target's archive for long-term retention. It is a // per-webhook archive database for long-term retention. It is a
// self-contained copy — independent of the per-webhook event // self-contained copy — independent of the per-webhook event
// database, which may prune events under its own retention. // database, which may prune events under its own retention.
type archivedEvent struct { type archivedEvent struct {
@@ -182,50 +169,33 @@ func ValidateArchiveExpiry(expiry string) error {
return nil return nil
} }
// archiveWriter owns one database target's archive SQLite files. // archiveWriter owns one per-webhook archive SQLite file. It
// It serialises writes, and after each write closes and reopens // serialises writes, and after each write closes and reopens
// the file (debounced to at most once per debounce window) so // the file (debounced to at most once per debounce window) so
// an operator can move the file away for offline archiving. The // an operator can move the file away for offline archiving. The
// next write recreates a moved or removed file, because the // next write recreates a moved or removed file, because the
// file is opened create-if-missing and its schema is migrated // file is opened create-if-missing and its schema is migrated
// on every open. // on every open.
type archiveWriter struct { type archiveWriter struct {
mu sync.Mutex mu sync.Mutex
path string
// path is the target's archive file as ArchivePath 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 log *slog.Logger
debounce time.Duration debounce time.Duration
db *gorm.DB db *gorm.DB
lastReopen time.Time lastReopen time.Time
reopens int 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 // evicted marks a writer that has been removed from the
// registry. Its handle is closed and it must never open the // per-webhook registry. Its handle is closed and it must
// file again: nothing holds it any more, so a reopen would // never open the file again: nothing holds it any more, so a
// leak the handle for the process lifetime. // reopen would leak the handle for the process lifetime.
evicted bool 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 // sweepOwned marks a registry entry that the idle sweep
// created because no writer was cached for the target. The // created because no writer was cached for the webhook. The
// sweep removes such an entry again when it is done, so a // sweep removes such an entry again when it is done, so a
// sweep can never leave — or resurrect — a registry entry // sweep can never leave — or resurrect — a registry entry
// for a target that has been deleted. A delivery that adopts // for a webhook that has been deleted. A delivery that adopts
// the writer clears the flag, handing the entry to the // the writer clears the flag, handing the entry to the
// registry proper. // registry proper.
// //
@@ -244,18 +214,15 @@ func newArchiveWriter(
path: path, path: path,
log: log, log: log,
debounce: archiveReopenDebounce, debounce: archiveReopenDebounce,
now: time.Now,
} }
} }
// write appends the event as a row to the archive file for period // write appends the event as a row, then applies the debounced
// (see archivePeriodPath), then applies the debounced close/reopen. // close/reopen. It recreates the archive file if it was moved
// When period names a different file from the one open, the open one // or removed since the last open. A positive expiry prunes rows
// is closed first. It recreates the archive file if it was moved or // older than it on each (re)open.
// removed since the last open. A positive expiry prunes rows older
// than it on each (re)open.
func (w *archiveWriter) write( func (w *archiveWriter) write(
row archivedEvent, expiry time.Duration, period string, row archivedEvent, expiry time.Duration,
) error { ) error {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -266,10 +233,8 @@ func (w *archiveWriter) write(
) )
} }
file := archivePeriodPath(w.path, period) if w.db == nil || !fileExists(w.path) {
err := w.reopen(expiry)
if w.db == nil || w.current != file || !fileExists(file) {
err := w.reopen(file, expiry)
if err != nil { if err != nil {
return err return err
} }
@@ -280,57 +245,51 @@ func (w *archiveWriter) write(
err := w.db.Create(&row).Error err := w.db.Create(&row).Error
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"archiving event to %s: %w", file, err, "archiving event to %s: %w", w.path, err,
) )
} }
if w.now().Sub(w.lastReopen) >= w.debounce { if time.Since(w.lastReopen) >= w.debounce {
return w.reopen(file, expiry) return w.reopen(expiry)
} }
return nil return nil
} }
// open opens (creating if missing) an archive file, migrates // open opens (creating if missing) the archive file, migrates
// its schema, records the reopen time, and prunes expired rows // its schema, records the reopen time, and prunes expired rows
// when expiry is positive. // when expiry is positive.
func (w *archiveWriter) open(file string, expiry time.Duration) error { func (w *archiveWriter) open(expiry time.Duration) error {
return w.openMode(file, archiveModeCreate, expiry) return w.openMode(archiveModeCreate, expiry)
} }
// openMode opens an archive file with the given SQLite URI // openMode opens the archive file with the given SQLite URI
// mode, migrates its schema, records the reopen time, and // mode, migrates its schema, records the reopen time, and
// prunes expired rows when expiry is positive. The write path // prunes expired rows when expiry is positive. The write path
// passes archiveModeCreate so a missing file is recreated; the // passes archiveModeCreate so a missing file is recreated; the
// idle sweep passes archiveModeExisting so a missing file is an // idle sweep passes archiveModeExisting so a missing file is an
// error rather than a newly conjured empty archive. // error rather than a newly conjured empty archive.
func (w *archiveWriter) openMode( func (w *archiveWriter) openMode(
file, mode string, expiry time.Duration, mode string, expiry time.Duration,
) error { ) error {
// Opened through database.OpenSQLite so an archive file carries dbURL := fmt.Sprintf("file:%s?mode=%s", w.path, mode)
// the same WAL journaling, busy timeout, immediate-transaction
// locking, and pool bounds as every other database file. See sqlDB, err := sql.Open("sqlite", dbURL)
// internal/database/sqlite_open.go.
sqlDB, err := database.OpenSQLite(file, mode)
if err != nil { if err != nil {
return fmt.Errorf( return fmt.Errorf(
"opening archive database %s: %w", file, err, "opening archive database %s: %w", w.path, err,
) )
} }
gdb, err := gorm.Open( gdb, err := gorm.Open(
sqlite.Dialector{Conn: sqlDB}, &gorm.Config{ sqlite.Dialector{Conn: sqlDB}, &gorm.Config{},
// Never leave this at GORM's default. See
// internal/gormlog.
Logger: gormlog.New(w.log),
},
) )
if err != nil { if err != nil {
_ = sqlDB.Close() _ = sqlDB.Close()
return fmt.Errorf( return fmt.Errorf(
"connecting to archive database %s: %w", "connecting to archive database %s: %w",
file, err, w.path, err,
) )
} }
@@ -339,13 +298,12 @@ func (w *archiveWriter) openMode(
_ = sqlDB.Close() _ = sqlDB.Close()
return fmt.Errorf( return fmt.Errorf(
"migrating archive database %s: %w", file, err, "migrating archive database %s: %w", w.path, err,
) )
} }
w.db = gdb w.db = gdb
w.current = file w.lastReopen = time.Now()
w.lastReopen = w.now()
w.reopens++ w.reopens++
if expiry > 0 { if expiry > 0 {
@@ -355,12 +313,12 @@ func (w *archiveWriter) openMode(
return nil return nil
} }
// reopen closes any open handle and opens file afresh. The // reopen closes any open handle and opens the file afresh. The
// fresh open recreates the file if it was moved away. // fresh open recreates the file if it was moved away.
func (w *archiveWriter) reopen(file string, expiry time.Duration) error { func (w *archiveWriter) reopen(expiry time.Duration) error {
w.close() w.close()
return w.open(file, expiry) return w.open(expiry)
} }
// close closes the underlying handle, if any. // close closes the underlying handle, if any.
@@ -377,56 +335,22 @@ func (w *archiveWriter) close() {
w.db = nil w.db = nil
} }
// sweepExpired prunes the target's archive files, which may have // sweepExpired prunes an archive that may have gone idle, with
// gone idle, with no write to trigger the usual on-reopen prune. It // no write to trigger the usual on-reopen prune. It takes the
// lists the files under the writer's own mutex, then takes the mutex // writer's own mutex for the whole operation, so a sweep is
// again for one file at a time, so a write waits for at most one // ordered against concurrent writes rather than reaching around
// file's prune, and each prune is ordered against concurrent writes // them to the file.
// rather than reaching around them to the file.
// //
// It never creates an archive file: it prunes only the files // It never creates the archive file: a missing file is skipped,
// archiveFiles lists, skips one that is gone by the time it is // and the reopen uses archiveModeExisting so SQLite itself
// reached (moved away, or renamed since the listing), and opens each // refuses to create one if the file disappears between the
// with archiveModeExisting so SQLite itself refuses to create one if // check and the open.
// 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 // The archive is left CLOSED afterwards. An idle archive holding
// no handle is what keeps the operator's move-the-file-away // no handle is what keeps the operator's move-the-file-away
// workflow working; the next write reopens (and recreates) the // workflow working; the next write reopens (and recreates) the
// file as it always has. // file as it always has.
func (w *archiveWriter) sweepExpired(expiry time.Duration) error { 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() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -436,7 +360,7 @@ func (w *archiveWriter) sweepFile(
) )
} }
if !fileExists(file.path) { if !fileExists(w.path) {
return nil return nil
} }
@@ -444,133 +368,21 @@ func (w *archiveWriter) sweepFile(
// freshly opened file, matching the write path's semantics. // freshly opened file, matching the write path's semantics.
w.close() w.close()
err := w.openMode(file.path, archiveModeExisting, expiry) err := w.openMode(archiveModeExisting, expiry)
if err != nil { if err != nil {
return err return err
} }
if file.period == "" {
w.close()
return nil
}
var rows int64
err = w.db.Model(&archivedEvent{}).Count(&rows).Error
w.close() 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 ErrArchiveNameTaken. 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", ErrArchiveNameTaken, 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 return nil
} }
// evict closes the writer's handle and marks it unusable. It is // evict closes the writer's handle and marks it unusable. It is
// called when the writer leaves the registry, because its target // called when the writer leaves the registry, either because the
// or its webhook was deleted, or at shutdown. The archive FILE is // webhook was deleted or because its last database target was
// deliberately left on disk: it is long-term storage an operator // removed. The archive FILE is deliberately left on disk: it is
// may still want. // long-term storage an operator may still want.
func (w *archiveWriter) evict() { func (w *archiveWriter) evict() {
w.mu.Lock() w.mu.Lock()
defer w.mu.Unlock() defer w.mu.Unlock()
@@ -595,7 +407,7 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.Error != nil { if res.Error != nil {
w.log.Error( w.log.Error(
"failed to prune expired archive rows", "failed to prune expired archive rows",
"path", w.current, "path", w.path,
"error", res.Error, "error", res.Error,
) )
@@ -605,81 +417,12 @@ func (w *archiveWriter) prune(expiry time.Duration) {
if res.RowsAffected > 0 { if res.RowsAffected > 0 {
w.log.Info( w.log.Info(
"pruned expired archive rows", "pruned expired archive rows",
"path", w.current, "path", w.path,
"rows_deleted", res.RowsAffected, "rows_deleted", res.RowsAffected,
) )
} }
} }
// ArchiveFileInfo is what the metadata of a database target's archive
// files says about them.
type ArchiveFileInfo 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
}
// StatArchive reads the metadata of a database target's archive
// files, given the path ArchivePath 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 StatArchive(path string) (ArchiveFileInfo, error) {
files, err := archiveFiles(path)
if err != nil {
return ArchiveFileInfo{}, err
}
var info ArchiveFileInfo
for _, file := range files {
db, err := os.Stat(file.path)
if errors.Is(err, fs.ErrNotExist) {
continue
}
if err != nil {
return ArchiveFileInfo{}, 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 ArchiveFileInfo{}, err
}
info.Size += wal.Size()
if wal.ModTime().After(info.Written) {
info.Written = wal.ModTime()
}
}
if info.Files == 0 {
return ArchiveFileInfo{}, fmt.Errorf(
"no archive file for %s: %w", path, fs.ErrNotExist,
)
}
return info, nil
}
// fileExists reports whether a path currently exists. // fileExists reports whether a path currently exists.
func fileExists(path string) bool { func fileExists(path string) bool {
_, err := os.Stat(path) _, err := os.Stat(path)
@@ -1,155 +0,0 @@
package delivery_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/delivery"
"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/delivery/target_database_archive.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/delivery/target_database_archive.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 := delivery.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 delivery.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)],
)
}
}
+73 -127
View File
@@ -1,7 +1,6 @@
package delivery_test package delivery_test
import ( import (
"context"
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
@@ -17,109 +16,85 @@ import (
"sneak.berlin/go/webhooker/internal/delivery" "sneak.berlin/go/webhooker/internal/delivery"
) )
// deliverTo archives one event to a database target, leaving the // evictTestEngine builds an engine backed by a temporary data
// target's writer cached with its handle open. // directory and returns it along with that directory.
func deliverTo( func evictTestEngine(t *testing.T) (*delivery.Engine, string) {
t *testing.T, env *archiveEnv, tgt *database.Target,
) {
t.Helper() t.Helper()
webhookDB := testWebhookDB(t) dataDir := t.TempDir()
event := seedEvent(t, webhookDB, `{"archived":true}`)
env.eng.ExportDeliverDatabase( eng := delivery.NewTestEngineWithDB(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt), nil,
database.NewTestWebhookDBManager(dataDir),
archiveTestLogger(),
&http.Client{Timeout: 5 * time.Second},
1,
) )
return eng, dataDir
} }
// TestEvictWebhook_ClosesAndRemovesWriter proves that evicting // TestEvictWebhook_ClosesAndRemovesWriter proves that evicting
// a webhook drops the archive writers of its database targets // a webhook drops its archive writer from the registry and
// from the registry and closes their open handles, rather than // closes the open archive handle, rather than leaving both
// leaving them alive for the process lifetime, and leaves another // alive for the process lifetime.
// webhook's writer alone.
func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) { func TestEvictWebhook_ClosesAndRemovesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, dataDir := evictTestEngine(t)
first := env.seedDatabaseTarget(t, "")
second := env.addDatabaseTarget(t, first.WebhookID, "")
other := env.seedDatabaseTarget(t, "")
for _, tgt := range []*database.Target{first, second, other} { webhookDB := testWebhookDB(t)
deliverTo(t, env, tgt) event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
require.True( eng.ExportDeliverDatabase(webhookDB, d)
t, env.eng.ExportArchiveHandleOpen(tgt.ID),
"the writer should hold an open handle after a write",
)
}
env.eng.EvictWebhook(first.WebhookID) webhookID := event.WebhookID
for _, tgt := range []*database.Target{first, second} { require.True(
assert.False( t, eng.ExportHasArchiveWriter(webhookID),
t, env.eng.ExportHasArchiveWriter(tgt.ID), "a delivery should have cached an archive writer",
"eviction should remove the registry entry", )
) require.True(
assert.False( t, eng.ExportArchiveHandleOpen(webhookID),
t, env.eng.ExportArchiveHandleOpen(tgt.ID), "the writer should hold an open handle after a write",
"eviction should close the archive handle",
)
assert.FileExists(
t, env.archivePath(tgt),
"eviction must not delete the archive file",
)
}
assert.True(
t, env.eng.ExportArchiveHandleOpen(other.ID),
"another webhook's writer must be left alone",
) )
}
// TestEvictTarget_LeavesOtherTargets proves that evicting one eng.EvictWebhook(webhookID)
// 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) assert.False(
doomed := env.seedDatabaseTarget(t, "") t, eng.ExportHasArchiveWriter(webhookID),
kept := env.addDatabaseTarget(t, doomed.WebhookID, "") "eviction should remove the registry entry",
)
assert.False(
t, eng.ExportArchiveHandleOpen(webhookID),
"eviction should close the archive handle",
)
deliverTo(t, env, doomed) archivePath := filepath.Join(
deliverTo(t, env, kept) dataDir, fmt.Sprintf("archive-%s.db", webhookID),
)
env.eng.EvictTarget(doomed.ID)
assert.False(t, env.eng.ExportHasArchiveWriter(doomed.ID))
assert.FileExists( assert.FileExists(
t, env.archivePath(doomed), t, archivePath,
"eviction must not delete the archive file", "eviction must not delete the archive file",
) )
assert.True(
t, env.eng.ExportArchiveHandleOpen(kept.ID),
"the other target's writer must be left alone",
)
} }
// TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe // TestEvictWebhook_UnknownWebhookIsNoOp proves eviction is safe
// for the common case of a webhook or target that never had an // for the common case of a webhook that never had a database
// archive writer, and that repeating it does not panic. // target, and that repeating it does not panic.
func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) { func TestEvictWebhook_UnknownWebhookIsNoOp(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
assert.NotPanics(t, func() { assert.NotPanics(t, func() {
env.eng.EvictWebhook("no-such-webhook") eng.EvictWebhook("no-such-webhook")
env.eng.EvictWebhook("no-such-webhook") eng.EvictWebhook("no-such-webhook")
env.eng.EvictTarget("no-such-target")
env.eng.EvictTarget("no-such-target")
}) })
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter("no-such-target"), t, eng.ExportHasArchiveWriter("no-such-webhook"),
"eviction must not create a writer", "eviction must not create a writer",
) )
} }
@@ -313,14 +288,17 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
) { ) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
tgt := env.seedDatabaseTarget(t, "")
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
// Prime the registry so the test can hold the very writer the // Prime the registry so the test can hold the very writer the
// eviction is about to detach. // eviction is about to detach.
deliverTo(t, env, tgt) eng.ExportDeliverDatabase(webhookDB, d)
w := env.eng.ExportArchiveWriterFor(tgt.ID) w := eng.ExportArchiveWriterFor(event.WebhookID)
require.NotNil(t, w) require.NotNil(t, w)
require.True(t, w.HandleOpen()) require.True(t, w.HandleOpen())
@@ -330,7 +308,7 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
// eviction has to contend for the writer's mutex. // eviction has to contend for the writer's mutex.
race.awaitFirstWrite() race.awaitFirstWrite()
env.eng.EvictWebhook(tgt.WebhookID) eng.EvictWebhook(event.WebhookID)
sawEvicted, otherErr := race.wait() sawEvicted, otherErr := race.wait()
@@ -345,73 +323,41 @@ func TestEvictWebhook_RacingWriteDoesNotReopenHandle(
"been evicted", "been evicted",
) )
assert.False( assert.False(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, eng.ExportHasArchiveWriter(event.WebhookID),
"the registry entry must stay gone", "the registry entry must stay gone",
) )
} }
// TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction // TestEvictWebhook_LaterDeliveryRecreatesWriter proves eviction
// does not break archiving for a target that is still alive: a // does not break archiving for a webhook that is still alive: a
// subsequent delivery gets a brand new writer from the registry. // subsequent delivery gets a brand new writer from the registry.
// It says nothing about the evicted writer itself — that is what // It says nothing about the evicted writer itself — that is what
// TestEvictedWriter_WriteDoesNotReopenFile covers. // TestEvictedWriter_WriteDoesNotReopenFile covers.
func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) { func TestEvictWebhook_LaterDeliveryRecreatesWriter(t *testing.T) {
t.Parallel() t.Parallel()
env := setupArchiveTest(t) eng, _ := evictTestEngine(t)
tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt) webhookDB := testWebhookDB(t)
require.True(t, env.eng.ExportHasArchiveWriter(tgt.ID)) event := seedEvent(t, webhookDB, `{"archived":true}`)
d := seedDatabaseTargetDelivery(t, webhookDB, event, "")
env.eng.EvictWebhook(tgt.WebhookID) eng.ExportDeliverDatabase(webhookDB, d)
require.True(
t, eng.ExportHasArchiveWriter(event.WebhookID),
)
// A fresh delivery for the same target gets a brand new eng.EvictWebhook(event.WebhookID)
// A fresh delivery for the same webhook gets a brand new
// writer from the registry, so archiving keeps working. // writer from the registry, so archiving keeps working.
deliverTo(t, env, tgt) second := seedDatabaseTargetDelivery(
t, webhookDB, event, "",
)
eng.ExportDeliverDatabase(webhookDB, second)
assert.True( assert.True(
t, env.eng.ExportHasArchiveWriter(tgt.ID), t, eng.ExportHasArchiveWriter(event.WebhookID),
"a later delivery should recreate the writer", "a later delivery should recreate the writer",
) )
} }
// TestEngineStop_WriteAfterStopIsRefused proves the engine's stop
// closes each archive writer the way deleting its webhook does: a
// write that reaches a writer after the stop is refused, reopens
// nothing and adds no row.
func TestEngineStop_WriteAfterStopIsRefused(t *testing.T) {
t.Parallel()
env := setupArchiveTest(t)
tgt := env.seedDatabaseTarget(t, "")
deliverTo(t, env, tgt)
w := env.eng.ExportArchiveWriterFor(tgt.ID)
require.NotNil(t, w)
require.True(t, w.HandleOpen())
require.NoError(t, env.eng.ExportStop(context.Background()))
err := w.Write(evictTestRow("ev-after-stop"), 0)
require.ErrorIs(
t, err, delivery.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.eng.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",
)
}
-386
View File
@@ -1,386 +0,0 @@
package delivery
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'"
// ArchivePath returns where a database target's archive file is: in
// the data directory, beside the webhook's event database, under the
// name ArchiveFileName gives it.
func ArchivePath(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
) string {
return filepath.Join(
filepath.Dir(dbMgr.DBPath(webhook.ID)),
ArchiveFileName(webhook.Name, target.Name, target.ID),
)
}
// ArchiveExportFileName returns the name a database target's archive
// downloads under:
// archive-WEBHOOKNAME-TARGETNAME-YYYYMMDDTHHMMSSZ.json.gz, the names
// made safe as in ArchiveFileName and the time in UTC.
func ArchiveExportFileName(
webhookName, targetName string, at time.Time,
) string {
return "archive-" + archiveNamePart(webhookName) + "-" +
archiveNamePart(targetName) + "-" +
at.UTC().Format("20060102T150405Z") + ".json.gz"
}
// ArchiveExport 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 ArchiveExport 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 ArchivePath 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"`
}
// NewArchiveExport lists a database target's archive files for export,
// given the path ArchivePath gives it (see archiveFiles). It opens none
// of them. Its caller holds lock, which every rename of the target's
// files runs under, 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 NewArchiveExport(
path string,
lock sync.Locker,
currentPath func() (string, error),
log *slog.Logger,
) (*ArchiveExport, error) {
files, err := archiveFiles(path)
if err != nil {
return nil, err
}
x := &ArchiveExport{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 *ArchiveExport) 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 *ArchiveExport) 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 *ArchiveExport) 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 *ArchiveExport) 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,
"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
}
@@ -1,563 +0,0 @@
package delivery_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/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// 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 *delivery.ArchiveExport, 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) *delivery.ArchiveExport {
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),
) *delivery.ArchiveExport {
t.Helper()
export, err := delivery.NewArchiveExport(
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 *delivery.ArchiveExport,
) 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 := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
bodies := []string{`{"order":1}`, "plain text", "", binaryBody}
for i, body := range bodies {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{
EventID: fmt.Sprintf("ev-%d", i),
WebhookID: exportWebhookID,
EntrypointID: "ep-1",
Method: "POST",
Headers: `{"X-Test":["yes"]}`,
Body: body,
ContentType: testContentType,
}, 0))
}
var stored []delivery.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 delivery.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.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, 9, "the nine 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, 10, "the nine 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,
delivery.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 := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "listed"}, 0))
opened := &unlockHook{fn: func() {
require.NoError(t, w.Write(delivery.ExportArchivedEvent{EventID: "during"}, 0))
}}
export := newExport(t, path, opened, func() (string, error) {
return path, nil
})
require.NoError(t, w.Write(delivery.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(&delivery.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 := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
for _, period := range []string{"", dayPeriod, hourPeriod} {
require.NoError(t, w.WritePeriod(
delivery.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 := delivery.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(
delivery.ExportArchivedEvent{EventID: "in-" + period}, 0, period,
))
}
export := listExport(t, path)
require.NoError(t, w.WritePeriod(
delivery.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 := delivery.NewExportArchiveWriter(path, archiveTestLogger(), 0)
random := make([]byte, 36<<10)
for hour := range 24 {
_, _ = rand.Read(random)
require.NoError(t, w.WritePeriod(delivery.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 before each
// reading, so the heap it reads is what is still held.
type heapPeak struct {
max uint64
}
func (p *heapPeak) Write(b []byte) (int, error) {
var m runtime.MemStats
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 := delivery.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(delivery.ExportArchivedEvent{
Body: base64.StdEncoding.EncodeToString(random),
}, 0))
}
export := listExport(t, path)
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",
delivery.ArchiveExportFileName(
exportWebhookName, exportTargetName,
time.Date(2026, 10, 2, 14, 3, 4, 0, cest),
),
)
}
@@ -1,198 +0,0 @@
package delivery
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
}
// ValidateArchiveRotation checks a user-supplied archive rotation for
// a database target: empty or none (both meaning one file), monthly,
// daily or hourly.
func ValidateArchiveRotation(rotation string) error {
switch rotation {
case "", archiveRotationNone, archiveRotationMonthly,
archiveRotationDaily, archiveRotationHourly:
return nil
default:
return fmt.Errorf("%w: %q", errArchiveRotationUnknown, rotation)
}
}
// parseArchiveRotation reads the rotation from a database target's
// config JSON. An empty config or an empty rotation is none.
func parseArchiveRotation(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 = ValidateArchiveRotation(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 ArchivePath 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"
}
// ArchivePathAt returns the archive file a database target writes an
// event received at receivedAt to: ArchivePath's file, with the
// period in its name when the target rotates.
func ArchivePathAt(
dbMgr *database.WebhookDBManager,
webhook *database.Webhook,
target *database.Target,
receivedAt time.Time,
) (string, error) {
rotation, err := parseArchiveRotation(target.Config)
if err != nil {
return "", err
}
return archivePeriodPath(
ArchivePath(dbMgr, webhook, target),
archivePeriod(rotation, receivedAt),
), nil
}
// archiveFiles lists the database target's archive files that exist,
// given the path ArchivePath 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
}
@@ -1,389 +0,0 @@
package delivery_test
import (
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/webhooker/internal/database"
"sneak.berlin/go/webhooker/internal/delivery"
)
// 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 delivers to a database target 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()
webhookDB := testWebhookDB(t)
event := seedEvent(t, webhookDB, `{"n":1}`)
event.CreatedAt = receivedAt
env.eng.ExportDeliverDatabase(
webhookDB, seedDatabaseTargetDelivery(t, webhookDB, event, tgt),
)
return event.ID
}
// TestDeliverDatabase_RotatesAtEachPeriodBoundary delivers, 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 TestDeliverDatabase_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)))
})
}
// TestDeliverDatabase_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 TestDeliverDatabase_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"))
}
// TestDeliverDatabase_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 TestDeliverDatabase_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.eng.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.eng.Rename(tgt.ID, "Orders", "Long Term"),
delivery.ErrArchiveNameTaken,
)
assert.FileExists(t, periodPath(oldPath, dayPeriod))
assert.FileExists(t, periodPath(oldPath, nextDayPeriod))
assert.NoFileExists(t, periodPath(newPath, dayPeriod))
}
// TestStatArchive_EveryFile proves StatArchive 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 := delivery.StatArchive(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 := database.NewTestWebhookDBManager(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 := delivery.ArchivePathAt(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 := delivery.ArchivePathAt(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, delivery.ValidateArchiveRotation(ok), ok)
}
for _, bad := range []string{"weekly", "Daily", "hourly "} {
require.Error(t, delivery.ValidateArchiveRotation(bad), bad)
}
}

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